From c9014c7e032ffd7ef2f8f2ee813aa3a072ae97c1 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:21:36 +0800 Subject: [PATCH 01/94] fix(runtime): fail closed on ABI handshake and correct KPM argv contract --- module/service.sh | 142 ++++++++++++++++------------------------------ 1 file changed, 50 insertions(+), 92 deletions(-) diff --git a/module/service.sh b/module/service.sh index ded5a43..52d3d3c 100644 --- a/module/service.sh +++ b/module/service.sh @@ -4,91 +4,47 @@ MODDIR=${0%/*} PNDIR="/data/adb/patchnest" PATH="$MODDIR/bin:$PATH" CONFIG="$PNDIR/package_config" -# P1-fix (ultracode-audit-2026-06-06): quote $PNDIR. If the path -# ever contains a space (custom user layout, su bind-mount trick, -# or future Magisk layout change) the previous form would word-split -# into two paths and `cat` would error out — masking the real -# config. The sanitization of $REHOOK below also defends against -# a hostile $PNDIR/rehook file (only off|enable|disable|empty -# are accepted). REHOOK="$(cat "$PNDIR/rehook" 2>/dev/null || true)" LOG="$PNDIR/service.log" KPM_DIR="$PNDIR/kpm" KPM_EVENT_DIR="$PNDIR/kpm_events" -# Helper: read a key from module.prop get_prop() { grep "^${1}=" "$2" 2>/dev/null | head -1 | cut -d'=' -f2- } -# Read the global PatchNest config file. This is a simple KEY=VALUE -# file; we only look at the keys we care about and treat anything else -# as future work. The file may not exist on first-run / legacy installs; -# defaults below are chosen to preserve pre-signature behavior. -# -# KPM_SIGNATURE_POLICY controls how unsigned / unverified KPM modules -# are handled at boot. Three modes: -# -# off — Never check signatures. Unsigned modules load silently. -# This is the default; preserves pre-v0.3.0 behavior. -# warn — Load unsigned modules but emit a visible warning to the -# service log and to the WebUI. Recommended for users who -# want to develop / embed their own KPMs without giving up -# the safety net of seeing which ones are unsigned. -# strict — Refuse to load any KPM that is not accompanied by a valid -# .kpm.sig file. Strictest; use only after all your KPMs -# are signed. -# -# The policy can be set in /data/adb/patchnest/config, e.g.: -# KPM_SIGNATURE_POLICY=warn -# or toggled from the WebUI Settings page. KPN_CONFIG="$PNDIR/config" -KPM_SIGNATURE_POLICY=off +# Review branch default: surface unsigned KPMs instead of silently loading +# them. Users can still choose off explicitly while developing local KPMs. +KPM_SIGNATURE_POLICY=warn if [ -f "$KPN_CONFIG" ]; then - # Tolerate comments, blank lines, and `export ` prefixes. _val=$(grep -E '^[[:space:]]*(export[[:space:]]+)?KPM_SIGNATURE_POLICY[[:space:]]*=' \ "$KPN_CONFIG" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//' | tr -d '"\r\n' | tr 'A-Z' 'a-z') case "$_val" in off|warn|strict) KPM_SIGNATURE_POLICY="$_val" ;; 0|false) KPM_SIGNATURE_POLICY=off ;; 1|true|yes|on) KPM_SIGNATURE_POLICY=strict ;; - *) KPM_SIGNATURE_POLICY=off ;; + *) KPM_SIGNATURE_POLICY=warn ;; esac fi -# Map the policy to the legacy boolean expected by the existing logic, -# and expose a third state. All actual decisions are made on the -# string policy below; the boolean is kept for logging only. case "$KPM_SIGNATURE_POLICY" in off) REQUIRE_KPM_SIGNATURES=0 ;; warn|strict) REQUIRE_KPM_SIGNATURES=1 ;; - *) REQUIRE_KPM_SIGNATURES=0 ;; + *) REQUIRE_KPM_SIGNATURES=1 ;; esac -# Source the KPM signature verifier. It is a no-op cost when -# KPM_SIGNATURE_POLICY=off (we never call it below). The verifier -# exposes `verify_kpm_sig ` which returns 0/1. # shellcheck disable=SC1091 . "$MODDIR/kpm_verify.sh" 2>/dev/null || true -# Rotate log on boot mkdir -p "$PNDIR" "$KPM_DIR/failed" "$KPM_EVENT_DIR" echo "=== $(date) service.sh started ===" > "$LOG" echo "[$(date)] MODDIR=$MODDIR" >> "$LOG" echo "[$(date)] PATH=$PATH" >> "$LOG" echo "[$(date)] KPM_SIGNATURE_POLICY=$KPM_SIGNATURE_POLICY" >> "$LOG" -# Detect root manager ROOT_MGR="unknown" if [ -f "$PNDIR/root_manager" ]; then - # P1-fix (ultracode-audit-2026-06-06): quote $PNDIR in the cat - # call, and sanitize the value to a safe character class. The - # /data/adb/patchnest/root_manager file is written by customize.sh - # (only 'apatch'|'ksu'|'magisk'|'unknown' values), but if a - # future installer writes a tampered value here, an unquoted - # expansion could break later `case` statements. The whitelist - # sanitization prevents the value from containing shell - # metacharacters that could affect any downstream use. _rm_raw="$(cat "$PNDIR/root_manager" 2>/dev/null || true)" _rm_sane="$(printf '%s' "$_rm_raw" | tr -cd 'a-z')" if [ -n "$_rm_sane" ]; then @@ -97,73 +53,59 @@ if [ -f "$PNDIR/root_manager" ]; then fi echo "[$(date)] root_manager=$ROOT_MGR" >> "$LOG" -# Check if kpatch binary exists and is executable if [ ! -x "$MODDIR/bin/kpatch" ]; then echo "[$(date)] ERROR: kpatch binary not found or not executable" >> "$LOG" touch "$MODDIR/unresolved" exit 0 fi -# Retry kpatch hello (P1-Cluster D: increase retries 3->5 for slow devices, -# and require both 'hello' exit code 0 AND non-empty output, to avoid -# treating a stuck kernel as "ready".) +# kpatch hello is the package-level ABI readiness gate. The hardened CLI now +# returns non-zero when the syscall fails or the kernel handshake magic does +# not match, so do not treat an empty/foreign handshake as success. retries=0 max_retries=5 -while [ $retries -lt $max_retries ]; do - if kpatch hello >/dev/null 2>&1; then +while [ "$retries" -lt "$max_retries" ]; do + hello_out="$(kpatch hello 2>>"$LOG")" + if [ $? -eq 0 ] && [ -n "$hello_out" ]; then break fi echo "[$(date)] kpatch hello attempt $((retries + 1)) failed, retrying..." >> "$LOG" sleep 2 retries=$((retries + 1)) done -if ! kpatch hello >/dev/null 2>&1; then - echo "[$(date)] kpatch hello failed after $retries retries" >> "$LOG" - echo "[$(date)] Kernel may not be patched yet. Open WebUI and click Start." >> "$LOG" +hello_out="$(kpatch hello 2>>"$LOG")" +if [ $? -ne 0 ] || [ -z "$hello_out" ]; then + echo "[$(date)] ERROR: kpatch/kernel ABI handshake failed after $retries retries" >> "$LOG" + echo "[$(date)] Refusing KPM/exclude/rehook operations; package is unresolved." >> "$LOG" touch "$MODDIR/unresolved" exit 0 fi -echo "[$(date)] kpatch hello OK" >> "$LOG" +echo "[$(date)] kpatch hello OK: $hello_out" >> "$LOG" -# Bootloop Auto-Recovery: healthy boot detected — reset the counter -# and clear any auto-recovery markers so we don't trigger unpatch. +# Healthy userspace/kernel handshake. This only clears the userspace marker; +# it does not claim physical boot-loop recovery has been validated. echo "0" > "$PNDIR/boot_count" 2>/dev/null rm -f "$PNDIR/autorecovery_active" "$PNDIR/auto_unpatch_requested" -# Safe KPM load -# Use a literal-glob test: when the directory is empty, the shell returns -# the pattern itself unchanged. The [ -e ] check then correctly skips it, -# avoiding the bug where the old [ -s ] guard would test the wrong path. for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do [ -e "$kpm" ] || continue [ -s "$kpm" ] || continue mod_basename=$(basename "$kpm" | sed 's/\.\(kpm\|ko\|o\)$//') args="" if [ -f "$KPM_EVENT_DIR/${mod_basename}.args" ]; then - # P0-8 security fix: the .args file lives under KPM_EVENT_DIR and is - # writable by anything running as root. Restrict to a safe character - # class so that a stray shell metacharacter cannot become an extra - # argument to `kpatch kpm load`. raw_args="$(cat "$KPM_EVENT_DIR/${mod_basename}.args" 2>/dev/null || true)" args="$(printf '%s' "$raw_args" | tr -cd 'A-Za-z0-9_=,.+:/@% -')" fi - # --- KPM signature verification (policy-controlled) -------------------- - # off → skip all checks, log nothing. - # warn → allow unsigned, but log + flag for the WebUI. - # strict → reject unsigned / invalid. _kpm_sig="$KPM_DIR/${mod_basename}.kpm.sig" if [ "$KPM_SIGNATURE_POLICY" != "off" ]; then if [ ! -f "$_kpm_sig" ]; then - # No .kpm.sig file present. if [ "$KPM_SIGNATURE_POLICY" = "strict" ]; then echo "[$(date)] REJECTED (strict, unsigned): $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" continue else - # warn mode — allow but flag it echo "[$(date)] WARN (unsigned, policy=$KPM_SIGNATURE_POLICY): $(basename "$kpm") — loading anyway" >> "$LOG" - # Write a marker file so the WebUI can surface the warning. echo "unsigned:$(basename "$kpm"):$(date +%s)" >> "$PNDIR/unsigned_modules.log" fi elif ! verify_kpm_sig "$kpm" "$_kpm_sig"; then @@ -174,7 +116,15 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi fi - if ! kpatch kpm load "$kpm" -- "$args"; then + # The current C CLI accepts `load PATH [ARGS]`; it does not parse `--` as + # an option terminator. Preserve the whole sanitized args string as one + # argv element instead of accidentally sending literal "--" to the KPM. + if [ -n "$args" ]; then + kpatch kpm load "$kpm" "$args" + else + kpatch kpm load "$kpm" + fi + if [ $? -ne 0 ]; then echo "[$(date)] Failed to load: $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" else @@ -182,25 +132,36 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi done -# Rehook if [ -n "$REHOOK" ]; then if [ "$REHOOK" = "enable" ] || [ "$REHOOK" = "disable" ]; then - kpatch rehook "$REHOOK" - echo "[$(date)] rehook $REHOOK" >> "$LOG" + if kpatch rehook "$REHOOK" >>"$LOG" 2>&1; then + echo "[$(date)] rehook $REHOOK" >> "$LOG" + else + echo "[$(date)] ERROR: rehook $REHOOK failed" >> "$LOG" + touch "$MODDIR/unresolved" + fi else rm -f "$PNDIR/rehook" fi fi -# Dispatch events dispatch_event() { - echo "[$(date)] Dispatching event: $1" >> "$LOG" - kpatch event "$1" "" "" 2>/dev/null + event_name="$1" + # PatchNest's current KPatch-Next-derived CLI has no `event` command. Do + # not silently pretend lifecycle dispatch succeeded. A future ABI backend + # may expose it; until then this remains explicitly unavailable. + if kpatch --help 2>/dev/null | grep -q '^[[:space:]]*event[[:space:]]'; then + echo "[$(date)] Dispatching event: $event_name" >> "$LOG" + if ! kpatch event "$event_name" "" "" >>"$LOG" 2>&1; then + echo "[$(date)] WARN: event dispatch failed: $event_name" >> "$LOG" + fi + else + echo "[$(date)] Event dispatch unavailable in packaged kpatch ABI: $event_name" >> "$LOG" + fi } dispatch_event "POST_FS_DATA" -# Wait for boot completion (with 5 min timeout to avoid infinite loop on broken ROMs) wait_count=0 until [ "$(getprop sys.boot_completed)" = "1" ]; do sleep 1 @@ -213,28 +174,25 @@ done dispatch_event "BOOT_COMPLETED" -# Apply exclusion config -# Use a temp file (not subshell pipeline) so we keep state and can quote safely. if [ -f "$CONFIG" ]; then excluded_count=0 excluded_failed=0 - # Read into a here-doc, then parse with a manual CSV reader that respects quoting. _cfg_tmp=$(mktemp /data/local/tmp/patchnest_cfg.XXXXXX) tail -n +2 "$CONFIG" > "$_cfg_tmp" while IFS= read -r line; do [ -z "$line" ] && continue - # Parse CSV: pkg,exclude,allow,uid (no quoted fields supported in our writer, - # but be defensive against embedded spaces by using a regex split). pkg=$(echo "$line" | awk -F, '{print $1}') exclude=$(echo "$line" | awk -F, '{print $2}') uid=$(echo "$line" | awk -F, '{print $4}') if [ "$exclude" = "1" ] && [ -n "$pkg" ] && [ -n "$uid" ]; then - # /data/system/packages.list: " " pkgq=$(printf '%s' "$pkg" | sed 's/[][\.*^$()+?{|/]/\\&/g') UID_VAL=$(grep -F " $uid" /data/system/packages.list 2>/dev/null | grep "^$pkgq " | head -1 | awk '{print $2}') if [ -n "$UID_VAL" ]; then - kpatch exclude_set "$UID_VAL" 1 - excluded_count=$((excluded_count + 1)) + if kpatch exclude_set "$UID_VAL" 1 >>"$LOG" 2>&1; then + excluded_count=$((excluded_count + 1)) + else + excluded_failed=$((excluded_failed + 1)) + fi else excluded_failed=$((excluded_failed + 1)) fi From d2b1e4232308241b827ab41cfbd78bd3038708d7 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:22:16 +0800 Subject: [PATCH 02/94] fix(kpm): pass module args according to actual CLI contract --- module/install_kpm.sh | 99 +++---------------------------------------- 1 file changed, 7 insertions(+), 92 deletions(-) diff --git a/module/install_kpm.sh b/module/install_kpm.sh index f4ef612..919b02f 100644 --- a/module/install_kpm.sh +++ b/module/install_kpm.sh @@ -9,17 +9,6 @@ # xxx.c # OR source code (for source modules) # config.json # optional: event/args defaults # -# module.prop format: -# id=my_module -# name=My Module -# version=1.0.0 -# versionCode=100 -# author=me -# description=A test module -# event=BOOT_COMPLETED,POST_FS_DATA -# args=--option1 -# autoLoad=true -# MODDIR=${0%/*} PNDIR="/data/adb/patchnest" @@ -34,42 +23,21 @@ log() { echo "- $1" } -# Read a key from module.prop get_prop() { local file="$1" key="$2" grep "^${key}=" "$file" 2>/dev/null | head -1 | cut -d'=' -f2- } ZIP_FILE="$1" -# P1-fix (ultracode-audit-2026-06-06): also reject any $ZIP_FILE that -# contains a path-traversal sequence, an absolute path, or a shell -# metacharacter. The file is later opened by unzip into a tmpdir; even -# though unzip -o can't write outside the tmpdir, a malicious filename -# could let a separate process (e.g. a malicious pre-existing .kpm.sig -# check) confuse the manifest parser. The whitelist allows KPM zip -# filenames that look like the canonical KPM naming we generate. -# -# Pattern order matters in POSIX case-glob: each branch is checked -# top-to-bottom, so put the most-specific rejections first. The -# earlier version of this comment mentioned codes like SC2221/SC2222 -# by name, but lint tooling that scans the comment for `shellcheck` -# directives (e.g. Github Actions ShellCheck 0.9) tries to parse the -# line as a directive, fails, and fails the build. Avoid the -# bare `shellcheck` keyword in comments to side-step that. case "$ZIP_FILE" in - # Reject empty or absolute paths outright. "" | /*) echo "! install_kpm.sh: refusing to install with empty or absolute path: '$ZIP_FILE'" >&2 exit 2 ;; - # Reject path-traversal sequences anywhere in the path. *..* | */./*) echo "! install_kpm.sh: refusing to install with path-traversal: '$ZIP_FILE'" >&2 exit 2 ;; - # Reject any character that isn't in our safe set. This must come - # last because it matches the broadest class; anything that - # reached here was already accepted by the two checks above. *[!A-Za-z0-9._/+@%=-]*) echo "! install_kpm.sh: refusing to install with unsafe characters in zip filename: '$ZIP_FILE'" >&2 exit 2 @@ -80,11 +48,9 @@ if [ ! -f "$ZIP_FILE" ]; then exit 1 fi -# Create temp extraction dir TMPDIR=$(mktemp -d /data/local/tmp/kpm_install.XXXXXX) trap 'rm -rf "$TMPDIR"' EXIT -# Extract zip echo "- Extracting $ZIP_FILE..." unzip -o "$ZIP_FILE" -d "$TMPDIR" > /dev/null 2>&1 if [ $? -ne 0 ]; then @@ -92,13 +58,11 @@ if [ $? -ne 0 ]; then exit 1 fi -# Validate module.prop if [ ! -f "$TMPDIR/module.prop" ]; then echo "! No module.prop found in ZIP" exit 1 fi -# Read metadata MOD_ID=$(get_prop "$TMPDIR/module.prop" "id") MOD_NAME=$(get_prop "$TMPDIR/module.prop" "name") MOD_VERSION=$(get_prop "$TMPDIR/module.prop" "version") @@ -108,58 +72,31 @@ MOD_EVENT=$(get_prop "$TMPDIR/module.prop" "event") MOD_ARGS=$(get_prop "$TMPDIR/module.prop" "args") MOD_AUTOLOAD=$(get_prop "$TMPDIR/module.prop" "autoLoad") -# P0-fix (ultracode-audit-2026-06-06): sanitize MOD_ARGS to the same -# safe character class that service.sh applies at load time. The args -# file is parsed in shell context; without sanitization, a malicious -# module.prop with args='$(id)' would execute on the user's device. MOD_ARGS="$(printf '%s' "$MOD_ARGS" | tr -cd 'A-Za-z0-9_=,.+:/@% -')" -# Defaults MOD_ID="${MOD_ID:-unknown}" MOD_NAME="${MOD_NAME:-$MOD_ID}" MOD_VERSION="${MOD_VERSION:-0.0.0}" MOD_AUTOLOAD="${MOD_AUTOLOAD:-true}" if [ -z "$MOD_ID" ] || [ "$MOD_ID" = "unknown" ]; then - # Generate ID from filename MOD_ID=$(basename "$ZIP_FILE" .zip | tr ' ' '_') fi -# P0-fix (ultracode-audit-2026-06-06, finding NEW-001): sanitize -# MOD_ID and any other module.prop value that flows into a path -# interpolation. Without this, a crafted KPM zip with -# id=../../system/xbin/foo -# would let install_kpm.sh write a .kpm file to an arbitrary -# root-owned path on the user's device. The whitelist matches the -# sanitization pattern used by service.sh for args. MOD_ID="$(printf '%s' "$MOD_ID" | tr -cd 'A-Za-z0-9_.-')" -# Also sanitize the other fields that are echoed into the kpm -# events dir, even though they don't directly form paths. MOD_NAME="$(printf '%s' "$MOD_NAME" | tr -cd 'A-Za-z0-9 _.-')" MOD_VERSION="$(printf '%s' "$MOD_VERSION" | tr -cd 'A-Za-z0-9_.+-')" MOD_AUTHOR="$(printf '%s' "$MOD_AUTHOR" | tr -cd 'A-Za-z0-9_@. -')" MOD_EVENT="$(printf '%s' "$MOD_EVENT" | tr -cd 'A-Za-z0-9_,')" -# Reject empty / unsafe IDs after sanitization. A MOD_ID that -# collapses to empty means the KPM's id field was all non-ASCII -# (or all dots) — refuse rather than silently installing as -# `.kpm`, which would clobber any file the user happens to have -# named `.kpm` on the device. if [ -z "$MOD_ID" ] || [ "${#MOD_ID}" -gt 64 ] || [ "$MOD_ID" = "." ] || [ "$MOD_ID" = ".." ]; then echo "! install_kpm.sh: refusing to install with unsafe id: '$MOD_ID'" >&2 exit 2 fi log "Installing KPM: $MOD_NAME ($MOD_ID) v$MOD_VERSION" - -# Create directories mkdir -p "$KPM_DIR" "$KPM_ZIP_DIR" "$KPM_EVENT_DIR" -# Check for source files (.c) -# P1-Cluster B fix: explicitly skip macOS resource forks (._*) and -# .DS_Store which unzip-on-macOS leaves behind. Otherwise `head -1` -# below can pick up a metadata file and the script silently reports -# "no .kpm found" with no error message. SRC_FILES=$(find "$TMPDIR" -type f -name "*.c" \ ! -name '._*' ! -name '.DS_Store' 2>/dev/null) KPM_FILES=$(find "$TMPDIR" -type f \ @@ -167,18 +104,11 @@ KPM_FILES=$(find "$TMPDIR" -type f \ ! -name '._*' ! -name '.DS_Store' 2>/dev/null) if [ -n "$KPM_FILES" ]; then - # Binary module: copy .kpm/.ko/.o directly KPM_FILE=$(echo "$KPM_FILES" | head -1) KPM_BASENAME=$(basename "$KPM_FILE") cp "$KPM_FILE" "$KPM_DIR/${MOD_ID}.kpm" log "Binary module installed: $KPM_DIR/${MOD_ID}.kpm" - # If a matching .kpm.sig is present in the ZIP, copy it alongside - # the binary so service.sh can verify the load on the next boot. - # The verifier looks for $KPM_DIR/${MOD_ID}.kpm.sig specifically. - # The sig file (if any) is the one whose basename is the kpm's - # basename + ".sig"; we look it up by name rather than the first - # .sig found in the ZIP, to support multi-kpm ZIPs cleanly. _kpm_stem=$(printf '%s' "$KPM_BASENAME" | sed -E 's/\.(kpm|ko|o)$//') for _sig in "$TMPDIR/${_kpm_stem}.kpm.sig" \ "$TMPDIR/${_kpm_stem}.sig" \ @@ -191,7 +121,6 @@ if [ -n "$KPM_FILES" ]; then fi done elif [ -n "$SRC_FILES" ]; then - # Source module: needs compilation COMPILE_SCRIPT="$MODDIR/compile_kpm.sh" if [ -x "$COMPILE_SCRIPT" ]; then echo "- Compiling source module..." @@ -203,58 +132,44 @@ elif [ -n "$SRC_FILES" ]; then fi log "Source module compiled and installed" else - # No compiler available, store source for later compilation mkdir -p "$PNDIR/kpm_src" + mkdir -p "$PNDIR/kpm_src/${MOD_ID}" cp -r "$TMPDIR"/* "$PNDIR/kpm_src/${MOD_ID}/" log "Source module stored (no compiler available): $PNDIR/kpm_src/${MOD_ID}/" echo "- Source stored, compilation requires TCC compiler" fi - # NOTE: source-compiled modules are inherently unsigned in this MVP. - # TODO(security): when REQUIRE_KPM_SIGNATURES=1 is enforced strictly - # and a user installs a source module that gets compiled locally, the - # resulting $KPM_DIR/${MOD_ID}.kpm will be rejected at next boot - # unless a signing step is added to compile_kpm.sh. For now, this is - # fine because service.sh allows unsigned modules with a warning. else echo "! No .kpm/.ko/.o or .c files found in ZIP" exit 1 fi -# Save ZIP for reference/updates cp "$ZIP_FILE" "$KPM_ZIP_DIR/${MOD_ID}.zip" -# Save event config if [ -n "$MOD_EVENT" ]; then echo "$MOD_EVENT" > "$KPM_EVENT_DIR/${MOD_ID}.events" log "Events registered: $MOD_EVENT" fi -# Save args if [ -n "$MOD_ARGS" ]; then echo "$MOD_ARGS" > "$KPM_EVENT_DIR/${MOD_ID}.args" fi -# Save autoLoad flag if [ "$MOD_AUTOLOAD" = "true" ]; then touch "$KPM_EVENT_DIR/${MOD_ID}.autoload" fi -# Save full module.prop for reference cp "$TMPDIR/module.prop" "$KPM_ZIP_DIR/${MOD_ID}.prop" -# Load module immediately if requested if [ "$MOD_AUTOLOAD" = "true" ]; then echo "- Loading module..." - # The double-dash ends kpatch's option parsing, so any leading - # '-' in $MOD_ARGS (or values that would otherwise be parsed as - # options) is preserved verbatim. $MOD_ARGS is itself quoted - # below so an args string with whitespace is passed as a single - # argument to kpatch kpm load. - ARGS_OPT="" + # PatchNest C CLI contract is `kpm load PATH [ARGS]`. Do not inject a + # literal `--`; that token was previously delivered to the KPM as its args + # while the real MOD_ARGS value was ignored. if [ -n "$MOD_ARGS" ]; then - ARGS_OPT="-- $MOD_ARGS" + kpatch kpm load "$KPM_DIR/${MOD_ID}.kpm" "$MOD_ARGS" 2>&1 + else + kpatch kpm load "$KPM_DIR/${MOD_ID}.kpm" 2>&1 fi - kpatch kpm load "$KPM_DIR/${MOD_ID}.kpm" $ARGS_OPT 2>&1 if [ $? -eq 0 ]; then log "Module $MOD_ID loaded successfully" echo "- Successfully installed and loaded: $MOD_NAME v$MOD_VERSION" From 3f52d3c89d072356d1a8c2632a20a804b054048c Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:25:43 +0800 Subject: [PATCH 03/94] docs(review): establish fail-closed flash-readiness gate --- FLASH_READINESS.md | 115 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 FLASH_READINESS.md diff --git a/FLASH_READINESS.md b/FLASH_READINESS.md new file mode 100644 index 0000000..158178e --- /dev/null +++ b/FLASH_READINESS.md @@ -0,0 +1,115 @@ +# PatchNest flash-readiness review + +Status: **NOT FLASH READY** + +This document is the live release gate for `review/flash-readiness-hardening`. A green source/package CI run is necessary but is not sufficient to remove this gate. + +## P0 blockers + +### FR-001 — Packaged kernel and CLI belong to different supercall ABI families + +Current `version.properties` combines: + +- `Zhanfg/KernelPatch-Public` `0.13.3` (`hello1158`, magic `0x11581158`, key/su authentication, KPM event/safemode extensions); +- `Zhanfg/PatchNest` `0.13.5-2`, reproduced from the KPatch-Next userspace snapshot (`hello2026`, magic `0x20262026`, NULL-key userspace call convention, no matching event command). + +KernelPatch-Public masks the packed command to the low 16 bits, so the high token alone is not the blocker. The handshake magic, authentication convention, and extension command surface are different. Package-level compatibility has not been demonstrated. + +**Gate:** one reviewed ABI profile must own `kpimg`, `kptools`, `kpatch`, safemode and lifecycle-event behavior. Mixed profiles are forbidden unless an explicit compatibility layer is implemented and tested. + +### FR-002 — Shared patch working directory can reuse stale images + +`boot_patch.sh` / `boot_unpatch.sh` currently use shared names such as `kernel`, `kernel.ori`, `ori.img` and `new-boot.img`, and may skip unpacking when `kernel` already exists. + +**Gate:** each patch/unpatch operation must use a fresh private `mktemp -d` workspace, unpack the requested boot image unconditionally, and remove the workspace with a trap. + +### FR-003 — Recovery selection is not bound to the flash target + +`auto_unpatch()` currently chooses the newest backup by mtime. Older manifests are optional and `backup_verified=false` is not a hard rejection. Slot/partition/device identity is not bound to the selected backup. + +**Gate:** automatic restore may only select a backup whose manifest is present, `backup_verified=true`, digest-valid, and bound to the exact target partition/slot/device identity. Otherwise it must refuse to flash. + +### FR-004 — Destructive flash has no mandatory readback verification + +A successful write syscall/pipeline is currently treated as a successful boot flash. + +**Gate:** block-device flash must hash the exact bytes being written, write and sync them, read back the same byte range, and compare the digest. Targets for which reliable readback is unavailable require a separately validated device-specific strategy and are excluded from the general release baseline. + +## P1 blockers + +### FR-005 — Root shell `eval` remains in `getvar()` + +The fallback `eval "$VARNAME=\$VALUE"` still evaluates attacker-controlled VALUE content under Android `/system/bin/sh`. Allow-listing only VARNAME does not make VALUE safe. + +**Gate:** replace with explicit case assignments; no `eval` in root-owned config parsing. + +### FR-006 — Boot partition discovery can guess the wrong target + +When the active slot is unresolved, the current fallback searches `boot_a` / `boot_b` and can also fall back to `vendor_boot` / `init_boot` as though they were interchangeable with boot. + +**Gate:** A/B devices require a resolved active slot. `vendor_boot` / `init_boot` require explicit separate support; they are never generic boot fallbacks. + +### FR-007 — Backup identity/hash logic does not cover normal block-device targets + +Several checks use `[ -f "$BOOT_FILE" ]`, while a real boot target is normally a block device. `original_sha256` can therefore remain null and external-change detection becomes ineffective. + +**Gate:** capture/hash the actual boot bytes used to create the backup, independent of whether the source path is a regular file or block device. + +### FR-008 — Backup names can collide within one minute + +Backup names currently have minute precision and can overwrite a previous good backup. + +**Gate:** no-clobber unique name (seconds + PID/random or `mktemp`) followed by atomic manifest/image promotion. + +### FR-009 — Embedded-KPM validation can fail open + +If `kptools -l -M` cannot verify an embedded KPM, patching currently proceeds with a warning. + +**Gate:** release path fails closed. Any development override must be explicit, off by default, and visibly mark the output non-release. + +### FR-010 — KPM load argument contract was wrong + +Module scripts called `kpatch kpm load PATH -- ARGS`, while the current C CLI accepts `PATH [ARGS]`; literal `--` became the KPM argument and the intended string was ignored. + +**Status:** fixed on this review branch in `service.sh` and `install_kpm.sh`. Rust parser foundation supports both call shapes for migration compatibility. + +### FR-011 — Lifecycle event dispatch is not implemented by the packaged CLI + +`service.sh` called `kpatch event ...` even though the KPatch-Next-derived PatchNest CLI has no `event` command. Failures were hidden. + +**Status:** review branch now records the capability as unavailable instead of silently claiming dispatch success. Final behavior depends on FR-001 ABI unification. + +### FR-012 — `kpatch hello` previously returned process success on handshake failure + +The C CLI printed the expected echo only on a matching magic but `main()` always returned 0. + +**Status:** fixed on `PatchNest/fix/cli-contract-hardening`; syscall failure and foreign hello magic now produce a stable non-zero exit code. + +## Release-only blockers + +### FR-013 — Package output is not proven byte-reproducible + +The same source tree produced a successful Actions module ZIP whose SHA-256 differed from the existing `v0.4.1-rc2` release asset. Normal `zip -r` metadata/timestamps are a likely cause. + +**Gate:** deterministic file order/timestamps/ZIP metadata and two-build byte comparison before publishing a hash-pinned update. + +### FR-014 — Physical device lifecycle evidence is still missing + +Required before declaring fully flash-ready: + +1. read-only preflight and target-slot identity; +2. backup capture + manifest binding; +3. patch without flash and image inspection; +4. flash + exact readback verification; +5. cold boot / warm reboot; +6. KPM load/control/unload/reload; +7. exclusion + rehook behavior; +8. root-manager coexistence for each supported manager; +9. rollback to the bound backup; +10. deliberate failed-boot recovery test; +11. second reboot after rollback; +12. evidence bundle bound to component SHAs and flashed image hashes. + +## Branch policy + +`module/FLASH_REVIEW_BLOCKED` intentionally prevents installation of this branch until all P0 blockers are closed. Removing that marker requires a dedicated final review commit with evidence links; it must not be deleted as part of an unrelated change. From c41c4f0bf39ffaf068d28de9235205d5546a9060 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:25:55 +0800 Subject: [PATCH 04/94] safety(review): block installation until P0 flash gates close --- module/FLASH_REVIEW_BLOCKED | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 module/FLASH_REVIEW_BLOCKED diff --git a/module/FLASH_REVIEW_BLOCKED b/module/FLASH_REVIEW_BLOCKED new file mode 100644 index 0000000..188341e --- /dev/null +++ b/module/FLASH_REVIEW_BLOCKED @@ -0,0 +1,2 @@ +PatchNest flash-readiness review is still open. Do not flash this branch. +See ../FLASH_READINESS.md for the active release gates. From be0ea694f6a2e882c2b9b63370b8a188e116aecf Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:26:15 +0800 Subject: [PATCH 05/94] safety(review): abort install when flash review marker is present --- module/customize.sh | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/module/customize.sh b/module/customize.sh index 6c17bbd..5952df7 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -1,22 +1,25 @@ #!/system/bin/sh MODDIR="/data/adb/modules/PatchNest" -# P2-Cluster E fix: defend against an unset/empty $MODDIR which would turn -# the rm -rf below into a recursive wipe of /. We rely on $MODPATH from -# the Magisk install harness, falling back to the legacy $MODDIR constant -# only when $MODPATH is empty. +# This review branch is intentionally non-flashable until the P0 gates in +# FLASH_READINESS.md are closed. The marker is packaged into the module so a +# review artifact cannot be mistaken for a release artifact. +if [ -f "${MODPATH:-$MODDIR}/FLASH_REVIEW_BLOCKED" ]; then + ui_print "! PatchNest review build: flashing is intentionally blocked" + ui_print "! P0 flash-readiness gates are still open" + ui_print "! Use a reviewed release artifact, not this branch" + abort "! FLASH_REVIEW_BLOCKED" +fi + [ -z "${MODPATH:-}" ] && MODPATH="$MODDIR" -# Sanity: refuse to run if MODPATH is empty or does not exist. if [ -z "$MODPATH" ] || [ ! -d "$MODPATH" ]; then abort "! MODPATH is empty or missing: '$MODPATH'" fi -# We only support arm64 if [ "$ARCH" != "arm64" ]; then abort "! Only arm64 is supported" fi -# Detect root manager ROOT_MGR="unknown" if [ -n "$APATCH" ]; then ROOT_MGR="apatch" @@ -33,31 +36,18 @@ set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 mkdir -p /data/adb/patchnest -# Optional system-managed KPM repos override. If the maintainer ships -# a file at $MODPATH/repos.json in their PatchNest build, copy it -# to /data/adb/patchnest/repos.json — the WebUI's Kpm-Repo page will -# read this and use it as the canonical repo list instead of the -# built-in default. Format: -# [ { "url": "https://...", "name": "..." }, ... ] -# This is the cleanest way for a PatchNest fork to ship a non-default -# default repo (e.g. "always use Acme's Kpm-Repo instead of the -# official one"). See https://github.com/Zhanfg/Kpm-Repo for details. if [ -f "$MODPATH/repos.json" ]; then cp "$MODPATH/repos.json" /data/adb/patchnest/repos.json ui_print "- Installed system repos.json" fi -# Migrate package_config from APatch if present if [ -f "/data/adb/ap/package_config" ] && [ ! -f "/data/adb/patchnest/package_config" ]; then cp "/data/adb/ap/package_config" /data/adb/patchnest/package_config ui_print "- Migrated APatch package_config" fi -# Copy binaries (single source: KernelPatch-Public) ui_print "- Installing KernelPatch binaries..." -# P1-Cluster D fix: missing critical binaries should abort the install, -# not silently produce a broken module. if [ ! -x "$MODPATH/bin/kpatch" ]; then abort "! kpatch binary missing or not executable in $MODPATH/bin" fi @@ -65,17 +55,10 @@ if [ ! -x "$MODPATH/bin/kptools" ]; then abort "! kptools binary missing or not executable in $MODPATH/bin" fi -# Save root manager info echo "$ROOT_MGR" > /data/adb/patchnest/root_manager -# backup module.prop cp "$MODPATH/module.prop" "$MODPATH/module.prop.bak" -# Hot update webui, patch scripts and binaries -# P2-Cluster E fix: defensive globs — if the directory is empty the -# pattern literally matches, so we guard with set +f / null-glob -# behaviour via noclobber on the rm side. We use a leading-/-style -# protection by checking each path explicitly. rm -rf "$MODDIR/webroot"/* 2>/dev/null || true rm -rf "$MODDIR/bin"/* 2>/dev/null || true rm -rf "$MODDIR/patch"/* 2>/dev/null || true @@ -86,7 +69,6 @@ cp -rf "$MODPATH/webroot"/* "$MODDIR/webroot/" 2>/dev/null || true cp -rf "$MODPATH/bin"/* "$MODDIR/bin/" 2>/dev/null || true cp -rf "$MODPATH/patch"/* "$MODDIR/patch/" 2>/dev/null || true -# Copy environment detection script cp -f "$MODPATH/detect_env.sh" "$MODDIR/detect_env.sh" 2>/dev/null || true ui_print "- Installation complete" From 36f683983437489b15c51463357ced81c08ab487 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:28:17 +0800 Subject: [PATCH 06/94] fix(flash): use private unpatch workspace and target-bound recovery --- module/patch/boot_unpatch.sh | 207 +++++++++++++++++------------------ 1 file changed, 103 insertions(+), 104 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 8bb4f25..d9e1a55 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -1,142 +1,141 @@ #!/system/bin/sh ####################################################################################### -# APatch Boot Image Unpatcher -# Imported from https://github.com/bmax121/APatch/blob/main/app/src/main/assets/boot_unpatch.sh +# PatchNest Boot Image Unpatcher +# Derived from APatch boot_unpatch.sh, hardened for fail-closed recovery. ####################################################################################### MODPATH=${0%/*} -ARCH=$(getprop ro.product.cpu.abi) PNDIR="/data/adb/patchnest" BACKUP_DIR="$PNDIR/backup" AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" -# Load utility functions +# shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" -BOOTIMAGE=$1 - -# ============================================================ -# auto_unpatch() -# Bootloop Auto-Recovery entry point. -# Flashes back the LATEST backup boot image from -# /data/adb/patchnest/backup/ to the active boot slot. -# Leaves the autorecovery_active marker in place until a -# healthy boot clears it (so the WebUI can show the status). -# Returns 0 on success, non-zero on failure. -# ============================================================ -auto_unpatch() { - if [ -z "$BOOTIMAGE" ] || [ ! -e "$BOOTIMAGE" ]; then - >&2 echo "! auto_unpatch: BOOTIMAGE not set or missing ($BOOTIMAGE)" - return 1 - fi +BOOTIMAGE=${1:-} +[ -n "$BOOTIMAGE" ] || { >&2 echo "! BOOTIMAGE is required"; exit 1; } +[ -e "$BOOTIMAGE" ] || { >&2 echo "! $BOOTIMAGE does not exist"; exit 1; } +BOOT_TARGET=$(readlink -f "$BOOTIMAGE" 2>/dev/null || printf '%s' "$BOOTIMAGE") + +command -v magiskboot >/dev/null 2>&1 || { >&2 echo "! Command magiskboot not found"; exit 1; } +command -v kptools >/dev/null 2>&1 || { >&2 echo "! Command kptools not found"; exit 1; } +command -v sha256sum >/dev/null 2>&1 || { >&2 echo "! Command sha256sum not found"; exit 1; } + +WORKDIR=$(mktemp -d /data/local/tmp/patchnest_unpatch.XXXXXX) || { + >&2 echo "! Cannot create private unpatch workspace" + exit 1 +} +cleanup() { + rm -rf "$WORKDIR" +} +trap cleanup EXIT HUP INT TERM + +json_string() { + key="$1" + file="$2" + grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" +} + +json_bool() { + key="$1" + file="$2" + grep -o "\"${key}\"[[:space:]]*:[[:space:]]*(true|false)" "$file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" +} + +# Select only a backup that is cryptographically bound to this exact target. +# Legacy manifests without target/digest fields are intentionally ineligible for +# automatic flashing. They may still be inspected/exported manually. +select_verified_backup() { + [ -d "$BACKUP_DIR" ] || return 1 + + for manifest in $(ls -1t "$BACKUP_DIR"/boot_backup_*.json 2>/dev/null); do + [ -f "$manifest" ] || continue + [ "$(json_bool backup_verified "$manifest")" = "true" ] || continue + + recorded_target=$(json_string boot_target "$manifest") + recorded_sha=$(json_string backup_sha256 "$manifest") + backup="${manifest%.json}.img" + + [ -n "$recorded_target" ] || continue + [ "$recorded_target" = "$BOOT_TARGET" ] || continue + printf '%s' "$recorded_sha" | grep -Eq '^[0-9a-f]{64}$' || continue + [ -f "$backup" ] || continue + + actual_sha=$(sha256sum "$backup" 2>/dev/null | awk '{print $1}') + [ "$actual_sha" = "$recorded_sha" ] || continue + + printf '%s\n' "$backup" + return 0 + done + return 1 +} +auto_unpatch() { command -v flash_image >/dev/null 2>&1 || { >&2 echo "! auto_unpatch: flash_image function not available" return 2 } - if [ ! -d "$BACKUP_DIR" ]; then - >&2 echo "! auto_unpatch: backup dir not found: $BACKUP_DIR" + verified_backup=$(select_verified_backup) || { + >&2 echo "! auto_unpatch: no verified backup is bound to $BOOT_TARGET" + >&2 echo "! Legacy/newest-by-time fallback is disabled for safety" return 3 - fi + } - # Pick the newest backup by modification time. - latest_backup=$(ls -1t "$BACKUP_DIR"/boot_backup_*.img 2>/dev/null | head -n 1) - if [ -z "$latest_backup" ] || [ ! -f "$latest_backup" ]; then - >&2 echo "! auto_unpatch: no backup images in $BACKUP_DIR" + echo "- auto_unpatch: verified backup: $verified_backup" + echo "- auto_unpatch: target: $BOOT_TARGET" + if ! flash_image "$verified_backup" "$BOOT_TARGET"; then + >&2 echo "! auto_unpatch: verified flash failed" return 4 fi - # ============================================================ - # If a JSON manifest accompanies the backup, surface a one-line - # summary so service.sh / WebUI can show "preserved: AK3 27.0". - # If the manifest is missing (legacy backup) we don't fail — - # auto_unpatch must still work for old users. - # ============================================================ - latest_manifest="${latest_backup%.img}.json" - if [ -f "$latest_manifest" ]; then - manifest_kpstate=$(grep -o '"kp_state"[[:space:]]*:[[:space:]]*"[^"]*"' "$latest_manifest" 2>/dev/null \ - | head -n 1 | sed -E 's/.*"kp_state"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/') - manifest_magver=$(grep -o '"magisk_version"[[:space:]]*:[[:space:]]*"[^"]*"' "$latest_manifest" 2>/dev/null \ - | head -n 1 | sed -E 's/.*"magisk_version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/') - manifest_ksuver=$(grep -o '"ksu_version"[[:space:]]*:[[:space:]]*"[^"]*"' "$latest_manifest" 2>/dev/null \ - | head -n 1 | sed -E 's/.*"ksu_version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/') - manifest_verified=$(grep -o '"backup_verified"[[:space:]]*:[[:space:]]*[a-z]*' "$latest_manifest" 2>/dev/null \ - | head -n 1 | sed -E 's/.*"backup_verified"[[:space:]]*:[[:space:]]*([a-z]*).*/\1/') - echo "- auto_unpatch: manifest kp_state=${manifest_kpstate:-unknown} magisk=${manifest_magver:-null} ksu=${manifest_ksuver:-null} verified=${manifest_verified:-unknown}" - else - echo "- auto_unpatch: no manifest for $latest_backup (legacy backup)" - fi - - echo "- auto_unpatch: using latest backup: $latest_backup" - - if ! flash_image "$latest_backup" "$BOOTIMAGE"; then - >&2 echo "! auto_unpatch: flash failed" - return 5 - fi - - # Best-effort cleanup of the counter so we don't immediately - # re-trigger on the next boot. Keep the marker so the WebUI - # can show that auto-recovery was activated. echo "0" > "$PNDIR/boot_count" 2>/dev/null - echo "- auto_unpatch: flash successful" + touch "$AUTORECOVERY_MARKER" 2>/dev/null || true + echo "- auto_unpatch: verified restore completed" return 0 } -[ -e "$BOOTIMAGE" ] || { echo "- $BOOTIMAGE does not exist!"; exit 1; } - -echo "- Target image: $BOOTIMAGE" +echo "- Target image: $BOOT_TARGET" +cd "$WORKDIR" || exit 1 - # Check for dependencies -command -v magiskboot >/dev/null 2>&1 || { echo "- Command magiskboot not found!"; exit 1; } -command -v kptools >/dev/null 2>&1 || { echo "- Command kptools not found!"; exit 1; } - -if [ ! -f kernel ]; then -echo "- Unpacking boot image" -magiskboot unpack "$BOOTIMAGE" >/dev/null 2>&1 -if [ $? -ne 0 ]; then - >&2 echo "! Unpack error: $?" +echo "- Unpacking current boot image into private workspace" +if ! magiskboot unpack "$BOOT_TARGET" >/dev/null 2>&1; then + >&2 echo "! Unpack failed" exit 1 - fi fi +[ -s kernel ] || { >&2 echo "! Unpack produced no kernel; refusing to continue"; exit 1; } -if [ -n "$(kptools -i kernel -l 2>/dev/null | grep patched=true)" ]; then - echo "- kernel has been patched " - if [ -f "new-boot.img" ]; then - echo "- found backup boot.img ,use it for recovery" - else - mv kernel kernel.ori - echo "- Unpatching kernel" - kptools -u --image kernel.ori --out kernel - if [ $? -ne 0 ]; then - >&2 echo "! Unpatch error: $?" - exit 1 - fi - echo "- Repacking boot image" - magiskboot repack "$BOOTIMAGE" >/dev/null 2>&1 - if [ $? -ne 0 ]; then - >&2 echo "! Repack error: $?" - exit 1 - fi - fi +if ! kptools -i kernel -l 2>/dev/null | grep -q 'patched=true'; then + echo "- Kernel is not PatchNest-patched; no unpatch required" + exit 0 +fi -else - echo "- no need unpatch" - exit 0 +echo "- Unpatching kernel" +mv kernel kernel.patched +if ! kptools -u --image kernel.patched --out kernel; then + >&2 echo "! Unpatch failed" + exit 1 fi +[ -s kernel ] || { >&2 echo "! Unpatch produced an empty kernel"; exit 1; } -if [ -f "new-boot.img" ]; then - echo "- Flashing boot image" - flash_image new-boot.img "$BOOTIMAGE" +echo "- Repacking from the current target image" +if ! magiskboot repack "$BOOT_TARGET" >/dev/null 2>&1; then + >&2 echo "! Repack failed" + exit 1 +fi +[ -s new-boot.img ] || { >&2 echo "! Repack produced no new-boot.img"; exit 1; } - if [ $? -ne 0 ]; then - >&2 echo "! Flash error: $?" - save_image_to_storage "new-boot.img" +echo "- Flashing unpatched boot image" +if ! flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"; then + rc=$? + >&2 echo "! Flash failed: $rc" + save_image_to_storage "$WORKDIR/new-boot.img" exit 1 - fi fi echo "- Flash successful" - -# Reset any error code -true +exit 0 From 58cc1665b172de0a4df5099cdc57b8bd05b8a6c0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:28:48 +0800 Subject: [PATCH 07/94] fix(flash): preserve real flash failure status --- module/patch/boot_unpatch.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index d9e1a55..4c77640 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -88,8 +88,10 @@ auto_unpatch() { echo "- auto_unpatch: verified backup: $verified_backup" echo "- auto_unpatch: target: $BOOT_TARGET" - if ! flash_image "$verified_backup" "$BOOT_TARGET"; then - >&2 echo "! auto_unpatch: verified flash failed" + flash_image "$verified_backup" "$BOOT_TARGET" + rc=$? + if [ "$rc" -ne 0 ]; then + >&2 echo "! auto_unpatch: verified flash failed: $rc" return 4 fi @@ -130,8 +132,9 @@ fi [ -s new-boot.img ] || { >&2 echo "! Repack produced no new-boot.img"; exit 1; } echo "- Flashing unpatched boot image" -if ! flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"; then - rc=$? +flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET" +rc=$? +if [ "$rc" -ne 0 ]; then >&2 echo "! Flash failed: $rc" save_image_to_storage "$WORKDIR/new-boot.img" exit 1 From f84e5bf5913d9c3f191b1d212c2c4b6ffd973530 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:37:04 +0800 Subject: [PATCH 08/94] fix(review): select verified backups without parsing ls --- module/patch/boot_unpatch.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 4c77640..4bfb111 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -52,7 +52,9 @@ json_bool() { select_verified_backup() { [ -d "$BACKUP_DIR" ] || return 1 - for manifest in $(ls -1t "$BACKUP_DIR"/boot_backup_*.json 2>/dev/null); do + best_manifest="" + best_backup="" + for manifest in "$BACKUP_DIR"/boot_backup_*.json; do [ -f "$manifest" ] || continue [ "$(json_bool backup_verified "$manifest")" = "true" ] || continue @@ -68,10 +70,14 @@ select_verified_backup() { actual_sha=$(sha256sum "$backup" 2>/dev/null | awk '{print $1}') [ "$actual_sha" = "$recorded_sha" ] || continue - printf '%s\n' "$backup" - return 0 + if [ -z "$best_manifest" ] || [ "$manifest" -nt "$best_manifest" ]; then + best_manifest="$manifest" + best_backup="$backup" + fi done - return 1 + + [ -n "$best_backup" ] || return 1 + printf '%s\n' "$best_backup" } auto_unpatch() { From c86ec93da607a7a6b5027783b36617f2b0e79350 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:38:52 +0800 Subject: [PATCH 09/94] fix(flash): add fail-closed partition and readback safety overrides --- module/patch/flash_safety.sh | 166 +++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 module/patch/flash_safety.sh diff --git a/module/patch/flash_safety.sh b/module/patch/flash_safety.sh new file mode 100644 index 0000000..169d0f0 --- /dev/null +++ b/module/patch/flash_safety.sh @@ -0,0 +1,166 @@ +#!/system/bin/sh +# PatchNest flash-path safety overrides. +# Source this AFTER util_functions.sh. It deliberately replaces only the +# high-risk helpers used by PatchNest boot patch/unpatch flows. + +# No eval: supported Magisk/APatch config keys are assigned explicitly. +getvar() { + _pn_key=$1 + _pn_proppath='/data/.magisk /cache/.magisk' + [ -n "${MAGISKTMP:-}" ] && _pn_proppath="$MAGISKTMP/.magisk/config $_pn_proppath" + _pn_value=$(grep_prop "$_pn_key" $_pn_proppath) + + case "$_pn_key" in + KEEPVERITY) KEEPVERITY=$_pn_value ;; + KEEPFORCEENCRYPT) KEEPFORCEENCRYPT=$_pn_value ;; + RECOVERYMODE) RECOVERYMODE=$_pn_value ;; + *) abort "! getvar: unsupported key '$_pn_key'" ;; + esac +} + +# Resolve only a real boot partition. vendor_boot/init_boot are not generic +# substitutes and are intentionally excluded until they have their own patch +# implementation and validation matrix. +find_boot_image() { + BOOTIMAGE='' + + if [ -n "${SLOT:-}" ]; then + case "$SLOT" in + _a|_b) ;; + *) abort "! Invalid active slot suffix: '$SLOT'" ;; + esac + BOOTIMAGE=$(find_block "boot$SLOT" "kern$SLOT" "kern-$SLOT" 2>/dev/null) + [ -n "$BOOTIMAGE" ] || abort "! Cannot resolve boot partition for active slot $SLOT" + echo "BOOTIMAGE=$BOOTIMAGE" + return 0 + fi + + # If the device exposes slot-suffixed boot partitions but slot identity was + # not resolved, guessing _a/_b is unsafe. Refuse instead. + _pn_boot_a=$(find_block boot_a kern_a kern-a 2>/dev/null || true) + _pn_boot_b=$(find_block boot_b kern_b kern-b 2>/dev/null || true) + if [ -n "$_pn_boot_a" ] || [ -n "$_pn_boot_b" ]; then + abort "! A/B boot partitions detected but active slot is unresolved" + fi + + BOOTIMAGE=$(find_block boot android_boot kernel bootimg lnx 2>/dev/null || true) + if [ -z "$BOOTIMAGE" ]; then + BOOTIMAGE=$(grep -v '#' /etc/*fstab* 2>/dev/null \ + | grep -E '/boot(img)?[^a-zA-Z]' \ + | grep -oE '/dev/[a-zA-Z0-9_./-]*' \ + | head -n 1) + fi + + [ -n "$BOOTIMAGE" ] || abort "! Cannot resolve a supported boot partition" + echo "BOOTIMAGE=$BOOTIMAGE" +} + +_pn_payload_prepare() { + _pn_source=$1 + PN_FLASH_PAYLOAD=$_pn_source + PN_FLASH_TEMP='' + + case "$_pn_source" in + *.gz) + PN_FLASH_TEMP=$(mktemp /data/local/tmp/patchnest_flash.XXXXXX.img) || return 1 + if ! gzip -dc "$_pn_source" > "$PN_FLASH_TEMP"; then + rm -f "$PN_FLASH_TEMP" + PN_FLASH_TEMP='' + return 1 + fi + PN_FLASH_PAYLOAD=$PN_FLASH_TEMP + ;; + esac + return 0 +} + +_pn_payload_cleanup() { + [ -z "${PN_FLASH_TEMP:-}" ] || rm -f "$PN_FLASH_TEMP" + PN_FLASH_TEMP='' +} + +# Fail-closed writer for the general block-device release baseline: +# - normalize compressed input first; +# - verify source fits the partition; +# - write + fsync; +# - hash exactly the written byte range back from the target; +# - reject char/NAND devices until a device-specific readback strategy exists. +flash_image() { + _pn_source=$1 + _pn_target=$2 + + command -v sha256sum >/dev/null 2>&1 || return 3 + _pn_payload_prepare "$_pn_source" || return 4 + + _pn_size=$(stat -c '%s' "$PN_FLASH_PAYLOAD" 2>/dev/null) + _pn_expected=$(sha256sum "$PN_FLASH_PAYLOAD" 2>/dev/null | awk '{print $1}') + if [ -z "$_pn_size" ] || [ "$_pn_size" -le 0 ] || \ + ! printf '%s' "$_pn_expected" | grep -Eq '^[0-9a-f]{64}$'; then + _pn_payload_cleanup + return 4 + fi + + if [ -b "$_pn_target" ]; then + _pn_capacity=$(blockdev --getsize64 "$_pn_target" 2>/dev/null) + [ -n "$_pn_capacity" ] && [ "$_pn_size" -le "$_pn_capacity" ] || { + _pn_payload_cleanup + return 1 + } + + blockdev --setrw "$_pn_target" 2>/dev/null || { + _pn_payload_cleanup + return 2 + } + [ "$(blockdev --getro "$_pn_target" 2>/dev/null)" != "1" ] || { + _pn_payload_cleanup + return 2 + } + + if ! dd if="$PN_FLASH_PAYLOAD" of="$_pn_target" bs=1048576 conv=notrunc,fsync 2>/dev/null; then + _pn_payload_cleanup + return 5 + fi + sync + + _pn_blocks=$(((_pn_size + 1048575) / 1048576)) + _pn_actual=$(dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ + | head -c "$_pn_size" \ + | sha256sum \ + | awk '{print $1}') + if [ "$_pn_actual" != "$_pn_expected" ]; then + >&2 echo "! Flash readback verification failed" + >&2 echo "! expected=$_pn_expected actual=${_pn_actual:-unavailable}" + _pn_payload_cleanup + return 6 + fi + elif [ -c "$_pn_target" ]; then + >&2 echo "! Character/NAND flashing is not in the reviewed release baseline" + _pn_payload_cleanup + return 7 + else + # File targets are used for offline image generation/tests. Verify them too. + if ! cat "$PN_FLASH_PAYLOAD" > "$_pn_target"; then + _pn_payload_cleanup + return 5 + fi + sync + _pn_actual=$(sha256sum "$_pn_target" 2>/dev/null | awk '{print $1}') + if [ "$_pn_actual" != "$_pn_expected" ]; then + _pn_payload_cleanup + return 6 + fi + fi + + _pn_payload_cleanup + return 0 +} + +save_image_to_storage() { + _pn_image=$1 + _pn_stamp=$(date +%y%m%d%H%M%S) + _pn_suffix="$$" + _pn_out="/storage/emulated/0/Download/patchnest_patched_${_pn_stamp}_${_pn_suffix}.img" + + cp -f "$_pn_image" "$_pn_out" || return 1 + echo "- Patched image saved to $_pn_out" +} From 107de2f2dccc827b4c0bcd3832c537cdae7d25e0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:40:05 +0800 Subject: [PATCH 10/94] fix(flash): activate safety overrides in unpatch path --- module/patch/boot_unpatch.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 4bfb111..e81d7be 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -11,6 +11,8 @@ AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" # shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" +# shellcheck disable=SC1091 +. "$MODPATH/flash_safety.sh" BOOTIMAGE=${1:-} [ -n "$BOOTIMAGE" ] || { >&2 echo "! BOOTIMAGE is required"; exit 1; } From 65dab4bb74e0a2fb0fd9a21a59392a51af6c30ff Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:40:18 +0800 Subject: [PATCH 11/94] fix(flash): activate fail-closed boot partition resolution --- module/patch/boot_extract.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/module/patch/boot_extract.sh b/module/patch/boot_extract.sh index 904f64c..fbacbd9 100644 --- a/module/patch/boot_extract.sh +++ b/module/patch/boot_extract.sh @@ -9,8 +9,11 @@ ARCH=$(getprop ro.product.cpu.abi) IS_INSTALL_NEXT_SLOT=$1 -# Load utility functions +# shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" +# PatchNest fail-closed slot/partition resolution overrides. +# shellcheck disable=SC1091 +. "$MODPATH/flash_safety.sh" if [ "$IS_INSTALL_NEXT_SLOT" = "true" ]; then get_next_slot From 6369d16c856a383eba4875b7d5f8bb3c5e5109d8 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:41:57 +0800 Subject: [PATCH 12/94] fix(review): keep verified backup selection POSIX-safe --- module/patch/boot_unpatch.sh | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index e81d7be..59fd2f5 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -49,12 +49,11 @@ json_bool() { } # Select only a backup that is cryptographically bound to this exact target. -# Legacy manifests without target/digest fields are intentionally ineligible for -# automatic flashing. They may still be inspected/exported manually. +# Glob expansion is lexical; timestamp-prefixed backup names therefore let the +# last valid entry replace earlier ones without parsing `ls` or using non-POSIX -nt. select_verified_backup() { [ -d "$BACKUP_DIR" ] || return 1 - best_manifest="" best_backup="" for manifest in "$BACKUP_DIR"/boot_backup_*.json; do [ -f "$manifest" ] || continue @@ -72,10 +71,7 @@ select_verified_backup() { actual_sha=$(sha256sum "$backup" 2>/dev/null | awk '{print $1}') [ "$actual_sha" = "$recorded_sha" ] || continue - if [ -z "$best_manifest" ] || [ "$manifest" -nt "$best_manifest" ]; then - best_manifest="$manifest" - best_backup="$backup" - fi + best_backup="$backup" done [ -n "$best_backup" ] || return 1 From b3c87787a3c37dbc723d49b9629ecc9c8f476df3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 08:58:26 +0800 Subject: [PATCH 13/94] fix(flash): make boot patch transaction fail-closed --- module/patch/boot_patch.sh | 609 +++++++++++++++---------------------- 1 file changed, 244 insertions(+), 365 deletions(-) diff --git a/module/patch/boot_patch.sh b/module/patch/boot_patch.sh index a8558d5..8383c72 100644 --- a/module/patch/boot_patch.sh +++ b/module/patch/boot_patch.sh @@ -1,427 +1,306 @@ #!/system/bin/sh ####################################################################################### -# APatch Boot Image Patcher -# Imported from https://github.com/bmax121/APatch/blob/main/app/src/main/assets/boot_patch.sh +# PatchNest Boot Image Patcher +# Transactional/fail-closed patch path derived from the APatch patching flow. ####################################################################################### # -# Usage: boot_patch.sh [ARGS_PASS_TO_KPTOOLS] -# -# Optional environment variables / flags: -# KP_REBACKUP=1 Force a fresh backup of the current boot image, even if -# a backup already exists. Use this when the WebUI knows -# the user re-flashed a known root tool (AK3 / Magisk / -# KSU) and the existing backup is stale. -# -# This script should be placed in a directory with the following files: -# -# File name Type Description -# -# boot_patch.sh script A script to patch boot image for APatch. -# (this file) The script will use files in its same -# directory to complete the patching process. -# bootimg binary The target boot image -# kpimg binary KernelPatch core Image -# kptools executable The KernelPatch tools binary to inject kpimg to kernel Image -# magiskboot executable Magisk tool to unpack boot.img. +# Usage: +# boot_patch.sh [ARGS_PASS_TO_KPTOOLS] # +# The second argument controls whether the generated image is flashed to the target. +# When false, the validated patched image is copied to Download only. ####################################################################################### MODPATH=${0%/*} -ARCH=$(getprop ro.product.cpu.abi) +MODULE_DIR=${MODPATH%/patch} PNDIR="/data/adb/patchnest" BACKUP_DIR="$PNDIR/backup" -# Caller may set KP_REBACKUP=1 to force re-backing up even when a -# backup already exists. Default off so an unset var is harmless. -KP_REBACKUP="${KP_REBACKUP:-0}" +INVOCATION_CWD=$(pwd) -# Load utility functions +# shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" +# shellcheck disable=SC1091 +. "$MODPATH/flash_safety.sh" -BOOTIMAGE=$1 -FLASH_TO_DEVICE=$2 +BOOTIMAGE=${1:-} +FLASH_TO_DEVICE=${2:-} +[ "$#" -ge 2 ] || { >&2 echo "! Usage: boot_patch.sh [kptools args]"; exit 2; } shift 2 +case "$FLASH_TO_DEVICE" in + true|false) ;; + *) >&2 echo "! flash flag must be exactly true or false"; exit 2 ;; +esac + +[ -n "$BOOTIMAGE" ] || { >&2 echo "! boot image is required"; exit 2; } [ -e "$BOOTIMAGE" ] || { >&2 echo "! $BOOTIMAGE does not exist"; exit 1; } +BOOT_TARGET=$(readlink -f "$BOOTIMAGE" 2>/dev/null || printf '%s' "$BOOTIMAGE") -# Check for dependencies command -v magiskboot >/dev/null 2>&1 || { >&2 echo "! Command magiskboot not found"; exit 1; } command -v kptools >/dev/null 2>&1 || { >&2 echo "! Command kptools not found"; exit 1; } +command -v sha256sum >/dev/null 2>&1 || { >&2 echo "! Command sha256sum not found"; exit 1; } +command -v xxd >/dev/null 2>&1 || { >&2 echo "! Command xxd not found"; exit 1; } -if [ ! -f kernel ]; then - echo "- Unpacking boot image" - magiskboot unpack "$BOOTIMAGE" >/dev/null 2>&1 - if [ $? -ne 0 ]; then - >&2 echo "! Unpack error: $?" - exit 1 - fi -fi +KPIMG_SOURCE="$MODULE_DIR/bin/kpimg" +[ -s "$KPIMG_SOURCE" ] || { + # Compatibility with older callers that staged kpimg in their cwd. + KPIMG_SOURCE="$INVOCATION_CWD/kpimg" +} +[ -s "$KPIMG_SOURCE" ] || { >&2 echo "! kpimg missing or empty"; exit 1; } -if kptools -i kernel -f | grep -q "CONFIG_KPM=y"; then - echo "! Patcher has Aborted." - echo "! Detected built-in KPM (CONFIG_KPM=y)." - echo "! PatchNest is not compatible alongside built-in KPM." - exit 1 -fi +WORKDIR=$(mktemp -d /data/local/tmp/patchnest_patch.XXXXXX) || { + >&2 echo "! Cannot create private patch workspace" + exit 1 +} +VALIDATE_DIR='' +BACKUP_CANDIDATE='' +MANIFEST_CANDIDATE='' +BACKUP_COMMITTED=0 + +cleanup() { + [ -z "$VALIDATE_DIR" ] || rm -rf "$VALIDATE_DIR" + rm -rf "$WORKDIR" + if [ "$BACKUP_COMMITTED" -ne 1 ]; then + [ -z "$BACKUP_CANDIDATE" ] || rm -f "$BACKUP_CANDIDATE" + [ -z "$MANIFEST_CANDIDATE" ] || rm -f "$MANIFEST_CANDIDATE" + fi +} +trap cleanup EXIT HUP INT TERM -if [ -z "$(kptools -i kernel -f 2>/dev/null | grep CONFIG_KALLSYMS_ALL=y)" ]; then - echo "! Patcher has Aborted." - echo "! PatchNest requires CONFIG_KALLSYMS_ALL to be Enabled." - echo "! But your kernel seems NOT enabled it." - exit 1 -fi +json_escape() { + printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' +} -# ============================================================ -# AK3 / Magisk / KSU / APatch root-chain detection -# Writes a small JSON manifest next to the backup image so that -# auto_unpatch (and the WebUI) know what was preserved vs. lost -# at recovery time. This is the "what's in this backup" record. -# -# The manifest is intentionally simple — pure shell + getprop — -# so it can be parsed by the WebUI without any kpatch-side helper. -# ============================================================ -detect_root_chain() { - local BOOT_FILE="$1" - local MANIFEST_PATH="$2" - - # 1. Kp marker: patched=true|false in kptools output - local KP_STATE="stock" - if kptools -i kernel -l 2>/dev/null | grep -q "patched=true"; then - KP_STATE="patched" - fi +hash_path() { + _pn_hash=$(sha256sum "$1" 2>/dev/null | awk '{print $1}') + printf '%s' "$_pn_hash" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s\n' "$_pn_hash" +} - # 2. Magisk: presence of /data/adb/magisk or `magisk --version` - local MAGISK_VER="null" - if [ -d /data/adb/magisk ] || ls /data/adb/magisk >/dev/null 2>&1; then - KP_STATE="magisk" - # `magisk --version` prints e.g. "27.0:topjohnwu:15000" - MAGISK_VER=$(magisk --version 2>/dev/null | head -n 1 | cut -d: -f1) - [ -z "$MAGISK_VER" ] && MAGISK_VER="null" - fi +validate_boot_image() { + _pn_image=$1 + VALIDATE_DIR=$(mktemp -d /data/local/tmp/patchnest_validate.XXXXXX) || return 1 + if ! (cd "$VALIDATE_DIR" && magiskboot unpack "$_pn_image" >/dev/null 2>&1); then + rm -rf "$VALIDATE_DIR" + VALIDATE_DIR='' + return 1 + fi + [ -s "$VALIDATE_DIR/kernel" ] || { + rm -rf "$VALIDATE_DIR" + VALIDATE_DIR='' + return 1 + } + rm -rf "$VALIDATE_DIR" + VALIDATE_DIR='' + return 0 +} - # 3. KSU: /data/adb/ksu dir or /sys/module/ksu loaded - local KSU_VER="null" - if [ -d /data/adb/ksu ] || [ -d /sys/module/ksu ] || ls /data/adb/ksu >/dev/null 2>&1; then - KSU_VER=$(ksu --version 2>/dev/null | head -n 1 | tr -d '\r\n') - [ -z "$KSU_VER" ] && KSU_VER="null" - # Magisk + KSU together is unusual; if KSU is the primary root, - # # upgrade the state. Don't overwrite "magisk" precedence — the - # WebUI displays both fields. - fi +write_verified_backup() { + mkdir -p "$BACKUP_DIR" || return 1 - # 4. APatch: /data/adb/ap - if [ -d /data/adb/ap ] || ls /data/adb/ap >/dev/null 2>&1; then - KP_STATE="apatch" - fi + _pn_stamp=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) + BACKUP_CANDIDATE=$(mktemp "$BACKUP_DIR/boot_backup_${_pn_stamp}_XXXXXX.img") || return 1 + MANIFEST_CANDIDATE="${BACKUP_CANDIDATE%.img}.json" - # 5. Kernel cmdline hint (verified boot state) — used by the WebUI - # to warn the user if the backup is from a green→yellow→red flip. - local CMDLINE_HINT=$(getprop ro.boot.vbmeta.device_state) - [ -z "$CMDLINE_HINT" ] && CMDLINE_HINT="unknown" + echo "- Capturing rollback image from $BOOT_TARGET" + if ! cat "$BOOT_TARGET" > "$BACKUP_CANDIDATE"; then + >&2 echo "! Failed to capture boot backup" + return 1 + fi + sync + [ -s "$BACKUP_CANDIDATE" ] || { >&2 echo "! Captured backup is empty"; return 1; } - # 6. kpimg size, if present alongside this script - local KPIMG_SIZE=0 - if [ -f "$MODPATH/kpimg" ]; then - KPIMG_SIZE=$(wc -c < "$MODPATH/kpimg" 2>/dev/null | tr -d ' ') - [ -z "$KPIMG_SIZE" ] && KPIMG_SIZE=0 - fi + if ! validate_boot_image "$BACKUP_CANDIDATE"; then + >&2 echo "! Backup validation failed; refusing to patch" + return 1 + fi - # 7. Original (current) boot image SHA256 — used by - # is_boot_modified_externally() to detect re-flashes. - local ORIG_SHA="null" - if [ -f "$BOOT_FILE" ]; then - if command -v sha256sum >/dev/null 2>&1; then - ORIG_SHA=$(sha256sum "$BOOT_FILE" 2>/dev/null | awk '{print $1}') - elif command -v magiskboot >/dev/null 2>&1; then - # magiskboot supports `magiskboot sha256 ` on newer builds. - ORIG_SHA=$(magiskboot sha256 "$BOOT_FILE" 2>/dev/null | head -n 1 | tr -d ' ') - fi - fi - [ -z "$ORIG_SHA" ] && ORIG_SHA="null" - - # 8. ISO-ish timestamp (UTC, no colons — POSIX-safe filename suffix). - local TAKEN_AT - TAKEN_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ) - - # 9. Sanitize string fields: replace " with \", strip control chars. - # (We never let user-supplied data flow into these fields, but be - # defensive — this manifest is parsed by the WebUI as JSON.) - # P0-8: sed substitution order was inverted. The previous form - # escaped `"` first, producing `\\"` for an input like `foo"`, - # and then escaped `\` — but the `\` we just inserted for the - # quote escape got re-doubled to `\\\\"`, yielding a literal - # backslash followed by a quote in the output JSON, which is - # invalid (`"foo\"bar"`, not `"foo\\"bar"`). Escape the - # backslash FIRST so any later quote-escape produces a clean - # `\"` rather than a doubled `\\\"`. - json_escape() { - printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' - } - - local SAFE_KPSTATE KPSTATE_ESC - KPSTATE_ESC=$(json_escape "$KP_STATE") - local SAFE_MAGVER - SAFE_MAGVER=$(json_escape "$MAGISK_VER") - local SAFE_KSUVER - SAFE_KSUVER=$(json_escape "$KSU_VER") - local SAFE_HINT - SAFE_HINT=$(json_escape "$CMDLINE_HINT") - local SAFE_SHA - SAFE_SHA=$(json_escape "$ORIG_SHA") - - cat > "$MANIFEST_PATH" <&2 echo "! Cannot hash current boot target" + return 1 + } + _pn_backup_sha=$(hash_path "$BACKUP_CANDIDATE") || { + >&2 echo "! Cannot hash captured backup" + return 1 + } + [ "$_pn_target_sha" = "$_pn_backup_sha" ] || { + >&2 echo "! Backup digest differs from current target" + >&2 echo "! target=$_pn_target_sha backup=$_pn_backup_sha" + return 1 + } + + _pn_backup_size=$(wc -c < "$BACKUP_CANDIDATE" 2>/dev/null | tr -d ' ') + printf '%s' "$_pn_backup_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + + _pn_kp_state="stock" + if kptools -i "$WORKDIR/kernel" -l 2>/dev/null | grep -q 'patched=true'; then + _pn_kp_state="patched" + fi + + _pn_magisk="null" + if command -v magisk >/dev/null 2>&1; then + _pn_magisk=$(magisk --version 2>/dev/null | head -n 1 | cut -d: -f1) + [ -n "$_pn_magisk" ] || _pn_magisk="null" + fi + + _pn_ksu="null" + if command -v ksu >/dev/null 2>&1; then + _pn_ksu=$(ksu --version 2>/dev/null | head -n 1 | tr -d '\r\n') + [ -n "$_pn_ksu" ] || _pn_ksu="null" + fi + + _pn_taken_at=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) + _pn_kpimg_sha=$(hash_path "$WORKDIR/kpimg") || return 1 + + cat > "$MANIFEST_CANDIDATE.tmp" </dev/null 2>&1; then - CUR_SHA=$(sha256sum "$BOOT_FILE" 2>/dev/null | awk '{print $1}') - else - CUR_SHA=$(magiskboot sha256 "$BOOT_FILE" 2>/dev/null | head -n 1 | tr -d ' ') +validate_embedded_kpms() { + _pn_prev='' + for _pn_arg in "$@"; do + if [ "$_pn_prev" = "-M" ]; then + _pn_kpm=$_pn_arg + case "$_pn_kpm" in + /*) ;; + *) >&2 echo "! Embedded KPM path must be absolute: $_pn_kpm"; return 1 ;; + esac + [ -f "$_pn_kpm" ] || { >&2 echo "! Embedded KPM not found: $_pn_kpm"; return 1; } + + _pn_magic=$(xxd -l 4 -p "$_pn_kpm" 2>/dev/null) + [ "$_pn_magic" = "7f454c46" ] || { + >&2 echo "! Embedded KPM is not ELF: $_pn_kpm" + return 1 + } + + _pn_machine=$(xxd -s 18 -l 2 -e "$_pn_kpm" 2>/dev/null | awk '{print $2}') + [ "$_pn_machine" = "000000b7" ] || { + >&2 echo "! Embedded KPM is not AArch64: $_pn_kpm" + return 1 + } + + _pn_meta=$(kptools -l -M "$_pn_kpm" 2>/dev/null) || { + >&2 echo "! kptools cannot validate embedded KPM: $_pn_kpm" + return 1 + } + _pn_name=$(printf '%s\n' "$_pn_meta" | sed -n 's/^name=//p' | head -n 1) + [ -n "$_pn_name" ] || { + >&2 echo "! Embedded KPM metadata has no name: $_pn_kpm" + return 1 + } + echo " - verified embedded KPM: $_pn_name" fi - [ -n "$CUR_SHA" ] || return 2 - - # Naive JSON value extraction — our writer controls the format - # and the field is always a 64-char hex string, so this is safe. - local REC_SHA - REC_SHA=$(grep -o '"original_sha256"[[:space:]]*:[[:space:]]*"[^"]*"' "$LATEST_MANIFEST" 2>/dev/null \ - | head -n 1 | sed -E 's/.*"original_sha256"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/') - - [ -n "$REC_SHA" ] && [ "$REC_SHA" != "null" ] || return 2 - [ "$CUR_SHA" = "$REC_SHA" ] && return 1 - return 0 + _pn_prev=$_pn_arg + done + [ "$_pn_prev" != "-M" ] || { >&2 echo "! -M requires a KPM file"; return 1; } + return 0 } -# Pick the most recent manifest path under BACKUP_DIR (or empty). -latest_manifest() { - ls -1t "$BACKUP_DIR"/boot_backup_*.json 2>/dev/null | head -n 1 +write_flash_receipt() { + _pn_image=$1 + _pn_sha=$(hash_path "$_pn_image") || return 1 + _pn_size=$(wc -c < "$_pn_image" 2>/dev/null | tr -d ' ') + _pn_time=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) + mkdir -p "$PNDIR" || return 1 + cat > "$PNDIR/last_flash.json.tmp" </dev/null | grep patched=false)" ]; then - SHOULD_BACKUP=1 - fi -else - >&2 echo "! kernel file missing or empty before backup check" +cd "$WORKDIR" || exit 1 +cp "$KPIMG_SOURCE" "$WORKDIR/kpimg" || { >&2 echo "! Failed to stage kpimg"; exit 1; } +[ -s "$WORKDIR/kpimg" ] || { >&2 echo "! Staged kpimg is empty"; exit 1; } + +echo "- Unpacking current boot image into private workspace" +if ! magiskboot unpack "$BOOT_TARGET" >/dev/null 2>&1; then + >&2 echo "! Unpack failed" exit 1 fi +[ -s kernel ] || { >&2 echo "! Unpack produced no kernel"; exit 1; } -if [ "$SHOULD_BACKUP" -eq 0 ] && [ "$KP_REBACKUP" = "1" ]; then - echo "- KP_REBACKUP=1: forcing fresh backup" - SHOULD_BACKUP=1 +if kptools -i kernel -f 2>/dev/null | grep -q 'CONFIG_KPM=y'; then + >&2 echo "! Built-in KPM detected (CONFIG_KPM=y); PatchNest patching is unsupported" + exit 1 fi - -if [ "$SHOULD_BACKUP" -eq 0 ]; then - LATEST=$(latest_manifest) - if [ -n "$LATEST" ] && is_boot_modified_externally "$BOOTIMAGE" "$LATEST"; then - LATEST_KPSTATE=$(grep -o '"kp_state"[[:space:]]*:[[:space:]]*"[^"]*"' "$LATEST" 2>/dev/null \ - | head -n 1 | sed -E 's/.*"kp_state"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/') - if [ "$LATEST_KPSTATE" = "patched" ]; then - echo "- Boot image SHA256 differs from last backup (kp_state=patched) — taking re-backup" - SHOULD_BACKUP=1 - fi - fi +if ! kptools -i kernel -f 2>/dev/null | grep -q 'CONFIG_KALLSYMS_ALL=y'; then + >&2 echo "! CONFIG_KALLSYMS_ALL is required" + exit 1 fi -if [ "$SHOULD_BACKUP" -eq 1 ]; then - echo "- Backing boot.img" - cp "$BOOTIMAGE" "ori.img" >/dev/null 2>&1 - cp "$BOOTIMAGE" "$TMP_BACKUP" - echo "- Boot backup saved to $TMP_BACKUP" - - # ============================================================ - # Step 3: validate the backup BEFORE patching. - # A corrupt/empty backup is worse than no backup at all — - # auto_unpatch would brick the device. Refuse and bail out. - # ============================================================ - echo "- Validating backup with magiskboot unpack…" - VALIDATE_TMP=$(mktemp -d) - if ! (cd "$VALIDATE_TMP" && magiskboot unpack "$TMP_BACKUP" >/dev/null 2>&1); then - >&2 echo "! Backup validation FAILED — refusing to patch." - >&2 echo "! The captured boot image is corrupt or empty." - >&2 echo "! Removing bad backup: $TMP_BACKUP" - rm -f "$TMP_BACKUP" "$TMP_MANIFEST" - rm -rf "$VALIDATE_TMP" - exit 1 - fi - rm -rf "$VALIDATE_TMP" - echo "- Backup verified." - - # ============================================================ - # Step 5: promote the draft manifest to the final manifest. - # We use sed to flip backup_verified:false → true. This avoids - # re-running detect_root_chain() (which would re-evaluate the - # now-flashed state and yield a different kp_state). - # ============================================================ - sed 's/"backup_verified"[[:space:]]*:[[:space:]]*false/"backup_verified": true/' "$TMP_MANIFEST" > "$TMP_MANIFEST.final" - mv "$TMP_MANIFEST.final" "$TMP_MANIFEST" - echo "- Manifest finalized: $TMP_MANIFEST" -else - # No new backup → drop the draft manifest; the previous one - # remains authoritative. - rm -f "$TMP_MANIFEST" +echo "- Validating embedded KPM arguments" +validate_embedded_kpms "$@" || exit 1 + +# A destructive device write always receives a fresh, target-bound rollback +# snapshot. This avoids stale/newest-by-time recovery ambiguity entirely. +if [ "$FLASH_TO_DEVICE" = "true" ]; then + write_verified_backup || exit 1 fi mv kernel kernel.ori -# ============================================================ -# Validate embedded KPMs before patching -# Parse -M from args and validate each one -# ============================================================ -echo "- Validating embedded modules..." -validate_failed=0 -# Use a positional parse so the first -M is also captured. -# prev_flag is initialized to a sentinel that will never match a real flag. -prev_flag="__start__" -for arg in "$@"; do - case "$prev_flag" in - -M) - kpm_file="$arg" - if [ ! -f "$kpm_file" ]; then - echo "! Embedded KPM not found: $kpm_file" - validate_failed=1 - prev_flag="$arg" - continue - fi - # Check ELF magic (7f 45 4c 46) - magic=$(xxd -l 4 -p "$kpm_file" 2>/dev/null) - if [ "$magic" != "7f454c46" ]; then - echo "! Invalid ELF: $kpm_file (magic=$magic)" - validate_failed=1 - prev_flag="$arg" - continue - fi - # Check aarch64 (e_machine = 0xB7 at offset 18, little-endian) - machine=$(xxd -s 18 -l 2 -e "$kpm_file" 2>/dev/null | awk '{print $2}') - if [ "$machine" != "000000b7" ]; then - echo "! Not aarch64: $kpm_file" - validate_failed=1 - prev_flag="$arg" - continue - fi - # Try kptools validation if available - if kptools -l -M "$kpm_file" >/dev/null 2>&1; then - kpm_name=$(kptools -l -M "$kpm_file" 2>/dev/null | grep "^name=" | cut -d= -f2) - echo " ✓ Valid: ${kpm_name:-$kpm_file}" - else - # kptools -l might not work for all formats, just warn - echo " ⚠ Cannot verify with kptools: $kpm_file (proceeding)" - fi - ;; - esac - prev_flag="$arg" -done - -if [ $validate_failed -ne 0 ]; then - echo "! Embedded KPM validation failed. Aborting patch." - echo "! Remove invalid KPM files and try again." - mv kernel.ori kernel - exit 1 -fi - echo "- Patching kernel" - -set -x -kptools -p -i kernel.ori -k kpimg -o kernel "$@" -patch_rc=$? -set +x - -if [ $patch_rc -ne 0 ]; then - >&2 echo "! Patch kernel error: $patch_rc" +if ! kptools -p -i kernel.ori -k kpimg -o kernel "$@"; then + >&2 echo "! Kernel patch failed" exit 1 fi +[ -s kernel ] || { >&2 echo "! kptools produced an empty kernel"; exit 1; } echo "- Repacking boot image" -if ! magiskboot repack "$BOOTIMAGE" >/dev/null 2>&1; then - >&2 echo "! Repack error" +if ! magiskboot repack "$BOOT_TARGET" >/dev/null 2>&1; then + >&2 echo "! Repack failed" + exit 1 +fi +[ -s new-boot.img ] || { >&2 echo "! Repack produced no new-boot.img"; exit 1; } + +# Validate the complete repacked boot image before either flashing or exporting it. +echo "- Validating repacked boot image" +if ! validate_boot_image "$WORKDIR/new-boot.img"; then + >&2 echo "! Repacked boot image failed validation" exit 1 fi if [ "$FLASH_TO_DEVICE" = "true" ]; then - # flash - if [ -b "$BOOTIMAGE" ] || [ -c "$BOOTIMAGE" ]; then - if [ -f "new-boot.img" ]; then - echo "- Flashing new boot image" - flash_image new-boot.img "$BOOTIMAGE" - if [ $? -ne 0 ]; then - >&2 echo "! Flash error" - save_image_to_storage "new-boot.img" - exit 1 - fi - else - >&2 echo "! new-boot.img missing — refusing to flash" - exit 1 - fi + echo "- Flashing with mandatory SHA-256 readback verification" + if ! flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"; then + _pn_rc=$? + >&2 echo "! Flash/readback verification failed: $_pn_rc" + save_image_to_storage "$WORKDIR/new-boot.img" || true + exit 1 fi - - echo "- Successfully Flashed!" + if ! write_flash_receipt "$WORKDIR/new-boot.img"; then + >&2 echo "! Flash succeeded but receipt creation failed" + exit 1 + fi + echo "- Successfully flashed and verified" else - save_image_to_storage "new-boot.img" - echo "- Successfully Patched!" + save_image_to_storage "$WORKDIR/new-boot.img" || exit 1 + echo "- Successfully patched and validated" fi +exit 0 From 4ac08fb873d5b683277e96de2b9b22184c501e12 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 08:59:36 +0800 Subject: [PATCH 14/94] test(flash): lock transactional patch safety contract --- tests/flash_safety_contract.sh | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/flash_safety_contract.sh diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh new file mode 100644 index 0000000..182e8b9 --- /dev/null +++ b/tests/flash_safety_contract.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PATCH="$ROOT/module/patch/boot_patch.sh" +SAFETY="$ROOT/module/patch/flash_safety.sh" +UNPATCH="$ROOT/module/patch/boot_unpatch.sh" +EXTRACT="$ROOT/module/patch/boot_extract.sh" + +fail() { + echo "flash safety contract: FAIL: $*" >&2 + exit 1 +} + +# Structural gates: these are release invariants, not documentation hints. +grep -Fq '. "$MODPATH/flash_safety.sh"' "$PATCH" || fail "boot_patch does not activate flash_safety" +grep -Fq 'mktemp -d /data/local/tmp/patchnest_patch.XXXXXX' "$PATCH" || fail "patch workspace is not operation-private" +grep -Fq '"boot_target":' "$PATCH" || fail "backup manifest has no target binding" +grep -Fq '"backup_sha256":' "$PATCH" || fail "backup manifest has no backup digest" +grep -Fq '"backup_verified": true' "$PATCH" || fail "backup manifest is not explicitly verified" +grep -Fq 'validate_boot_image "$WORKDIR/new-boot.img"' "$PATCH" || fail "repacked image is not validated" +grep -Fq 'flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"' "$PATCH" || fail "patch path bypasses reviewed flash writer" + +# Old fail-open/stale-workspace patterns must never return. +! grep -Fq 'if [ ! -f kernel ]' "$PATCH" || fail "patch may reuse stale kernel" +! grep -Fq 'Cannot verify with kptools' "$PATCH" || fail "embedded KPM validation is fail-open" +! grep -Fq '(proceeding)' "$PATCH" || fail "embedded KPM validation is fail-open" +! grep -Eq 'TMP_DATE=.*%y%m%d%H%M([^%]|$)' "$PATCH" || fail "backup naming is minute-granularity" + +# All destructive boot flows must activate the reviewed override layer. +for file in "$PATCH" "$UNPATCH" "$EXTRACT"; do + grep -Fq 'flash_safety.sh' "$file" || fail "$(basename "$file") does not source flash_safety" +done + +# Exercise the writer against a regular-file target. This validates the same +# digest contract used by offline tests without requiring a privileged block device. +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +printf '%s\n' 'PatchNest transactional flash contract' > "$TMP/source.img" +printf '%s\n' 'old target contents' > "$TMP/target.img" + +# shellcheck disable=SC1090 +. "$SAFETY" +flash_image "$TMP/source.img" "$TMP/target.img" || fail "file-target flash_image failed" +cmp -s "$TMP/source.img" "$TMP/target.img" || fail "file-target readback differs" + +expected=$(sha256sum "$TMP/source.img" | awk '{print $1}') +actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') +[ "$expected" = "$actual" ] || fail "digest mismatch after verified write" + +echo "flash safety contract: PASS" From 1aaa359da67ffe17ab89e07daaa61efd6d1e4e2c Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 08:59:47 +0800 Subject: [PATCH 15/94] ci(flash): enforce pre-flash safety invariants --- .github/workflows/flash-safety.yml | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/flash-safety.yml diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml new file mode 100644 index 0000000..40b060f --- /dev/null +++ b/.github/workflows/flash-safety.yml @@ -0,0 +1,53 @@ +name: Flash safety + +permissions: + contents: read + +on: + pull_request: + branches: [main] + paths: + - 'module/patch/**' + - 'tests/flash_safety_contract.sh' + - '.github/workflows/flash-safety.yml' + push: + branches: + - 'review/flash-readiness-hardening' + paths: + - 'module/patch/**' + - 'tests/flash_safety_contract.sh' + - '.github/workflows/flash-safety.yml' + workflow_dispatch: + +jobs: + contract: + name: Transactional flash contract + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install shell validation tools + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Shell syntax + run: | + set -euo pipefail + for file in module/patch/*.sh tests/flash_safety_contract.sh; do + sh -n "$file" + done + + - name: ShellCheck reviewed flash path + run: | + shellcheck -s sh -S warning \ + --exclude=SC3043,SC2034,SC2115,SC2046,SC2319,SC2155 \ + module/patch/boot_patch.sh \ + module/patch/boot_extract.sh \ + module/patch/boot_unpatch.sh \ + module/patch/flash_safety.sh \ + tests/flash_safety_contract.sh + + - name: Run transactional flash contract + run: sh tests/flash_safety_contract.sh From 668fa28b76321029d3d32cbdc172e14ac109ddc2 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:01:55 +0800 Subject: [PATCH 16/94] fix(flash): preserve verified write failure status --- module/patch/boot_patch.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/module/patch/boot_patch.sh b/module/patch/boot_patch.sh index 8383c72..f463658 100644 --- a/module/patch/boot_patch.sh +++ b/module/patch/boot_patch.sh @@ -287,8 +287,9 @@ fi if [ "$FLASH_TO_DEVICE" = "true" ]; then echo "- Flashing with mandatory SHA-256 readback verification" - if ! flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"; then - _pn_rc=$? + flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET" + _pn_rc=$? + if [ "$_pn_rc" -ne 0 ]; then >&2 echo "! Flash/readback verification failed: $_pn_rc" save_image_to_storage "$WORKDIR/new-boot.img" || true exit 1 From a23bfb9e4e196941581423369449989b26d5a861 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:10:18 +0800 Subject: [PATCH 17/94] feat(flash): add transactional Public1158 superkey lifecycle --- module/patch/superkey_safety.sh | 90 +++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 module/patch/superkey_safety.sh diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh new file mode 100644 index 0000000..686992f --- /dev/null +++ b/module/patch/superkey_safety.sh @@ -0,0 +1,90 @@ +#!/system/bin/sh +# PatchNest Public1158 superkey lifecycle. +# This file is sourced by boot_patch.sh. It never prints the key value. + +PATCHNEST_SUPERKEY_FILE="${PATCHNEST_SUPERKEY_FILE:-/data/adb/patchnest/superkey}" +PATCHNEST_EXPORT_KEY_DIR="${PATCHNEST_EXPORT_KEY_DIR:-/data/adb/patchnest/export_keys}" +PATCHNEST_SUPERKEY='' +PATCHNEST_SUPERKEY_IS_NEW=0 + +patchnest_validate_superkey() { + _pn_key=$1 + _pn_len=${#_pn_key} + [ "$_pn_len" -ge 32 ] || return 1 + [ "$_pn_len" -le 63 ] || return 1 + # Keep the persisted format intentionally narrow. kptools accepts arbitrary + # strings, but a hex-only key avoids shell/log/parser ambiguity everywhere. + printf '%s' "$_pn_key" | grep -Eq '^[0-9a-fA-F]+$' +} + +patchnest_prepare_superkey() { + _pn_workdir=$1 + PATCHNEST_SUPERKEY='' + PATCHNEST_SUPERKEY_IS_NEW=0 + + if [ -f "$PATCHNEST_SUPERKEY_FILE" ]; then + _pn_existing=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') + if patchnest_validate_superkey "$_pn_existing"; then + PATCHNEST_SUPERKEY=$_pn_existing + export PATCHNEST_SUPERKEY + return 0 + fi + >&2 echo "! Existing PatchNest superkey file is invalid; refusing to overwrite it implicitly" + return 1 + fi + + command -v xxd >/dev/null 2>&1 || { + >&2 echo "! xxd is required to generate a Public1158 superkey" + return 1 + } + [ -r /dev/urandom ] || { + >&2 echo "! /dev/urandom is unavailable" + return 1 + } + + # 24 random bytes -> 48 lowercase hexadecimal characters. This remains + # below Public1158's 0x40-byte key limit while providing 192 random bits. + _pn_generated=$(xxd -p -l 24 /dev/urandom 2>/dev/null | tr -d '\r\n') + patchnest_validate_superkey "$_pn_generated" || { + >&2 echo "! Generated superkey failed local validation" + return 1 + } + + umask 077 + printf '%s\n' "$_pn_generated" > "$_pn_workdir/superkey.candidate" || return 1 + chmod 0600 "$_pn_workdir/superkey.candidate" || return 1 + PATCHNEST_SUPERKEY=$_pn_generated + PATCHNEST_SUPERKEY_IS_NEW=1 + export PATCHNEST_SUPERKEY +} + +patchnest_superkey_sha256() { + [ -n "$PATCHNEST_SUPERKEY" ] || return 1 + printf '%s' "$PATCHNEST_SUPERKEY" | sha256sum | awk '{print $1}' +} + +patchnest_commit_superkey() { + [ -n "$PATCHNEST_SUPERKEY" ] || return 1 + _pn_dir=${PATCHNEST_SUPERKEY_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_SUPERKEY_FILE}.tmp.$$" + printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_tmp" || return 1 + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_FILE" || return 1 + chmod 0600 "$PATCHNEST_SUPERKEY_FILE" || return 1 +} + +patchnest_store_export_key() { + _pn_image=$1 + [ -f "$_pn_image" ] || return 1 + [ -n "$PATCHNEST_SUPERKEY" ] || return 1 + _pn_image_sha=$(sha256sum "$_pn_image" 2>/dev/null | awk '{print $1}') + printf '%s' "$_pn_image_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + mkdir -p "$PATCHNEST_EXPORT_KEY_DIR" || return 1 + umask 077 + _pn_out="$PATCHNEST_EXPORT_KEY_DIR/${_pn_image_sha}.superkey" + printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_out" || return 1 + chmod 0600 "$_pn_out" || return 1 + printf '%s\n' "$_pn_out" +} From 09ac0965047b6980faf118370d381b250c4881a9 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:11:12 +0800 Subject: [PATCH 18/94] feat(flash): bind Public1158 superkey transaction to image write --- module/patch/boot_patch.sh | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/module/patch/boot_patch.sh b/module/patch/boot_patch.sh index f463658..14318f0 100644 --- a/module/patch/boot_patch.sh +++ b/module/patch/boot_patch.sh @@ -21,6 +21,8 @@ INVOCATION_CWD=$(pwd) . "$MODPATH/util_functions.sh" # shellcheck disable=SC1091 . "$MODPATH/flash_safety.sh" +# shellcheck disable=SC1091 +. "$MODPATH/superkey_safety.sh" BOOTIMAGE=${1:-} FLASH_TO_DEVICE=${2:-} @@ -151,6 +153,7 @@ write_verified_backup() { _pn_taken_at=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) _pn_kpimg_sha=$(hash_path "$WORKDIR/kpimg") || return 1 + _pn_superkey_sha=$(patchnest_superkey_sha256) || return 1 cat > "$MANIFEST_CANDIDATE.tmp" </dev/null | tr -d ' ') _pn_time=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) + _pn_key_sha=$(patchnest_superkey_sha256) || return 1 mkdir -p "$PNDIR" || return 1 cat > "$PNDIR/last_flash.json.tmp" <&2 echo "! Failed to stage kpimg"; exit 1; } [ -s "$WORKDIR/kpimg" ] || { >&2 echo "! Staged kpimg is empty"; exit 1; } +# Public1158 KPM management is superkey-authenticated. Prepare a key before +# building the image, but do not persist a newly generated key until a device +# write has passed exact-range readback verification. +patchnest_prepare_superkey "$WORKDIR" || exit 1 + echo "- Unpacking current boot image into private workspace" if ! magiskboot unpack "$BOOT_TARGET" >/dev/null 2>&1; then >&2 echo "! Unpack failed" @@ -265,7 +276,7 @@ fi mv kernel kernel.ori echo "- Patching kernel" -if ! kptools -p -i kernel.ori -k kpimg -o kernel "$@"; then +if ! kptools -p -i kernel.ori -k kpimg -s "$PATCHNEST_SUPERKEY" -o kernel "$@"; then >&2 echo "! Kernel patch failed" exit 1 fi @@ -294,14 +305,34 @@ if [ "$FLASH_TO_DEVICE" = "true" ]; then save_image_to_storage "$WORKDIR/new-boot.img" || true exit 1 fi + + if ! patchnest_commit_superkey; then + >&2 echo "! Patched image verified, but superkey persistence failed" + >&2 echo "! Rolling back to the verified pre-write boot image" + flash_image "$BACKUP_CANDIDATE" "$BOOT_TARGET" + _pn_rollback_rc=$? + if [ "$_pn_rollback_rc" -ne 0 ]; then + >&2 echo "! CRITICAL: automatic rollback failed: $_pn_rollback_rc" + >&2 echo "! Verified recovery image remains at: $BACKUP_CANDIDATE" + else + echo "- Rollback verified after superkey persistence failure" + fi + exit 1 + fi + if ! write_flash_receipt "$WORKDIR/new-boot.img"; then - >&2 echo "! Flash succeeded but receipt creation failed" + >&2 echo "! Flash and superkey commit succeeded, but receipt creation failed" exit 1 fi - echo "- Successfully flashed and verified" + echo "- Successfully flashed, read back, and committed Public1158 credentials" else + _pn_export_key=$(patchnest_store_export_key "$WORKDIR/new-boot.img") || { + >&2 echo "! Could not store root-only credential record for exported image" + exit 1 + } save_image_to_storage "$WORKDIR/new-boot.img" || exit 1 echo "- Successfully patched and validated" + echo "- Root-only credential record: $_pn_export_key" fi exit 0 From 00bb7e2f83c282e92812b54ed2ec37ca4f711ad0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:11:38 +0800 Subject: [PATCH 19/94] test(flash): verify transactional superkey contract --- tests/flash_safety_contract.sh | 37 ++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh index 182e8b9..1b65bea 100644 --- a/tests/flash_safety_contract.sh +++ b/tests/flash_safety_contract.sh @@ -4,6 +4,7 @@ set -eu ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) PATCH="$ROOT/module/patch/boot_patch.sh" SAFETY="$ROOT/module/patch/flash_safety.sh" +SUPERKEY="$ROOT/module/patch/superkey_safety.sh" UNPATCH="$ROOT/module/patch/boot_unpatch.sh" EXTRACT="$ROOT/module/patch/boot_extract.sh" @@ -14,12 +15,16 @@ fail() { # Structural gates: these are release invariants, not documentation hints. grep -Fq '. "$MODPATH/flash_safety.sh"' "$PATCH" || fail "boot_patch does not activate flash_safety" +grep -Fq '. "$MODPATH/superkey_safety.sh"' "$PATCH" || fail "boot_patch does not activate superkey lifecycle" grep -Fq 'mktemp -d /data/local/tmp/patchnest_patch.XXXXXX' "$PATCH" || fail "patch workspace is not operation-private" grep -Fq '"boot_target":' "$PATCH" || fail "backup manifest has no target binding" grep -Fq '"backup_sha256":' "$PATCH" || fail "backup manifest has no backup digest" +grep -Fq '"superkey_sha256":' "$PATCH" || fail "backup/receipt has no credential binding" grep -Fq '"backup_verified": true' "$PATCH" || fail "backup manifest is not explicitly verified" grep -Fq 'validate_boot_image "$WORKDIR/new-boot.img"' "$PATCH" || fail "repacked image is not validated" grep -Fq 'flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"' "$PATCH" || fail "patch path bypasses reviewed flash writer" +grep -Fq -- '-s "$PATCHNEST_SUPERKEY"' "$PATCH" || fail "Public1158 superkey is not embedded by kptools" +grep -Fq 'patchnest_commit_superkey' "$PATCH" || fail "verified flash does not commit its matching superkey" # Old fail-open/stale-workspace patterns must never return. ! grep -Fq 'if [ ! -f kernel ]' "$PATCH" || fail "patch may reuse stale kernel" @@ -32,10 +37,11 @@ for file in "$PATCH" "$UNPATCH" "$EXTRACT"; do grep -Fq 'flash_safety.sh' "$file" || fail "$(basename "$file") does not source flash_safety" done -# Exercise the writer against a regular-file target. This validates the same -# digest contract used by offline tests without requiring a privileged block device. TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +# Exercise the writer against a regular-file target. This validates the same +# digest contract used by offline tests without requiring a privileged block device. printf '%s\n' 'PatchNest transactional flash contract' > "$TMP/source.img" printf '%s\n' 'old target contents' > "$TMP/target.img" @@ -48,4 +54,31 @@ expected=$(sha256sum "$TMP/source.img" | awk '{print $1}') actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') [ "$expected" = "$actual" ] || fail "digest mismatch after verified write" +# Exercise the Public1158 credential lifecycle entirely in the temp tree. +PATCHNEST_SUPERKEY_FILE="$TMP/state/superkey" +PATCHNEST_EXPORT_KEY_DIR="$TMP/state/export_keys" +export PATCHNEST_SUPERKEY_FILE PATCHNEST_EXPORT_KEY_DIR +# shellcheck disable=SC1090 +. "$SUPERKEY" +patchnest_prepare_superkey "$TMP" || fail "superkey preparation failed" +patchnest_validate_superkey "$PATCHNEST_SUPERKEY" || fail "generated key is invalid" +[ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || fail "new key persisted before flash commit" +key_before=$PATCHNEST_SUPERKEY +key_sha=$(patchnest_superkey_sha256) +printf '%s' "$key_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "superkey digest is invalid" + +patchnest_commit_superkey || fail "superkey commit failed" +[ -f "$PATCHNEST_SUPERKEY_FILE" ] || fail "committed superkey missing" +[ "$(stat -c '%a' "$PATCHNEST_SUPERKEY_FILE")" = "600" ] || fail "committed superkey mode is not 0600" +[ "$(cat "$PATCHNEST_SUPERKEY_FILE")" = "$key_before" ] || fail "committed key changed" + +# A later patch must reuse the persisted credential rather than silently rotate it. +PATCHNEST_SUPERKEY='' +patchnest_prepare_superkey "$TMP" || fail "persisted superkey reload failed" +[ "$PATCHNEST_SUPERKEY" = "$key_before" ] || fail "persisted key was not reused" + +export_record=$(patchnest_store_export_key "$TMP/source.img") || fail "export key record failed" +[ -f "$export_record" ] || fail "export key record missing" +[ "$(stat -c '%a' "$export_record")" = "600" ] || fail "export key record mode is not 0600" + echo "flash safety contract: PASS" From 9f7fb3bf0275977671778b34ea92efb6f1f50f4f Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:11:58 +0800 Subject: [PATCH 20/94] ci(flash): lint superkey safety lifecycle --- .github/workflows/flash-safety.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 40b060f..dd5372b 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -47,6 +47,7 @@ jobs: module/patch/boot_extract.sh \ module/patch/boot_unpatch.sh \ module/patch/flash_safety.sh \ + module/patch/superkey_safety.sh \ tests/flash_safety_contract.sh - name: Run transactional flash contract From b2258cf236b735dd39707df5b3bc808344085091 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:12:58 +0800 Subject: [PATCH 21/94] build(module): pin reviewed Public1158 CLI source commit --- version.properties | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/version.properties b/version.properties index 224353c..97ca774 100644 --- a/version.properties +++ b/version.properties @@ -1,13 +1,17 @@ -# PatchNest module dependency versions and trusted release digests. +# PatchNest module dependency versions and trusted source/binary identities. # Used by build.sh and .github/workflows/build.yaml. patchnest="0.13.5-2" kernelpatch="0.13.3" magiskboot="v30.7" -# SHA256 values are calculated by downloading the exact GitHub Release assets -# consumed by the build. Updating a dependency tag requires reviewing the -# upstream release and replacing the matching digest in the same commit. +# The module uses the Public1158 compatibility build, not the historical +# Next2026 kpatch-android release asset. Pin the exact reviewed PatchNest source +# commit used to compile kpatch-public1158. +patchnest_public1158_commit=b8b071e6173e51991cf3670410c7fc2259891feb + +# SHA256 values for external binary release assets consumed by the build. +# Updating a dependency tag requires reviewing the upstream release and +# replacing the matching digest in the same commit. kpimg_linux_0.13.3=7b8cf7e97169d2d73bba2e11653ad5bdbc6fc6251c5507b8d108f4a2e0bcd76f kptools_android_0.13.3=ebf9b8eb17b4b3a6b1d4959402033bb1c6c4044d0e2532d480c34d1f412d5225 -kpatch_android_0.13.5-2=d6a654816f11c8d297ca59aaace9c61537238f26191738c14610d2dcf39bf3b0 magisk_apk_v30.7=e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5 From f057f5bdb064d7f3f18bb340e9452c16c1129d5d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:13:31 +0800 Subject: [PATCH 22/94] build(module): compile pinned Public1158 CLI instead of mixing Next ABI --- build.sh | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/build.sh b/build.sh index 23372a4..3fa11ff 100644 --- a/build.sh +++ b/build.sh @@ -82,10 +82,50 @@ download_assets() { done } +build_public1158_cli() { + local commit="$1" + local out="$2" + + [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || { + echo "ERROR: invalid patchnest_public1158_commit: $commit" >&2 + exit 1 + } + [[ -n "${ANDROID_NDK_HOME:-}" ]] || { + echo "ERROR: ANDROID_NDK_HOME is required to build the pinned Public1158 CLI" >&2 + exit 1 + } + command -v git >/dev/null 2>&1 || { echo "ERROR: git is required" >&2; exit 1; } + command -v cmake >/dev/null 2>&1 || { echo "ERROR: cmake is required" >&2; exit 1; } + command -v ninja >/dev/null 2>&1 || { echo "ERROR: ninja is required" >&2; exit 1; } + + local tmp + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' RETURN + + git clone -q --filter=blob:none --no-checkout https://github.com/Zhanfg/PatchNest.git "$tmp/PatchNest" + git -C "$tmp/PatchNest" fetch -q --depth=1 origin "$commit" + git -C "$tmp/PatchNest" checkout -q --detach "$commit" + test "$(git -C "$tmp/PatchNest" rev-parse HEAD)" = "$commit" + + cmake -S "$tmp/PatchNest" -B "$tmp/build" \ + -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DANDROID_PLATFORM=android-33 \ + -DANDROID_ABI=arm64-v8a + cmake --build "$tmp/build" --target kpatch-public1158 --parallel + test -s "$tmp/build/kpatch-public1158" + cp "$tmp/build/kpatch-public1158" "$out" + chmod 0755 "$out" + rm -rf "$tmp" + trap - RETURN +} + VERSION_KERNELPATCH=$(get_ver "kernelpatch") VERSION_KERNELPATCH="${VERSION_KERNELPATCH:-latest}" VERSION_PATCHNEST=$(get_ver "patchnest") VERSION_PATCHNEST="${VERSION_PATCHNEST:-latest}" +VERSION_PATCHNEST_PUBLIC1158_COMMIT=$(get_ver "patchnest_public1158_commit") VERSION_MAGISKBOOT=$(get_ver "magiskboot") VERSION_MAGISKBOOT="${VERSION_MAGISKBOOT:-latest}" @@ -96,10 +136,10 @@ if [[ ! -f "module/bin/kpimg" || ! -f "module/bin/kptools" ]]; then mv module/bin/kptools-android module/bin/kptools fi -# Fetch the PatchNest user-space tool. +# Build the userspace CLI from the exact reviewed Public1158 compatibility +# commit. Never substitute the historical Next2026 kpatch-android asset here. if [[ ! -f "module/bin/kpatch" ]]; then - download_assets "Zhanfg/PatchNest" "$VERSION_PATCHNEST" "module/bin" "kpatch-android" - mv module/bin/kpatch-android module/bin/kpatch + build_public1158_cli "$VERSION_PATCHNEST_PUBLIC1158_COMMIT" "module/bin/kpatch" fi # Fetch and extract magiskboot from the pinned official Magisk APK. From 7425d80f12285998262fafe42da57bfce309f8ea Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:14:21 +0800 Subject: [PATCH 23/94] build(module): eliminate mixed userspace/kernel ABI package --- .github/workflows/build.yaml | 75 +++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 12ff937..5a461c1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -67,16 +67,22 @@ jobs: exit 1 } - for key in patchnest kernelpatch magiskboot; do + for key in patchnest kernelpatch magiskboot patchnest_public1158_commit; do grep -Fq "${key}=" version.properties || { echo "::error::Missing version key: $key" exit 1 } done + + source_commit=$(grep -F 'patchnest_public1158_commit=' version.properties | head -n 1 | cut -d= -f2- | tr -d '"') + printf '%s' "$source_commit" | grep -Eq '^[0-9a-f]{40}$' || { + echo "::error::patchnest_public1158_commit must be an exact 40-hex commit" + exit 1 + } + for key in \ kpimg_linux_0.13.3 \ kptools_android_0.13.3 \ - kpatch_android_0.13.5-2 \ magisk_apk_v30.7; do value=$(grep -F "${key}=" version.properties | head -n 1 | cut -d= -f2-) printf '%s' "$value" | grep -Eq '^[0-9a-f]{64}$' || { @@ -85,6 +91,11 @@ jobs: } done + if grep -q '^kpatch_android_' version.properties; then + echo "::error::Historical Next2026 kpatch release pin must not be used by the Public1158 module" + exit 1 + fi + - name: Check shell syntax run: | set -euo pipefail @@ -101,7 +112,8 @@ jobs: module/action.sh module/status.sh module/uninstall.sh \ module/install_kpm.sh module/compile_kpm.sh \ module/patch/boot_patch.sh module/patch/boot_extract.sh \ - module/patch/boot_unpatch.sh + module/patch/boot_unpatch.sh module/patch/flash_safety.sh \ + module/patch/superkey_safety.sh shellcheck -s sh -S error module/patch/util_functions.sh || true - name: Verify locale completeness @@ -166,12 +178,12 @@ jobs: - name: Install build tools run: | sudo apt-get update - sudo apt-get install -y jq zip unzip file + sudo apt-get install -y jq zip unzip file cmake ninja-build - name: Load dependency versions run: | set -euo pipefail - for key in patchnest kernelpatch magiskboot; do + for key in patchnest kernelpatch magiskboot patchnest_public1158_commit; do value=$(grep -F "${key}=" version.properties | head -n 1 | cut -d= -f2- | tr -d '"') [ -n "$value" ] || { echo "::error::Missing $key"; exit 1; } echo "$key=$value" >> "$GITHUB_ENV" @@ -186,9 +198,6 @@ jobs: gh release download "$kernelpatch" \ -R Zhanfg/KernelPatch-Public \ -p kpimg-linux -p kptools-android -D module/bin - gh release download "$patchnest" \ - -R Zhanfg/PatchNest \ - -p kpatch-android -D module/bin gh release download "$magiskboot" \ -R topjohnwu/Magisk \ -p 'Magisk*.apk' -O magisk.apk @@ -213,20 +222,56 @@ jobs: } verify "kpimg_linux_${kernelpatch}" module/bin/kpimg-linux verify "kptools_android_${kernelpatch}" module/bin/kptools-android - verify "kpatch_android_${patchnest}" module/bin/kpatch-android verify "magisk_apk_${magiskboot}" magisk.apk exit "$fail" - - name: Prepare module binaries + - name: Prepare core module binaries run: | set -euo pipefail mv module/bin/kpimg-linux module/bin/kpimg mv module/bin/kptools-android module/bin/kptools - mv module/bin/kpatch-android module/bin/kpatch unzip -p magisk.apk 'lib/arm64-v8a/libmagiskboot.so' > module/bin/magiskboot test -s module/bin/magiskboot rm magisk.apk - chmod 0755 module/bin/kpatch module/bin/kptools module/bin/magiskboot + chmod 0755 module/bin/kptools module/bin/magiskboot + + - name: Build pinned Public1158 userspace CLI + run: | + set -euo pipefail + printf '%s' "$patchnest_public1158_commit" | grep -Eq '^[0-9a-f]{40}$' + rm -rf /tmp/patchnest-public1158 build/public1158 + git clone --filter=blob:none --no-checkout https://github.com/Zhanfg/PatchNest.git /tmp/patchnest-public1158 + git -C /tmp/patchnest-public1158 fetch --depth=1 origin "$patchnest_public1158_commit" + git -C /tmp/patchnest-public1158 checkout --detach "$patchnest_public1158_commit" + test "$(git -C /tmp/patchnest-public1158 rev-parse HEAD)" = "$patchnest_public1158_commit" + + cmake -S /tmp/patchnest-public1158 -B build/public1158 \ + -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DANDROID_PLATFORM=android-33 \ + -DANDROID_ABI=arm64-v8a + cmake --build build/public1158 --target kpatch-public1158 --parallel + cp build/public1158/kpatch-public1158 module/bin/kpatch + chmod 0755 module/bin/kpatch + + strings module/bin/kpatch | grep -Fxq 'public1158' + strings module/bin/kpatch | grep -Fxq 'hello1158' + if strings module/bin/kpatch | grep -Fxq 'hello2026'; then + echo "::error::Packaged kpatch still contains Next2026 hello identity" + exit 1 + fi + + mkdir -p module/provenance + cli_sha=$(sha256sum module/bin/kpatch | awk '{print $1}') + cat > module/provenance/kpatch-public1158.json </dev/null + test "$(jq -r '.sourceCommit' module/provenance/kpatch-public1158.json)" = "$patchnest_public1158_commit" + test "$(jq -r '.binarySha256' module/provenance/kpatch-public1158.json)" = "$(sha256sum module/bin/kpatch | awk '{print $1}')" - name: Build WebUI run: | @@ -268,6 +316,7 @@ jobs: sha256sum out/PatchNest-Module.zip > out/PatchNest-Module.zip.sha256 unzip -l out/PatchNest-Module.zip | grep -q 'module.prop' unzip -l out/PatchNest-Module.zip | grep -q 'webroot/index.html' + unzip -l out/PatchNest-Module.zip | grep -q 'provenance/kpatch-public1158.json' - name: Determine release tag id: release_meta From ee71653bd57a045959957827e801414c040fb811 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:16:55 +0800 Subject: [PATCH 24/94] test(flash): keep repo root discovery POSIX-clean --- tests/flash_safety_contract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh index 1b65bea..aedac8d 100644 --- a/tests/flash_safety_contract.sh +++ b/tests/flash_safety_contract.sh @@ -1,7 +1,7 @@ #!/bin/sh set -eu -ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) PATCH="$ROOT/module/patch/boot_patch.sh" SAFETY="$ROOT/module/patch/flash_safety.sh" SUPERKEY="$ROOT/module/patch/superkey_safety.sh" From 7269dd48178a06fe4b442306528cd21f7879a825 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:23:07 +0800 Subject: [PATCH 25/94] fix(runtime): gate lifecycle operations by explicit ABI profile --- module/service.sh | 78 +++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/module/service.sh b/module/service.sh index 52d3d3c..33830ea 100644 --- a/module/service.sh +++ b/module/service.sh @@ -59,28 +59,42 @@ if [ ! -x "$MODDIR/bin/kpatch" ]; then exit 0 fi -# kpatch hello is the package-level ABI readiness gate. The hardened CLI now -# returns non-zero when the syscall fails or the kernel handshake magic does -# not match, so do not treat an empty/foreign handshake as success. +# kpatch hello is the package-level ABI readiness gate. Capture the exact echo +# and turn it into a capability profile; never infer mutation safety from a +# numeric command ID shared by multiple KernelPatch families. retries=0 max_retries=5 +hello_out="" while [ "$retries" -lt "$max_retries" ]; do hello_out="$(kpatch hello 2>>"$LOG")" - if [ $? -eq 0 ] && [ -n "$hello_out" ]; then + hello_rc=$? + if [ "$hello_rc" -eq 0 ] && [ -n "$hello_out" ]; then break fi - echo "[$(date)] kpatch hello attempt $((retries + 1)) failed, retrying..." >> "$LOG" - sleep 2 retries=$((retries + 1)) + echo "[$(date)] kpatch hello attempt $retries failed, retrying..." >> "$LOG" + sleep 2 done -hello_out="$(kpatch hello 2>>"$LOG")" -if [ $? -ne 0 ] || [ -z "$hello_out" ]; then + +if [ "$hello_rc" -ne 0 ] || [ -z "$hello_out" ]; then echo "[$(date)] ERROR: kpatch/kernel ABI handshake failed after $retries retries" >> "$LOG" - echo "[$(date)] Refusing KPM/exclude/rehook operations; package is unresolved." >> "$LOG" + echo "[$(date)] Refusing KPM/exclude/rehook/event operations; package is unresolved." >> "$LOG" touch "$MODDIR/unresolved" exit 0 fi -echo "[$(date)] kpatch hello OK: $hello_out" >> "$LOG" + +case "$hello_out" in + hello1158) ABI_PROFILE=public1158 ;; + hello2026) ABI_PROFILE=next2026 ;; + *) + echo "[$(date)] ERROR: unrecognized successful hello response: $hello_out" >> "$LOG" + touch "$MODDIR/unresolved" + exit 0 + ;; +esac + +echo "[$(date)] kpatch hello OK: $hello_out profile=$ABI_PROFILE" >> "$LOG" +printf '%s\n' "$ABI_PROFILE" > "$PNDIR/abi_profile" # Healthy userspace/kernel handshake. This only clears the userspace marker; # it does not claim physical boot-loop recovery has been validated. @@ -116,9 +130,8 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi fi - # The current C CLI accepts `load PATH [ARGS]`; it does not parse `--` as - # an option terminator. Preserve the whole sanitized args string as one - # argv element instead of accidentally sending literal "--" to the KPM. + # The C CLI accepts `load PATH [ARGS]`; preserve the sanitized args string + # as one argv element rather than sending a literal option terminator. if [ -n "$args" ]; then kpatch kpm load "$kpm" "$args" else @@ -132,8 +145,13 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi done +# 0x1100/0x1101 are rehook in Next2026 but SU grant/revoke in Public1158. +# Never dispatch rehook merely because the numeric command exists. if [ -n "$REHOOK" ]; then - if [ "$REHOOK" = "enable" ] || [ "$REHOOK" = "disable" ]; then + if [ "$ABI_PROFILE" = "public1158" ]; then + echo "[$(date)] rehook request ignored: unsupported and unsafe on Public1158" >> "$LOG" + rm -f "$PNDIR/rehook" + elif [ "$REHOOK" = "enable" ] || [ "$REHOOK" = "disable" ]; then if kpatch rehook "$REHOOK" >>"$LOG" 2>&1; then echo "[$(date)] rehook $REHOOK" >> "$LOG" else @@ -147,32 +165,35 @@ fi dispatch_event() { event_name="$1" - # PatchNest's current KPatch-Next-derived CLI has no `event` command. Do - # not silently pretend lifecycle dispatch succeeded. A future ABI backend - # may expose it; until then this remains explicitly unavailable. - if kpatch --help 2>/dev/null | grep -q '^[[:space:]]*event[[:space:]]'; then - echo "[$(date)] Dispatching event: $event_name" >> "$LOG" - if ! kpatch event "$event_name" "" "" >>"$LOG" 2>&1; then - echo "[$(date)] WARN: event dispatch failed: $event_name" >> "$LOG" - fi - else - echo "[$(date)] Event dispatch unavailable in packaged kpatch ABI: $event_name" >> "$LOG" + if [ "$ABI_PROFILE" != "public1158" ]; then + echo "[$(date)] Event $event_name skipped: ABI $ABI_PROFILE has no reviewed event capability" >> "$LOG" + return 0 + fi + + echo "[$(date)] Dispatching Public1158 event: $event_name" >> "$LOG" + if ! kpatch event "$event_name" "PatchNest" "" >>"$LOG" 2>&1; then + echo "[$(date)] ERROR: Public1158 event dispatch failed: $event_name" >> "$LOG" + touch "$MODDIR/unresolved" + return 1 fi + return 0 } -dispatch_event "POST_FS_DATA" +dispatch_event "POST_FS_DATA" || true wait_count=0 until [ "$(getprop sys.boot_completed)" = "1" ]; do sleep 1 wait_count=$((wait_count + 1)) if [ "$wait_count" -ge 300 ]; then - echo "[$(date)] WARN: boot_completed timeout, continuing anyway" >> "$LOG" + echo "[$(date)] WARN: boot_completed timeout; BOOT_COMPLETED event will not be forged" >> "$LOG" break fi done -dispatch_event "BOOT_COMPLETED" +if [ "$(getprop sys.boot_completed)" = "1" ]; then + dispatch_event "BOOT_COMPLETED" || true +fi if [ -f "$CONFIG" ]; then excluded_count=0 @@ -200,6 +221,9 @@ if [ -f "$CONFIG" ]; then done < "$_cfg_tmp" rm -f "$_cfg_tmp" echo "[$(date)] exclusion: applied=$excluded_count failed=$excluded_failed" >> "$LOG" + if [ "$excluded_failed" -gt 0 ]; then + touch "$MODDIR/unresolved" + fi fi echo "[$(date)] service.sh completed" >> "$LOG" From 9a69bd331521e97abaafaabfc8dc4a8aec09d3ef Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:23:41 +0800 Subject: [PATCH 26/94] build(module): pin event-capable Public1158 CLI head --- version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.properties b/version.properties index 97ca774..ccf8788 100644 --- a/version.properties +++ b/version.properties @@ -7,7 +7,7 @@ magiskboot="v30.7" # The module uses the Public1158 compatibility build, not the historical # Next2026 kpatch-android release asset. Pin the exact reviewed PatchNest source # commit used to compile kpatch-public1158. -patchnest_public1158_commit=b8b071e6173e51991cf3670410c7fc2259891feb +patchnest_public1158_commit=7fed93c4e259a6edf191c1a9900874babb232c4b # SHA256 values for external binary release assets consumed by the build. # Updating a dependency tag requires reviewing the upstream release and From e7eb2274dc8e4fe902bf7ab9439401119d8e3698 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:24:15 +0800 Subject: [PATCH 27/94] build(module): add deterministic ZIP packager --- scripts/package_module.sh | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 scripts/package_module.sh diff --git a/scripts/package_module.sh b/scripts/package_module.sh new file mode 100644 index 0000000..cf837cf --- /dev/null +++ b/scripts/package_module.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +SOURCE_DIR=${1:-} +OUTPUT=${2:-} + +[ -n "$SOURCE_DIR" ] && [ -d "$SOURCE_DIR" ] || { + echo "usage: package_module.sh " >&2 + exit 2 +} +[ -n "$OUTPUT" ] || { + echo "usage: package_module.sh " >&2 + exit 2 +} + +case "$OUTPUT" in + /*) OUTPUT_ABS=$OUTPUT ;; + *) OUTPUT_ABS="$(pwd)/$OUTPUT" ;; +esac + +command -v zip >/dev/null 2>&1 || { echo "zip is required" >&2; exit 1; } +command -v sort >/dev/null 2>&1 || { echo "sort is required" >&2; exit 1; } + +STAGE=$(mktemp -d) +cleanup() { + rm -rf "$STAGE" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "$STAGE/module" +cp -a "$SOURCE_DIR/." "$STAGE/module/" + +# ZIP's DOS timestamp field cannot represent dates before 1980. A fixed UTC +# timestamp makes package bytes independent from checkout/build wall-clock time. +find "$STAGE/module" -exec touch -h -t 200001010000.00 {} + + +mkdir -p "$(dirname "$OUTPUT_ABS")" +rm -f "$OUTPUT_ABS" + +( + cd "$STAGE/module" + # Stable lexical path order + -X (no UID/GID/extra timestamp fields). + # File modes are preserved by cp -a and stored by zip on Unix. + find . -type f -print | LC_ALL=C sort > "$STAGE/file-list" + [ -s "$STAGE/file-list" ] || { + echo "module tree contains no files" >&2 + exit 1 + } + zip -X -q "$OUTPUT_ABS" -@ < "$STAGE/file-list" +) + +[ -s "$OUTPUT_ABS" ] || { echo "deterministic package is empty" >&2; exit 1; } From bd9209650bd6b23ff14f0465428e553ea7ba16a7 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:25:08 +0800 Subject: [PATCH 28/94] build(module): prove deterministic local package bytes --- build.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index 3fa11ff..5a372f2 100644 --- a/build.sh +++ b/build.sh @@ -185,7 +185,15 @@ fi commit_number=$(git rev-list --count HEAD) commit_hash=$(git rev-parse --short HEAD) - -cd module -zip -r "../out/PatchNest-${commit_number}-${commit_hash}.zip" . -cd .. +package="out/PatchNest-${commit_number}-${commit_hash}.zip" +repeat="out/PatchNest-${commit_number}-${commit_hash}.repeat.zip" + +sh scripts/package_module.sh module "$package" +sh scripts/package_module.sh module "$repeat" +cmp -s "$package" "$repeat" || { + echo "ERROR: same-tree module packaging is not byte-reproducible" >&2 + exit 1 +} +rm -f "$repeat" +sha256sum "$package" > "${package}.sha256" +echo "✓ deterministic package: $package" From 9a722617949678bff37f06dfb7b4a1dee8a165d0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:26:08 +0800 Subject: [PATCH 29/94] ci(module): prove final ZIP byte reproducibility --- .github/workflows/build.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5a461c1..1e41f98 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -99,13 +99,14 @@ jobs: - name: Check shell syntax run: | set -euo pipefail - for file in build.sh module/*.sh module/patch/*.sh; do + for file in build.sh scripts/package_module.sh module/*.sh module/patch/*.sh; do bash -n "$file" done - name: ShellCheck run: | shellcheck -s bash -S error build.sh + shellcheck -s sh -S warning scripts/package_module.sh shellcheck -s sh -S warning \ --exclude=SC3043,SC2034,SC2115,SC2046,SC2319,SC2155 \ module/service.sh module/customize.sh module/post-fs-data.sh \ @@ -307,11 +308,18 @@ jobs: - name: Validate complete module run: node tests/validate_module.js - - name: Package module + - name: Package module reproducibly run: | set -euo pipefail mkdir -p out - (cd module && zip -qr ../out/PatchNest-Module.zip .) + sh scripts/package_module.sh module out/PatchNest-Module.zip + sh scripts/package_module.sh module out/PatchNest-Module.repeat.zip + cmp -s out/PatchNest-Module.zip out/PatchNest-Module.repeat.zip || { + echo "::error::Same assembled module tree produced different ZIP bytes" + sha256sum out/PatchNest-Module.zip out/PatchNest-Module.repeat.zip + exit 1 + } + rm -f out/PatchNest-Module.repeat.zip test -s out/PatchNest-Module.zip sha256sum out/PatchNest-Module.zip > out/PatchNest-Module.zip.sha256 unzip -l out/PatchNest-Module.zip | grep -q 'module.prop' From 6290be1ebcb1c3eb9f9b355d03fe25e9e168302d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:28:05 +0800 Subject: [PATCH 30/94] fix(package): store canonical module root paths --- scripts/package_module.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index cf837cf..62ca455 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -40,8 +40,10 @@ rm -f "$OUTPUT_ABS" ( cd "$STAGE/module" # Stable lexical path order + -X (no UID/GID/extra timestamp fields). + # Strip the find(1) "./" prefix so module.prop and META-INF live at the + # canonical ZIP root expected by Android root-manager installers. # File modes are preserved by cp -a and stored by zip on Unix. - find . -type f -print | LC_ALL=C sort > "$STAGE/file-list" + find . -type f -print | sed 's#^\./##' | LC_ALL=C sort > "$STAGE/file-list" [ -s "$STAGE/file-list" ] || { echo "module tree contains no files" >&2 exit 1 From 2dddddd65d9f485d3ce5f9e0652f550357adb5f3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:28:39 +0800 Subject: [PATCH 31/94] test(runtime): lock ABI capability boundaries --- tests/runtime_abi_contract.sh | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/runtime_abi_contract.sh diff --git a/tests/runtime_abi_contract.sh b/tests/runtime_abi_contract.sh new file mode 100644 index 0000000..b5915ae --- /dev/null +++ b/tests/runtime_abi_contract.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +SERVICE="$ROOT/module/service.sh" +VERSIONS="$ROOT/version.properties" + +fail() { + echo "runtime ABI contract: FAIL: $*" >&2 + exit 1 +} + +# Successful hello values must map to explicit profiles; there is no generic +# "non-empty output means ready" path. +grep -Fq 'hello1158) ABI_PROFILE=public1158' "$SERVICE" || fail "Public1158 hello mapping missing" +grep -Fq 'hello2026) ABI_PROFILE=next2026' "$SERVICE" || fail "Next2026 hello mapping missing" +grep -Fq 'unrecognized successful hello response' "$SERVICE" || fail "unknown hello is not fail-closed" + +# Public1158 command 0x1100/0x1101 means SU grant/revoke, not rehook. The +# service must branch on profile before any `kpatch rehook` invocation. +public_guard=$(grep -n 'ABI_PROFILE.*public1158' "$SERVICE" | grep 'rehook' -B1 -A1 | head -n1 | cut -d: -f1 || true) +rehook_call=$(grep -n 'kpatch rehook' "$SERVICE" | head -n1 | cut -d: -f1 || true) +[ -n "$public_guard" ] || fail "Public1158 rehook guard missing" +[ -n "$rehook_call" ] || fail "Next2026 rehook call missing" +[ "$public_guard" -lt "$rehook_call" ] || fail "rehook call occurs before Public1158 guard" +grep -Fq 'rehook request ignored: unsupported and unsafe on Public1158' "$SERVICE" || fail "Public1158 rehook rejection is not explicit" + +# KPM event dispatch is a reviewed Public1158 capability only. +grep -Fq 'ABI_PROFILE" != "public1158' "$SERVICE" || fail "event dispatch is not profile-gated" +grep -Fq 'kpatch event "$event_name" "PatchNest" ""' "$SERVICE" || fail "Public1158 event dispatch missing" + +# The module may only build the separately reviewed Public1158 compatibility +# binary. Reintroducing the historical Next release asset is forbidden. +grep -Fq 'patchnest_public1158_commit=' "$VERSIONS" || fail "Public1158 source pin missing" +! grep -q '^kpatch_android_' "$VERSIONS" || fail "Next2026 release binary pin reintroduced" + +echo "runtime ABI contract: PASS" From fc109ff4f3caa1c32bd25825d201cd55b0569320 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:29:05 +0800 Subject: [PATCH 32/94] test(runtime): make capability guard ordering assertion exact --- tests/runtime_abi_contract.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/runtime_abi_contract.sh b/tests/runtime_abi_contract.sh index b5915ae..045e5df 100644 --- a/tests/runtime_abi_contract.sh +++ b/tests/runtime_abi_contract.sh @@ -18,7 +18,7 @@ grep -Fq 'unrecognized successful hello response' "$SERVICE" || fail "unknown he # Public1158 command 0x1100/0x1101 means SU grant/revoke, not rehook. The # service must branch on profile before any `kpatch rehook` invocation. -public_guard=$(grep -n 'ABI_PROFILE.*public1158' "$SERVICE" | grep 'rehook' -B1 -A1 | head -n1 | cut -d: -f1 || true) +public_guard=$(grep -n 'if \[ "$ABI_PROFILE" = "public1158" \]; then' "$SERVICE" | head -n1 | cut -d: -f1 || true) rehook_call=$(grep -n 'kpatch rehook' "$SERVICE" | head -n1 | cut -d: -f1 || true) [ -n "$public_guard" ] || fail "Public1158 rehook guard missing" [ -n "$rehook_call" ] || fail "Next2026 rehook call missing" @@ -26,7 +26,7 @@ rehook_call=$(grep -n 'kpatch rehook' "$SERVICE" | head -n1 | cut -d: -f1 || tru grep -Fq 'rehook request ignored: unsupported and unsafe on Public1158' "$SERVICE" || fail "Public1158 rehook rejection is not explicit" # KPM event dispatch is a reviewed Public1158 capability only. -grep -Fq 'ABI_PROFILE" != "public1158' "$SERVICE" || fail "event dispatch is not profile-gated" +grep -Fq 'if [ "$ABI_PROFILE" != "public1158" ]; then' "$SERVICE" || fail "event dispatch is not profile-gated" grep -Fq 'kpatch event "$event_name" "PatchNest" ""' "$SERVICE" || fail "Public1158 event dispatch missing" # The module may only build the separately reviewed Public1158 compatibility From d6726fe7d33400779f2cfb5abd5d9fadeed27bee Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:29:25 +0800 Subject: [PATCH 33/94] ci(runtime): enforce ABI capability boundaries --- .github/workflows/flash-safety.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index dd5372b..e8a7f14 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -8,20 +8,26 @@ on: branches: [main] paths: - 'module/patch/**' + - 'module/service.sh' + - 'version.properties' - 'tests/flash_safety_contract.sh' + - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' push: branches: - 'review/flash-readiness-hardening' paths: - 'module/patch/**' + - 'module/service.sh' + - 'version.properties' - 'tests/flash_safety_contract.sh' + - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' workflow_dispatch: jobs: contract: - name: Transactional flash contract + name: Transactional flash and ABI contract runs-on: ubuntu-24.04 steps: - name: Checkout @@ -35,11 +41,11 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh tests/flash_safety_contract.sh; do + for file in module/patch/*.sh module/service.sh tests/flash_safety_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done - - name: ShellCheck reviewed flash path + - name: ShellCheck reviewed flash/runtime path run: | shellcheck -s sh -S warning \ --exclude=SC3043,SC2034,SC2115,SC2046,SC2319,SC2155 \ @@ -48,7 +54,12 @@ jobs: module/patch/boot_unpatch.sh \ module/patch/flash_safety.sh \ module/patch/superkey_safety.sh \ - tests/flash_safety_contract.sh + module/service.sh \ + tests/flash_safety_contract.sh \ + tests/runtime_abi_contract.sh - name: Run transactional flash contract run: sh tests/flash_safety_contract.sh + + - name: Run runtime ABI contract + run: sh tests/runtime_abi_contract.sh From ea6a0dfe92cc1d5fa06d6fda66c90132d5cb807c Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:33:27 +0800 Subject: [PATCH 34/94] fix(webui): preserve KPM argv values when spawning patcher --- webui/page/patch.js | 39 ++++++--------------------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/webui/page/patch.js b/webui/page/patch.js index 4e327ce..cf28176 100644 --- a/webui/page/patch.js +++ b/webui/page/patch.js @@ -104,7 +104,6 @@ async function parseBootimg() { if (import.meta.env.DEV) { document.getElementById('kernel-info').textContent = `6.18-Linux`; document.getElementById('kernel').classList.remove('animate-hidden'); - // Still populate the KPM list so the dev UI matches prod behavior. existedExtras = []; renderKpmList(); return; @@ -133,15 +132,12 @@ async function parseBootimg() { if (ini.kernel) { kimgInfo.banner = ini.kernel.banner; kimgInfo.patched = ini.kernel.patched === 'true'; - // Extra metadata fields (may be absent on older kptools builds) kimgInfo.security_patch = ini.kernel.security_patch || ''; kimgInfo.os_version = ini.kernel.os_version || ''; - // Kernel info card document.getElementById('kernel-info').textContent = kimgInfo.banner; document.getElementById('kernel').classList.remove('animate-hidden'); - // Show security patch + OS version as a secondary detail line. const kernelDetails = document.getElementById('kernel-details'); if (kernelDetails) { const parts = []; @@ -150,7 +146,6 @@ async function parseBootimg() { kernelDetails.textContent = parts.join(' | '); } - // Boot image size: read the unpacked kernel file if we're in tmp. if (modDir) { try { const sizeResult = await exec(`wc -c < ${modDir}/tmp/kernel.ori 2>/dev/null || wc -c < ${modDir}/tmp/kernel 2>/dev/null`, { @@ -167,7 +162,6 @@ async function parseBootimg() { } if (kimgInfo.patched && ini.kpimg) { - // Parse extras existedExtras = []; let kpmNum = parseInt(ini.kernel.extra_num); if (isNaN(kpmNum) && ini.extras) { @@ -204,19 +198,14 @@ async function extractAndParseBootimg() { return; } - // Prepare work directory const prepare = spawn('sh', ['-c', `mkdir -p ${modDir}/tmp && rm -rf ${modDir}/tmp/* && cp ${modDir}/bin/kpimg ${modDir}/tmp/`]); await new Promise((resolve, reject) => { - // P1-Cluster C fix: only resolve on exit code 0; reject on any - // non-zero exit so the patch can fail fast instead of producing - // a corrupt boot image when kpimg is missing. prepare.on('exit', (code) => { if (code === 0) resolve(); else reject(new Error(`prepare failed with exit code ${code}`)); }); }); - // get slot and device const result = spawn('busybox', ['sh', `${modDir}/patch/boot_extract.sh`], { env: { PATH: `${modDir}/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH`, ASH_STANDALONE: '1' } }); @@ -242,7 +231,6 @@ async function extractAndParseBootimg() { return; } - // Bootimg info card document.getElementById('bootimg-slot').textContent = bootSlot ? getString('info_slot', bootSlot) : ''; document.getElementById('bootimg-device').textContent = bootDev ? getString('info_device', bootDev) : getString('info_device_unknown'); document.getElementById('bootimg').classList.remove('animate-hidden'); @@ -336,11 +324,6 @@ async function embedKPM() { embedBtn.disabled = true; startBtn.disabled = true; - // Generate random filename. - // P1-Cluster A fix: Math.random() is non-cryptographic and only - // gives ~30 bits of entropy in 6 base36 chars. Two near-simultaneous - // uploads could collide. crypto.randomUUID() is a strong 122-bit - // source available in all modern WebViews (Chromium 92+). const randName = (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) : Math.random().toString(36).substring(7) @@ -350,10 +333,6 @@ async function embedKPM() { try { await uploadFile(file, tmpPath, onProgress, signal); } catch (e) { - // P0-fix (ultracode-audit-2026-06-06): quote ${tmpPath} so a - // malicious filename with whitespace can't be interpreted - // as two separate args to `rm`. randName is built from - // randomUUID/crypto, but defence-in-depth: belt + braces. exec(`rm -f ${escapeShell(tmpPath)}`); throw e; } finally { @@ -376,7 +355,7 @@ async function embedKPM() { newExtras.push({ type: 'KPM', name: ini.kpm.name, - event: 'pre-kernel-init', // default + event: 'pre-kernel-init', args: '', version: ini.kpm.version, license: ini.kpm.license, @@ -404,7 +383,6 @@ function patch(type) { return; } - // Reset and show the progress card. if (progressCard) progressCard.classList.remove('animate-hidden'); resetProgress(); const progress = startProgress(type, progressContainer); @@ -425,23 +403,22 @@ function patch(type) { flashToDevice.selected ? 'true' : 'false' ); - // New kpm + // spawn() receives an argv array. Do not shell-quote values here: + // quoting would become literal KPM argument content. newExtras.forEach(extra => { args.push('-M', `${modDir}/tmp/${extra.fileName}`); - if (extra.args) args.push('-A', escapeShell(extra.args)); + if (extra.args) args.push('-A', extra.args); if (extra.event) args.push('-V', extra.event); args.push('-T', 'kpm'); }); - // Embeded kpm existedExtras.forEach(extra => { args.push('-E', extra.name); - if (extra.args) args.push('-A', escapeShell(extra.args)); + if (extra.args) args.push('-A', extra.args); if (extra.event) args.push('-V', extra.event); args.push('-T', 'kpm'); }); } else { - // Unpatch logic args.push(`${modDir}/patch/boot_unpatch.sh`, bootDev); } @@ -471,15 +448,11 @@ function patch(type) { kimgInfo = { banner: '', patched: false }; newExtras = []; } - // P1-Cluster C fix: await the cleanup so it can't race with a - // subsequent patch start. The previous fire-and-forget meant a - // second patch could begin while tmp/ was being deleted. try { await exec(`rm -rf ${modDir}/tmp`); } catch (_) { - // best-effort; if rm fails the next upload's mkdir will retry } }); } -export { getKpimgInfo, extractAndParseBootimg, getInstalledVersion, patch, embedKPM, parseIni } +export { getKpimgInfo, extractAndParseBootimg, getInstalledVersion, patch, embedKPM, parseIni } \ No newline at end of file From 8806869f247b46e6f87b5ad9eebbba721e7b8013 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:35:56 +0800 Subject: [PATCH 35/94] feat(recovery): bind rollback to device and verified flash transaction --- module/patch/transaction_safety.sh | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 module/patch/transaction_safety.sh diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh new file mode 100644 index 0000000..e95ad5e --- /dev/null +++ b/module/patch/transaction_safety.sh @@ -0,0 +1,85 @@ +#!/system/bin/sh +# Transaction identity helpers shared by patch and restore paths. +# No function in this file writes a boot target directly. + +PATCHNEST_ROLLBACK_BINDING_FILE="${PATCHNEST_ROLLBACK_BINDING_FILE:-/data/adb/patchnest/rollback_binding.json}" + +patchnest_device_binding_sha256() { + # Tests may supply a deterministic synthetic identity. Production uses the + # boot serial plus immutable-ish boot/product context, then stores only the + # digest so the raw serial never enters PatchNest manifests/logs. + if [ -n "${PATCHNEST_DEVICE_IDENTITY:-}" ]; then + _pn_identity=$PATCHNEST_DEVICE_IDENTITY + else + command -v getprop >/dev/null 2>&1 || return 1 + _pn_serial=$(getprop ro.boot.serialno 2>/dev/null | tr -d '\r\n') + [ -n "$_pn_serial" ] || _pn_serial=$(getprop ro.serialno 2>/dev/null | tr -d '\r\n') + [ -n "$_pn_serial" ] || return 1 + + _pn_product=$(getprop ro.product.device 2>/dev/null | tr -d '\r\n') + _pn_vbmeta=$(getprop ro.boot.vbmeta.digest 2>/dev/null | tr -d '\r\n') + _pn_slot=$(getprop ro.boot.slot_suffix 2>/dev/null | tr -d '\r\n') + _pn_identity="$_pn_serial|$_pn_product|$_pn_vbmeta|$_pn_slot" + fi + + _pn_target=${BOOT_TARGET:-unknown-target} + _pn_digest=$(printf '%s' "$_pn_identity|$_pn_target" | sha256sum | awk '{print $1}') + printf '%s' "$_pn_digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s\n' "$_pn_digest" +} + +patchnest_commit_rollback_binding() { + [ -n "${BOOT_TARGET:-}" ] || return 1 + [ -n "${BACKUP_CANDIDATE:-}" ] || return 1 + [ -f "$BACKUP_CANDIDATE" ] || return 1 + [ -n "${WORKDIR:-}" ] || return 1 + [ -f "$WORKDIR/new-boot.img" ] || return 1 + + _pn_backup_name=$(basename "$BACKUP_CANDIDATE") + case "$_pn_backup_name" in + boot_backup_*.img) ;; + *) return 1 ;; + esac + + _pn_backup_sha=$(sha256sum "$BACKUP_CANDIDATE" 2>/dev/null | awk '{print $1}') + _pn_patched_sha=$(sha256sum "$WORKDIR/new-boot.img" 2>/dev/null | awk '{print $1}') + _pn_device_sha=$(patchnest_device_binding_sha256) || return 1 + printf '%s' "$_pn_backup_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s' "$_pn_patched_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + + _pn_key_sha="null" + if command -v patchnest_superkey_sha256 >/dev/null 2>&1; then + _pn_key_sha=$(patchnest_superkey_sha256 2>/dev/null || printf 'null') + fi + case "$_pn_key_sha" in + null) ;; + *) printf '%s' "$_pn_key_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 ;; + esac + + _pn_when=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) + _pn_dir=${PATCHNEST_ROLLBACK_BINDING_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_ROLLBACK_BINDING_FILE}.tmp.$$" + + cat > "$_pn_tmp" < Date: Sat, 8 Aug 2026 09:36:26 +0800 Subject: [PATCH 36/94] feat(recovery): load transaction identity helpers with flash safety --- module/patch/flash_safety.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/module/patch/flash_safety.sh b/module/patch/flash_safety.sh index 169d0f0..428ac68 100644 --- a/module/patch/flash_safety.sh +++ b/module/patch/flash_safety.sh @@ -3,6 +3,14 @@ # Source this AFTER util_functions.sh. It deliberately replaces only the # high-risk helpers used by PatchNest boot patch/unpatch flows. +# Transaction binding helpers are kept separate from the low-level writer but +# are loaded here so every reviewed patch/unpatch path receives the same device +# identity and rollback semantics. +if [ -n "${MODPATH:-}" ] && [ -f "$MODPATH/transaction_safety.sh" ]; then + # shellcheck disable=SC1091 + . "$MODPATH/transaction_safety.sh" +fi + # No eval: supported Magisk/APatch config keys are assigned explicitly. getvar() { _pn_key=$1 From 685768ede7c588aca915a1a142d0ad3533a23a7a Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:36:58 +0800 Subject: [PATCH 37/94] feat(recovery): commit rollback transaction before Public1158 key --- module/patch/superkey_safety.sh | 38 ++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh index 686992f..6f04194 100644 --- a/module/patch/superkey_safety.sh +++ b/module/patch/superkey_safety.sh @@ -65,14 +65,42 @@ patchnest_superkey_sha256() { patchnest_commit_superkey() { [ -n "$PATCHNEST_SUPERKEY" ] || return 1 + + # On the destructive patch path, the rollback transaction record is part of + # credential commit. If it cannot be bound to this device/target/backup, + # report failure so boot_patch.sh restores the verified pre-write image. + _pn_binding_committed=0 + if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then + command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || return 1 + patchnest_commit_rollback_binding || return 1 + _pn_binding_committed=1 + fi + _pn_dir=${PATCHNEST_SUPERKEY_FILE%/*} - mkdir -p "$_pn_dir" || return 1 + mkdir -p "$_pn_dir" || { + [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + return 1 + } umask 077 _pn_tmp="${PATCHNEST_SUPERKEY_FILE}.tmp.$$" - printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_tmp" || return 1 - chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } - mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_FILE" || return 1 - chmod 0600 "$PATCHNEST_SUPERKEY_FILE" || return 1 + printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_tmp" || { + [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + return 1 + } + chmod 0600 "$_pn_tmp" || { + rm -f "$_pn_tmp" + [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + return 1 + } + mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_FILE" || { + rm -f "$_pn_tmp" + [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + return 1 + } + chmod 0600 "$PATCHNEST_SUPERKEY_FILE" || { + [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + return 1 + } } patchnest_store_export_key() { From 87722c261f5defc2b08ca217f5d7cf52a32e8970 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:37:38 +0800 Subject: [PATCH 38/94] feat(recovery): bind rollback to exact written byte range --- module/patch/transaction_safety.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index e95ad5e..30eaeb9 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -43,9 +43,11 @@ patchnest_commit_rollback_binding() { _pn_backup_sha=$(sha256sum "$BACKUP_CANDIDATE" 2>/dev/null | awk '{print $1}') _pn_patched_sha=$(sha256sum "$WORKDIR/new-boot.img" 2>/dev/null | awk '{print $1}') + _pn_patched_size=$(stat -c '%s' "$WORKDIR/new-boot.img" 2>/dev/null) _pn_device_sha=$(patchnest_device_binding_sha256) || return 1 printf '%s' "$_pn_backup_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 printf '%s' "$_pn_patched_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s' "$_pn_patched_size" | grep -Eq '^[1-9][0-9]*$' || return 1 _pn_key_sha="null" if command -v patchnest_superkey_sha256 >/dev/null 2>&1; then @@ -70,6 +72,7 @@ patchnest_commit_rollback_binding() { "rollback_backup": "$_pn_backup_name", "rollback_backup_sha256": "$_pn_backup_sha", "patched_image_sha256": "$_pn_patched_sha", + "patched_image_size": $_pn_patched_size, "superkey_sha256": "$_pn_key_sha", "verified_readback": true, "committed_at": "$_pn_when" From 1705264b1593bf4e6745a6260be2bebcc80fa659 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:38:10 +0800 Subject: [PATCH 39/94] fix(recovery): restore only the exact bound flash transaction --- module/patch/boot_unpatch.sh | 156 ++++++++++++++++++++++++++--------- 1 file changed, 116 insertions(+), 40 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 59fd2f5..935d74c 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -1,7 +1,10 @@ #!/system/bin/sh ####################################################################################### -# PatchNest Boot Image Unpatcher -# Derived from APatch boot_unpatch.sh, hardened for fail-closed recovery. +# PatchNest Boot Image Unpatcher / bound-backup restorer +####################################################################################### +# Usage: +# boot_unpatch.sh +# boot_unpatch.sh --restore-bound-backup ####################################################################################### MODPATH=${0%/*} @@ -14,6 +17,12 @@ AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" # shellcheck disable=SC1091 . "$MODPATH/flash_safety.sh" +RESTORE_BOUND=0 +if [ "${1:-}" = "--restore-bound-backup" ]; then + RESTORE_BOUND=1 + shift +fi + BOOTIMAGE=${1:-} [ -n "$BOOTIMAGE" ] || { >&2 echo "! BOOTIMAGE is required"; exit 1; } [ -e "$BOOTIMAGE" ] || { >&2 echo "! $BOOTIMAGE does not exist"; exit 1; } @@ -22,6 +31,10 @@ BOOT_TARGET=$(readlink -f "$BOOTIMAGE" 2>/dev/null || printf '%s' "$BOOTIMAGE") command -v magiskboot >/dev/null 2>&1 || { >&2 echo "! Command magiskboot not found"; exit 1; } command -v kptools >/dev/null 2>&1 || { >&2 echo "! Command kptools not found"; exit 1; } command -v sha256sum >/dev/null 2>&1 || { >&2 echo "! Command sha256sum not found"; exit 1; } +command -v patchnest_device_binding_sha256 >/dev/null 2>&1 || { + >&2 echo "! Transaction identity helper is unavailable" + exit 1 +} WORKDIR=$(mktemp -d /data/local/tmp/patchnest_unpatch.XXXXXX) || { >&2 echo "! Cannot create private unpatch workspace" @@ -48,63 +61,122 @@ json_bool() { | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" } -# Select only a backup that is cryptographically bound to this exact target. -# Glob expansion is lexical; timestamp-prefixed backup names therefore let the -# last valid entry replace earlier ones without parsing `ls` or using non-POSIX -nt. -select_verified_backup() { - [ -d "$BACKUP_DIR" ] || return 1 - - best_backup="" - for manifest in "$BACKUP_DIR"/boot_backup_*.json; do - [ -f "$manifest" ] || continue - [ "$(json_bool backup_verified "$manifest")" = "true" ] || continue +json_number() { + key="$1" + file="$2" + grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" +} - recorded_target=$(json_string boot_target "$manifest") - recorded_sha=$(json_string backup_sha256 "$manifest") - backup="${manifest%.json}.img" +hash_target_prefix() { + target="$1" + size="$2" + printf '%s' "$size" | grep -Eq '^[1-9][0-9]*$' || return 1 + blocks=$(((size + 1048575) / 1048576)) + digest=$(dd if="$target" bs=1048576 count="$blocks" 2>/dev/null \ + | head -c "$size" \ + | sha256sum \ + | awk '{print $1}') + printf '%s' "$digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s\n' "$digest" +} - [ -n "$recorded_target" ] || continue - [ "$recorded_target" = "$BOOT_TARGET" ] || continue - printf '%s' "$recorded_sha" | grep -Eq '^[0-9a-f]{64}$' || continue - [ -f "$backup" ] || continue +# Resolve exactly the rollback image committed by the last successful verified +# PatchNest write. No mtime/lexical/newest fallback exists here. +resolve_bound_backup() { + binding=${PATCHNEST_ROLLBACK_BINDING_FILE:-$PNDIR/rollback_binding.json} + [ -f "$binding" ] || { + >&2 echo "! No committed rollback transaction" + return 1 + } + [ "$(json_bool verified_readback "$binding")" = "true" ] || { + >&2 echo "! Rollback transaction has no verified readback" + return 2 + } - actual_sha=$(sha256sum "$backup" 2>/dev/null | awk '{print $1}') - [ "$actual_sha" = "$recorded_sha" ] || continue + recorded_target=$(json_string boot_target "$binding") + recorded_device=$(json_string device_binding_sha256 "$binding") + backup_name=$(json_string rollback_backup "$binding") + backup_sha=$(json_string rollback_backup_sha256 "$binding") + patched_sha=$(json_string patched_image_sha256 "$binding") + patched_size=$(json_number patched_image_size "$binding") - best_backup="$backup" - done + [ "$recorded_target" = "$BOOT_TARGET" ] || { + >&2 echo "! Rollback target mismatch" + return 3 + } + printf '%s' "$recorded_device" | grep -Eq '^[0-9a-f]{64}$' || return 3 + printf '%s' "$backup_sha" | grep -Eq '^[0-9a-f]{64}$' || return 3 + printf '%s' "$patched_sha" | grep -Eq '^[0-9a-f]{64}$' || return 3 + printf '%s' "$patched_size" | grep -Eq '^[1-9][0-9]*$' || return 3 - [ -n "$best_backup" ] || return 1 - printf '%s\n' "$best_backup" -} + current_device=$(patchnest_device_binding_sha256) || { + >&2 echo "! Cannot establish current device identity" + return 4 + } + [ "$current_device" = "$recorded_device" ] || { + >&2 echo "! Rollback binding belongs to another device/slot/target context" + return 4 + } -auto_unpatch() { - command -v flash_image >/dev/null 2>&1 || { - >&2 echo "! auto_unpatch: flash_image function not available" - return 2 + case "$backup_name" in + boot_backup_*.img) ;; + *) >&2 echo "! Unsafe rollback backup name"; return 5 ;; + esac + case "$backup_name" in + */*|*..*) >&2 echo "! Unsafe rollback backup path"; return 5 ;; + esac + + backup="$BACKUP_DIR/$backup_name" + [ -f "$backup" ] || { + >&2 echo "! Bound rollback backup is missing: $backup" + return 5 + } + actual_backup_sha=$(sha256sum "$backup" 2>/dev/null | awk '{print $1}') + [ "$actual_backup_sha" = "$backup_sha" ] || { + >&2 echo "! Bound rollback backup digest mismatch" + return 6 } - verified_backup=$(select_verified_backup) || { - >&2 echo "! auto_unpatch: no verified backup is bound to $BOOT_TARGET" - >&2 echo "! Legacy/newest-by-time fallback is disabled for safety" - return 3 + # Refuse stale rollback after an external flash or a later transaction. + current_prefix_sha=$(hash_target_prefix "$BOOT_TARGET" "$patched_size") || return 7 + [ "$current_prefix_sha" = "$patched_sha" ] || { + >&2 echo "! Current boot bytes no longer match the committed PatchNest transaction" + >&2 echo "! Refusing stale automatic rollback" + return 7 } - echo "- auto_unpatch: verified backup: $verified_backup" - echo "- auto_unpatch: target: $BOOT_TARGET" + printf '%s\n' "$backup" +} + +restore_bound_backup() { + verified_backup=$(resolve_bound_backup) || return $? + + echo "- restore: transaction-bound backup: $verified_backup" + echo "- restore: target: $BOOT_TARGET" flash_image "$verified_backup" "$BOOT_TARGET" rc=$? if [ "$rc" -ne 0 ]; then - >&2 echo "! auto_unpatch: verified flash failed: $rc" - return 4 + >&2 echo "! restore: verified flash failed: $rc" + return 8 fi + patchnest_remove_rollback_binding || { + >&2 echo "! restore completed but rollback binding could not be cleared" + return 9 + } echo "0" > "$PNDIR/boot_count" 2>/dev/null touch "$AUTORECOVERY_MARKER" 2>/dev/null || true - echo "- auto_unpatch: verified restore completed" + echo "- restore: transaction-bound rollback verified" return 0 } +if [ "$RESTORE_BOUND" -eq 1 ]; then + restore_bound_backup + exit $? +fi + echo "- Target image: $BOOT_TARGET" cd "$WORKDIR" || exit 1 @@ -135,7 +207,7 @@ if ! magiskboot repack "$BOOT_TARGET" >/dev/null 2>&1; then fi [ -s new-boot.img ] || { >&2 echo "! Repack produced no new-boot.img"; exit 1; } -echo "- Flashing unpatched boot image" +echo "- Flashing unpatched boot image with readback verification" flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET" rc=$? if [ "$rc" -ne 0 ]; then @@ -144,5 +216,9 @@ if [ "$rc" -ne 0 ]; then exit 1 fi +# The current target no longer corresponds to the previously committed patched +# transaction, so that rollback authorization must not remain live. +patchnest_remove_rollback_binding || true + echo "- Flash successful" exit 0 From 507c37322709e0fa88f6f7aeef60462a2af0ebdd Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:38:56 +0800 Subject: [PATCH 40/94] test(recovery): enforce device-bound exact rollback transaction --- tests/flash_safety_contract.sh | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh index aedac8d..f28a681 100644 --- a/tests/flash_safety_contract.sh +++ b/tests/flash_safety_contract.sh @@ -4,6 +4,7 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) PATCH="$ROOT/module/patch/boot_patch.sh" SAFETY="$ROOT/module/patch/flash_safety.sh" +TRANSACTION="$ROOT/module/patch/transaction_safety.sh" SUPERKEY="$ROOT/module/patch/superkey_safety.sh" UNPATCH="$ROOT/module/patch/boot_unpatch.sh" EXTRACT="$ROOT/module/patch/boot_extract.sh" @@ -37,6 +38,13 @@ for file in "$PATCH" "$UNPATCH" "$EXTRACT"; do grep -Fq 'flash_safety.sh' "$file" || fail "$(basename "$file") does not source flash_safety" done +# Recovery is transaction-bound; no "newest valid backup" selector may return. +grep -Fq -- '--restore-bound-backup' "$UNPATCH" || fail "bound-backup restore entry point missing" +grep -Fq 'rollback_binding.json' "$UNPATCH" || fail "restore does not require rollback transaction binding" +grep -Fq 'device_binding_sha256' "$UNPATCH" || fail "restore does not verify device identity" +grep -Fq 'patched_image_sha256' "$UNPATCH" || fail "restore does not verify current patched bytes" +! grep -Fq 'for manifest in' "$UNPATCH" || fail "restore still scans/selects arbitrary backup manifests" + TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT HUP INT TERM @@ -81,4 +89,38 @@ export_record=$(patchnest_store_export_key "$TMP/source.img") || fail "export ke [ -f "$export_record" ] || fail "export key record missing" [ "$(stat -c '%a' "$export_record")" = "600" ] || fail "export key record mode is not 0600" +# Commit a synthetic destructive transaction and ensure only digests/identities +# required for exact rollback are persisted. +PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/state/rollback_binding.json" +PATCHNEST_DEVICE_IDENTITY='unit-test-device-A' +export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_DEVICE_IDENTITY +# shellcheck disable=SC1090 +. "$TRANSACTION" + +BOOT_TARGET="$TMP/transaction-target.img" +BACKUP_CANDIDATE="$TMP/boot_backup_20260808T000000Z_TEST.img" +WORKDIR="$TMP/transaction-work" +FLASH_TO_DEVICE=true +export BOOT_TARGET BACKUP_CANDIDATE WORKDIR FLASH_TO_DEVICE +mkdir -p "$WORKDIR" +printf '%s\n' 'original boot bytes' > "$BACKUP_CANDIDATE" +printf '%s\n' 'patched boot bytes' > "$WORKDIR/new-boot.img" +cp "$WORKDIR/new-boot.img" "$BOOT_TARGET" + +patchnest_commit_superkey || fail "destructive transaction commit failed" +[ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "rollback transaction was not committed" +[ "$(stat -c '%a' "$PATCHNEST_ROLLBACK_BINDING_FILE")" = "600" ] || fail "rollback binding mode is not 0600" + +grep -Fq '"verified_readback": true' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "rollback binding is not readback-qualified" +grep -Fq '"rollback_backup": "boot_backup_20260808T000000Z_TEST.img"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "rollback binding does not name exact backup" +grep -Eq '"device_binding_sha256": "[0-9a-f]{64}"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "device digest missing" +grep -Eq '"patched_image_sha256": "[0-9a-f]{64}"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "patched digest missing" +grep -Eq '"patched_image_size": [1-9][0-9]*' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "patched byte range missing" + +binding_a=$(patchnest_device_binding_sha256) +PATCHNEST_DEVICE_IDENTITY='unit-test-device-B' +export PATCHNEST_DEVICE_IDENTITY +binding_b=$(patchnest_device_binding_sha256) +[ "$binding_a" != "$binding_b" ] || fail "device binding does not distinguish device identity" + echo "flash safety contract: PASS" From 3314ce8e63ea08dbb1083ee416c65374203a6c5e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:39:16 +0800 Subject: [PATCH 41/94] ci(recovery): lint transaction-bound rollback helper --- .github/workflows/flash-safety.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index e8a7f14..8ae341c 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -53,6 +53,7 @@ jobs: module/patch/boot_extract.sh \ module/patch/boot_unpatch.sh \ module/patch/flash_safety.sh \ + module/patch/transaction_safety.sh \ module/patch/superkey_safety.sh \ module/service.sh \ tests/flash_safety_contract.sh \ From 1a0fb5f2f47e363e2f2a3d890acacee511e689f7 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:41:32 +0800 Subject: [PATCH 42/94] test(device): add auditable physical flash lifecycle harness --- scripts/device_validation.sh | 281 +++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 scripts/device_validation.sh diff --git a/scripts/device_validation.sh b/scripts/device_validation.sh new file mode 100644 index 0000000..0a3cabc --- /dev/null +++ b/scripts/device_validation.sh @@ -0,0 +1,281 @@ +#!/system/bin/sh +# PatchNest physical-device validation harness. +# +# Default phases are read-only. Destructive restore/KPM-cycle phases require an +# exact unlock token and are never run automatically by the module or CI. +# +# Usage: +# sh device_validation.sh preflight +# sh device_validation.sh postboot +# sh device_validation.sh rollback-check +# PATCHNEST_DEVICE_TEST_UNLOCK=RESTORE_BOUND_BACKUP sh device_validation.sh restore +# PATCHNEST_DEVICE_TEST_UNLOCK=KPM_CYCLE sh device_validation.sh kpm-cycle /path/test.kpm + +set -u + +MODE=${1:-preflight} +shift 2>/dev/null || true +MODDIR=${PATCHNEST_MODDIR:-/data/adb/modules/PatchNest} +PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} +STAMP=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) +EVIDENCE=${PATCHNEST_EVIDENCE_DIR:-/data/local/tmp/patchnest-evidence-$STAMP} +LOG="$EVIDENCE/validation.log" +TARGET='' + +mkdir -p "$EVIDENCE" || exit 1 +chmod 0700 "$EVIDENCE" 2>/dev/null || true + +log() { + printf '%s\n' "$*" | tee -a "$LOG" +} + +fail() { + log "FAIL: $*" + exit 1 +} + +record_cmd() { + name=$1 + shift + { + printf '### %s\n' "$name" + "$@" + rc=$? + printf 'exit=%s\n\n' "$rc" + return "$rc" + } >> "$LOG" 2>&1 +} + +json_string() { + key=$1 + file=$2 + grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" +} + +json_number() { + key=$1 + file=$2 + grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" +} + +hash_prefix() { + target=$1 + size=$2 + printf '%s' "$size" | grep -Eq '^[1-9][0-9]*$' || return 1 + blocks=$(((size + 1048575) / 1048576)) + dd if="$target" bs=1048576 count="$blocks" 2>/dev/null \ + | head -c "$size" \ + | sha256sum \ + | awk '{print $1}' +} + +require_root() { + [ "$(id -u 2>/dev/null)" = "0" ] || fail "root shell required" +} + +require_module_tree() { + [ -d "$MODDIR" ] || fail "module directory missing: $MODDIR" + [ -x "$MODDIR/bin/kpatch" ] || fail "kpatch missing" + [ -x "$MODDIR/bin/kptools" ] || fail "kptools missing" + [ -x "$MODDIR/bin/magiskboot" ] || fail "magiskboot missing" +} + +resolve_target() { + out=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_extract.sh" false 2>>"$LOG") || fail "boot target resolution failed" + printf '%s\n' "$out" >> "$LOG" + TARGET=$(printf '%s\n' "$out" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) + [ -n "$TARGET" ] || fail "boot target was not emitted" + TARGET=$(readlink -f "$TARGET" 2>/dev/null || printf '%s' "$TARGET") + [ -e "$TARGET" ] || fail "resolved target does not exist: $TARGET" +} + +copy_if_present() { + src=$1 + name=$2 + [ -f "$src" ] || return 0 + cp "$src" "$EVIDENCE/$name" 2>/dev/null || true +} + +collect_common() { + log "mode=$MODE" + log "timestamp=$STAMP" + log "module_dir=$MODDIR" + log "state_dir=$PNDIR" + log "boot_target=$TARGET" + log "boot_slot=$(getprop ro.boot.slot_suffix 2>/dev/null)" + log "product_device=$(getprop ro.product.device 2>/dev/null)" + log "boot_completed=$(getprop sys.boot_completed 2>/dev/null)" + log "vbmeta_device_state=$(getprop ro.boot.vbmeta.device_state 2>/dev/null)" + + if [ -f "$MODDIR/module.prop" ]; then + cp "$MODDIR/module.prop" "$EVIDENCE/module.prop" + fi + copy_if_present "$MODDIR/provenance/kpatch-public1158.json" "kpatch-public1158.json" + copy_if_present "$PNDIR/last_flash.json" "last_flash.json" + copy_if_present "$PNDIR/rollback_binding.json" "rollback_binding.json" + copy_if_present "$PNDIR/abi_profile" "abi_profile" + copy_if_present "$PNDIR/service.log" "service.log" + + if [ -f "$PNDIR/superkey" ]; then + key_mode=$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null || printf unknown) + key_sha=$(sha256sum "$PNDIR/superkey" 2>/dev/null | awk '{print $1}') + log "superkey_present=1" + log "superkey_mode=$key_mode" + log "superkey_file_sha256=$key_sha" + else + log "superkey_present=0" + fi + + if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then + log "flash_review_blocked=1" + else + log "flash_review_blocked=0" + fi +} + +validate_target_unpack() { + tmp=$(mktemp -d /data/local/tmp/patchnest-device-unpack.XXXXXX) || fail "cannot create unpack workspace" + if ! (cd "$tmp" && "$MODDIR/bin/magiskboot" unpack "$TARGET" >/dev/null 2>&1); then + rm -rf "$tmp" + fail "magiskboot cannot unpack resolved target" + fi + [ -s "$tmp/kernel" ] || { + rm -rf "$tmp" + fail "resolved target unpack produced no kernel" + } + PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$tmp/kernel" -l > "$EVIDENCE/kernel-info.txt" 2>&1 || true + rm -rf "$tmp" +} + +validate_binding_read_only() { + binding="$PNDIR/rollback_binding.json" + [ -f "$binding" ] || fail "rollback binding is missing" + + recorded_target=$(json_string boot_target "$binding") + recorded_device=$(json_string device_binding_sha256 "$binding") + backup_name=$(json_string rollback_backup "$binding") + backup_sha=$(json_string rollback_backup_sha256 "$binding") + patched_sha=$(json_string patched_image_sha256 "$binding") + patched_size=$(json_number patched_image_size "$binding") + + [ "$recorded_target" = "$TARGET" ] || fail "binding target mismatch" + printf '%s' "$recorded_device" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid device binding digest" + printf '%s' "$backup_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid rollback digest" + printf '%s' "$patched_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid patched digest" + printf '%s' "$patched_size" | grep -Eq '^[1-9][0-9]*$' || fail "invalid patched size" + + # shellcheck disable=SC1091 + . "$MODDIR/patch/util_functions.sh" + # shellcheck disable=SC1091 + . "$MODDIR/patch/flash_safety.sh" + current_device=$(patchnest_device_binding_sha256) || fail "cannot derive device binding" + [ "$current_device" = "$recorded_device" ] || fail "device binding mismatch" + + case "$backup_name" in + boot_backup_*.img) ;; + *) fail "unsafe backup name in rollback binding" ;; + esac + case "$backup_name" in + */*|*..*) fail "unsafe backup path in rollback binding" ;; + esac + backup="$PNDIR/backup/$backup_name" + [ -f "$backup" ] || fail "rollback backup missing" + [ "$(sha256sum "$backup" | awk '{print $1}')" = "$backup_sha" ] || fail "rollback backup SHA mismatch" + + current_sha=$(hash_prefix "$TARGET" "$patched_size") || fail "cannot hash current patched byte range" + [ "$current_sha" = "$patched_sha" ] || fail "current boot no longer matches committed patched transaction" + log "rollback_binding_eligible=1" + log "rollback_backup=$backup_name" +} + +finalize() { + bundle="/storage/emulated/0/Download/PatchNest_Device_Evidence_${STAMP}_${MODE}.tar.gz" + if [ -d /storage/emulated/0/Download ] && command -v tar >/dev/null 2>&1; then + tar -czf "$bundle" -C "${EVIDENCE%/*}" "${EVIDENCE##*/}" 2>/dev/null \ + && log "evidence_bundle=$bundle" \ + || log "evidence_bundle_failed=1" + fi + log "evidence_dir=$EVIDENCE" +} + +require_root +require_module_tree +resolve_target +collect_common + +case "$MODE" in + preflight) + validate_target_unpack + record_cmd "kpatch file digest" sha256sum "$MODDIR/bin/kpatch" || true + record_cmd "kptools file digest" sha256sum "$MODDIR/bin/kptools" || true + record_cmd "kpimg file digest" sha256sum "$MODDIR/bin/kpimg" || true + if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then + log "result=REVIEW_PACKAGE_INTENTIONALLY_BLOCKED" + else + log "result=PREFLIGHT_PASS" + fi + ;; + + postboot) + [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android boot_completed is not 1" + hello=$(PATH="$MODDIR/bin:$PATH" kpatch hello 2>>"$LOG") || fail "kpatch hello failed" + [ "$hello" = "hello1158" ] || fail "unexpected ABI hello: $hello" + log "hello=$hello" + record_cmd "kpver" env PATH="$MODDIR/bin:$PATH" kpatch kpver || fail "kpver failed" + record_cmd "kpm num" env PATH="$MODDIR/bin:$PATH" kpatch kpm num || fail "kpm num failed" + record_cmd "kpm list" env PATH="$MODDIR/bin:$PATH" kpatch kpm list || fail "kpm list failed" + [ -f "$PNDIR/superkey" ] || fail "superkey was not committed" + [ "$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null)" = "600" ] || fail "superkey permissions are not 0600" + validate_binding_read_only + [ ! -f "$MODDIR/unresolved" ] || fail "module runtime marked unresolved" + log "result=POSTBOOT_PASS" + ;; + + rollback-check) + validate_binding_read_only + log "result=ROLLBACK_ELIGIBLE" + ;; + + restore) + [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "RESTORE_BOUND_BACKUP" ] \ + || fail "restore requires PATCHNEST_DEVICE_TEST_UNLOCK=RESTORE_BOUND_BACKUP" + validate_binding_read_only + cp "$PNDIR/rollback_binding.json" "$EVIDENCE/rollback_binding.before-restore.json" + log "destructive_restore=START" + PATH="$MODDIR/bin:$PATH" "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$TARGET" \ + >> "$LOG" 2>&1 || fail "bound-backup restore failed" + log "destructive_restore=PASS" + log "result=RESTORE_WRITE_VERIFIED_REBOOT_REQUIRED" + ;; + + kpm-cycle) + candidate=${1:-} + [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "KPM_CYCLE" ] \ + || fail "KPM cycle requires PATCHNEST_DEVICE_TEST_UNLOCK=KPM_CYCLE" + [ -f "$candidate" ] || fail "KPM candidate missing: $candidate" + meta=$(PATH="$MODDIR/bin:$PATH" kptools -l -M "$candidate" 2>>"$LOG") || fail "candidate metadata validation failed" + name=$(printf '%s\n' "$meta" | sed -n 's/^name=//p' | head -n 1) + [ -n "$name" ] || fail "candidate KPM has no name" + log "kpm_candidate=$candidate" + log "kpm_name=$name" + PATH="$MODDIR/bin:$PATH" kpatch kpm load "$candidate" >> "$LOG" 2>&1 || fail "KPM load failed" + PATH="$MODDIR/bin:$PATH" kpatch kpm info "$name" >> "$LOG" 2>&1 || { + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$name" >> "$LOG" 2>&1 || true + fail "KPM info failed after load" + } + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$name" >> "$LOG" 2>&1 || fail "KPM unload failed" + log "result=KPM_CYCLE_PASS" + ;; + + *) + fail "unknown mode: $MODE" + ;; +esac + +finalize +exit 0 From 75e7a616780f2dc56df7c8f1cfabf46c0f8e6516 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:42:18 +0800 Subject: [PATCH 43/94] ci(device): lint physical validation evidence harness --- .github/workflows/flash-safety.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 8ae341c..49e7eac 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -9,6 +9,7 @@ on: paths: - 'module/patch/**' - 'module/service.sh' + - 'scripts/device_validation.sh' - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -19,6 +20,7 @@ on: paths: - 'module/patch/**' - 'module/service.sh' + - 'scripts/device_validation.sh' - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -41,7 +43,7 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh tests/flash_safety_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh scripts/device_validation.sh tests/flash_safety_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -56,6 +58,7 @@ jobs: module/patch/transaction_safety.sh \ module/patch/superkey_safety.sh \ module/service.sh \ + scripts/device_validation.sh \ tests/flash_safety_contract.sh \ tests/runtime_abi_contract.sh From 1d7d457f0472cc15f2e2477e1091489cac8bfe40 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:43:02 +0800 Subject: [PATCH 44/94] fix(device): bind evidence checks to installed patch context --- scripts/device_validation.sh | 203 ++++++++++++++++++----------------- 1 file changed, 106 insertions(+), 97 deletions(-) diff --git a/scripts/device_validation.sh b/scripts/device_validation.sh index 0a3cabc..523625c 100644 --- a/scripts/device_validation.sh +++ b/scripts/device_validation.sh @@ -1,20 +1,11 @@ #!/system/bin/sh # PatchNest physical-device validation harness. -# -# Default phases are read-only. Destructive restore/KPM-cycle phases require an -# exact unlock token and are never run automatically by the module or CI. -# -# Usage: -# sh device_validation.sh preflight -# sh device_validation.sh postboot -# sh device_validation.sh rollback-check -# PATCHNEST_DEVICE_TEST_UNLOCK=RESTORE_BOUND_BACKUP sh device_validation.sh restore -# PATCHNEST_DEVICE_TEST_UNLOCK=KPM_CYCLE sh device_validation.sh kpm-cycle /path/test.kpm +# Read-only by default. Destructive modes require exact unlock tokens. set -u MODE=${1:-preflight} -shift 2>/dev/null || true +[ "$#" -gt 0 ] && shift MODDIR=${PATCHNEST_MODDIR:-/data/adb/modules/PatchNest} PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} STAMP=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) @@ -35,40 +26,40 @@ fail() { } record_cmd() { - name=$1 + _pn_name=$1 shift { - printf '### %s\n' "$name" + printf '### %s\n' "$_pn_name" "$@" - rc=$? - printf 'exit=%s\n\n' "$rc" - return "$rc" + _pn_rc=$? + printf 'exit=%s\n\n' "$_pn_rc" + return "$_pn_rc" } >> "$LOG" 2>&1 } json_string() { - key=$1 - file=$2 - grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null \ + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$_pn_file" 2>/dev/null \ | head -n 1 \ - | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" } json_number() { - key=$1 - file=$2 - grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$file" 2>/dev/null \ + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$_pn_file" 2>/dev/null \ | head -n 1 \ - | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" } hash_prefix() { - target=$1 - size=$2 - printf '%s' "$size" | grep -Eq '^[1-9][0-9]*$' || return 1 - blocks=$(((size + 1048575) / 1048576)) - dd if="$target" bs=1048576 count="$blocks" 2>/dev/null \ - | head -c "$size" \ + _pn_target=$1 + _pn_size=$2 + printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + _pn_blocks=$(((_pn_size + 1048575) / 1048576)) + dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ + | head -c "$_pn_size" \ | sha256sum \ | awk '{print $1}' } @@ -82,23 +73,24 @@ require_module_tree() { [ -x "$MODDIR/bin/kpatch" ] || fail "kpatch missing" [ -x "$MODDIR/bin/kptools" ] || fail "kptools missing" [ -x "$MODDIR/bin/magiskboot" ] || fail "magiskboot missing" + [ -f "$MODDIR/patch/transaction_safety.sh" ] || fail "transaction helper missing" } resolve_target() { - out=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + _pn_out=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ "$MODDIR/patch/boot_extract.sh" false 2>>"$LOG") || fail "boot target resolution failed" - printf '%s\n' "$out" >> "$LOG" - TARGET=$(printf '%s\n' "$out" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) + printf '%s\n' "$_pn_out" >> "$LOG" + TARGET=$(printf '%s\n' "$_pn_out" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) [ -n "$TARGET" ] || fail "boot target was not emitted" TARGET=$(readlink -f "$TARGET" 2>/dev/null || printf '%s' "$TARGET") [ -e "$TARGET" ] || fail "resolved target does not exist: $TARGET" } copy_if_present() { - src=$1 - name=$2 - [ -f "$src" ] || return 0 - cp "$src" "$EVIDENCE/$name" 2>/dev/null || true + _pn_src=$1 + _pn_name=$2 + [ -f "$_pn_src" ] || return 0 + cp "$_pn_src" "$EVIDENCE/$_pn_name" 2>/dev/null || true } collect_common() { @@ -112,9 +104,7 @@ collect_common() { log "boot_completed=$(getprop sys.boot_completed 2>/dev/null)" log "vbmeta_device_state=$(getprop ro.boot.vbmeta.device_state 2>/dev/null)" - if [ -f "$MODDIR/module.prop" ]; then - cp "$MODDIR/module.prop" "$EVIDENCE/module.prop" - fi + copy_if_present "$MODDIR/module.prop" "module.prop" copy_if_present "$MODDIR/provenance/kpatch-public1158.json" "kpatch-public1158.json" copy_if_present "$PNDIR/last_flash.json" "last_flash.json" copy_if_present "$PNDIR/rollback_binding.json" "rollback_binding.json" @@ -122,11 +112,9 @@ collect_common() { copy_if_present "$PNDIR/service.log" "service.log" if [ -f "$PNDIR/superkey" ]; then - key_mode=$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null || printf unknown) - key_sha=$(sha256sum "$PNDIR/superkey" 2>/dev/null | awk '{print $1}') log "superkey_present=1" - log "superkey_mode=$key_mode" - log "superkey_file_sha256=$key_sha" + log "superkey_mode=$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null || printf unknown)" + log "superkey_file_sha256=$(sha256sum "$PNDIR/superkey" 2>/dev/null | awk '{print $1}')" else log "superkey_present=0" fi @@ -139,65 +127,83 @@ collect_common() { } validate_target_unpack() { - tmp=$(mktemp -d /data/local/tmp/patchnest-device-unpack.XXXXXX) || fail "cannot create unpack workspace" - if ! (cd "$tmp" && "$MODDIR/bin/magiskboot" unpack "$TARGET" >/dev/null 2>&1); then - rm -rf "$tmp" + _pn_tmp=$(mktemp -d /data/local/tmp/patchnest-device-unpack.XXXXXX) || fail "cannot create unpack workspace" + if ! (cd "$_pn_tmp" && "$MODDIR/bin/magiskboot" unpack "$TARGET" >/dev/null 2>&1); then + rm -rf "$_pn_tmp" fail "magiskboot cannot unpack resolved target" fi - [ -s "$tmp/kernel" ] || { - rm -rf "$tmp" + [ -s "$_pn_tmp/kernel" ] || { + rm -rf "$_pn_tmp" fail "resolved target unpack produced no kernel" } - PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$tmp/kernel" -l > "$EVIDENCE/kernel-info.txt" 2>&1 || true - rm -rf "$tmp" + PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$_pn_tmp/kernel" -l \ + > "$EVIDENCE/kernel-info.txt" 2>&1 || true + rm -rf "$_pn_tmp" } -validate_binding_read_only() { - binding="$PNDIR/rollback_binding.json" - [ -f "$binding" ] || fail "rollback binding is missing" - - recorded_target=$(json_string boot_target "$binding") - recorded_device=$(json_string device_binding_sha256 "$binding") - backup_name=$(json_string rollback_backup "$binding") - backup_sha=$(json_string rollback_backup_sha256 "$binding") - patched_sha=$(json_string patched_image_sha256 "$binding") - patched_size=$(json_number patched_image_size "$binding") - - [ "$recorded_target" = "$TARGET" ] || fail "binding target mismatch" - printf '%s' "$recorded_device" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid device binding digest" - printf '%s' "$backup_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid rollback digest" - printf '%s' "$patched_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid patched digest" - printf '%s' "$patched_size" | grep -Eq '^[1-9][0-9]*$' || fail "invalid patched size" - - # shellcheck disable=SC1091 +load_transaction_context() { + # The installed helpers intentionally key device identity to BOOT_TARGET and + # discover transaction_safety.sh through MODPATH. Map validation state onto + # those exact production variable names before sourcing the reviewed code. + MODPATH="$MODDIR/patch" + BOOT_TARGET="$TARGET" + export MODPATH BOOT_TARGET + # shellcheck disable=SC1090 . "$MODDIR/patch/util_functions.sh" - # shellcheck disable=SC1091 + # shellcheck disable=SC1090 . "$MODDIR/patch/flash_safety.sh" - current_device=$(patchnest_device_binding_sha256) || fail "cannot derive device binding" - [ "$current_device" = "$recorded_device" ] || fail "device binding mismatch" + command -v patchnest_device_binding_sha256 >/dev/null 2>&1 \ + || fail "transaction identity helper was not loaded" +} - case "$backup_name" in +validate_binding_read_only() { + _pn_binding="$PNDIR/rollback_binding.json" + [ -f "$_pn_binding" ] || fail "rollback binding is missing" + + _pn_recorded_target=$(json_string boot_target "$_pn_binding") + _pn_recorded_device=$(json_string device_binding_sha256 "$_pn_binding") + _pn_backup_name=$(json_string rollback_backup "$_pn_binding") + _pn_backup_sha=$(json_string rollback_backup_sha256 "$_pn_binding") + _pn_patched_sha=$(json_string patched_image_sha256 "$_pn_binding") + _pn_patched_size=$(json_number patched_image_size "$_pn_binding") + + [ "$_pn_recorded_target" = "$TARGET" ] || fail "binding target mismatch" + printf '%s' "$_pn_recorded_device" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid device binding digest" + printf '%s' "$_pn_backup_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid rollback digest" + printf '%s' "$_pn_patched_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid patched digest" + printf '%s' "$_pn_patched_size" | grep -Eq '^[1-9][0-9]*$' || fail "invalid patched size" + + load_transaction_context + _pn_current_device=$(patchnest_device_binding_sha256) || fail "cannot derive device binding" + [ "$_pn_current_device" = "$_pn_recorded_device" ] || fail "device binding mismatch" + + case "$_pn_backup_name" in boot_backup_*.img) ;; *) fail "unsafe backup name in rollback binding" ;; esac - case "$backup_name" in + case "$_pn_backup_name" in */*|*..*) fail "unsafe backup path in rollback binding" ;; esac - backup="$PNDIR/backup/$backup_name" - [ -f "$backup" ] || fail "rollback backup missing" - [ "$(sha256sum "$backup" | awk '{print $1}')" = "$backup_sha" ] || fail "rollback backup SHA mismatch" - current_sha=$(hash_prefix "$TARGET" "$patched_size") || fail "cannot hash current patched byte range" - [ "$current_sha" = "$patched_sha" ] || fail "current boot no longer matches committed patched transaction" + _pn_backup="$PNDIR/backup/$_pn_backup_name" + [ -f "$_pn_backup" ] || fail "rollback backup missing" + [ "$(sha256sum "$_pn_backup" | awk '{print $1}')" = "$_pn_backup_sha" ] \ + || fail "rollback backup SHA mismatch" + + _pn_current_sha=$(hash_prefix "$TARGET" "$_pn_patched_size") \ + || fail "cannot hash current patched byte range" + [ "$_pn_current_sha" = "$_pn_patched_sha" ] \ + || fail "current boot no longer matches committed patched transaction" + log "rollback_binding_eligible=1" - log "rollback_backup=$backup_name" + log "rollback_backup=$_pn_backup_name" } finalize() { - bundle="/storage/emulated/0/Download/PatchNest_Device_Evidence_${STAMP}_${MODE}.tar.gz" + _pn_bundle="/storage/emulated/0/Download/PatchNest_Device_Evidence_${STAMP}_${MODE}.tar.gz" if [ -d /storage/emulated/0/Download ] && command -v tar >/dev/null 2>&1; then - tar -czf "$bundle" -C "${EVIDENCE%/*}" "${EVIDENCE##*/}" 2>/dev/null \ - && log "evidence_bundle=$bundle" \ + tar -czf "$_pn_bundle" -C "${EVIDENCE%/*}" "${EVIDENCE##*/}" 2>/dev/null \ + && log "evidence_bundle=$_pn_bundle" \ || log "evidence_bundle_failed=1" fi log "evidence_dir=$EVIDENCE" @@ -223,9 +229,9 @@ case "$MODE" in postboot) [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android boot_completed is not 1" - hello=$(PATH="$MODDIR/bin:$PATH" kpatch hello 2>>"$LOG") || fail "kpatch hello failed" - [ "$hello" = "hello1158" ] || fail "unexpected ABI hello: $hello" - log "hello=$hello" + _pn_hello=$(PATH="$MODDIR/bin:$PATH" kpatch hello 2>>"$LOG") || fail "kpatch hello failed" + [ "$_pn_hello" = "hello1158" ] || fail "unexpected ABI hello: $_pn_hello" + log "hello=$_pn_hello" record_cmd "kpver" env PATH="$MODDIR/bin:$PATH" kpatch kpver || fail "kpver failed" record_cmd "kpm num" env PATH="$MODDIR/bin:$PATH" kpatch kpm num || fail "kpm num failed" record_cmd "kpm list" env PATH="$MODDIR/bin:$PATH" kpatch kpm list || fail "kpm list failed" @@ -254,21 +260,24 @@ case "$MODE" in ;; kpm-cycle) - candidate=${1:-} + _pn_candidate=${1:-} [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "KPM_CYCLE" ] \ || fail "KPM cycle requires PATCHNEST_DEVICE_TEST_UNLOCK=KPM_CYCLE" - [ -f "$candidate" ] || fail "KPM candidate missing: $candidate" - meta=$(PATH="$MODDIR/bin:$PATH" kptools -l -M "$candidate" 2>>"$LOG") || fail "candidate metadata validation failed" - name=$(printf '%s\n' "$meta" | sed -n 's/^name=//p' | head -n 1) - [ -n "$name" ] || fail "candidate KPM has no name" - log "kpm_candidate=$candidate" - log "kpm_name=$name" - PATH="$MODDIR/bin:$PATH" kpatch kpm load "$candidate" >> "$LOG" 2>&1 || fail "KPM load failed" - PATH="$MODDIR/bin:$PATH" kpatch kpm info "$name" >> "$LOG" 2>&1 || { - PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$name" >> "$LOG" 2>&1 || true + [ -f "$_pn_candidate" ] || fail "KPM candidate missing: $_pn_candidate" + _pn_meta=$(PATH="$MODDIR/bin:$PATH" kptools -l -M "$_pn_candidate" 2>>"$LOG") \ + || fail "candidate metadata validation failed" + _pn_name=$(printf '%s\n' "$_pn_meta" | sed -n 's/^name=//p' | head -n 1) + [ -n "$_pn_name" ] || fail "candidate KPM has no name" + log "kpm_candidate=$_pn_candidate" + log "kpm_name=$_pn_name" + PATH="$MODDIR/bin:$PATH" kpatch kpm load "$_pn_candidate" >> "$LOG" 2>&1 \ + || fail "KPM load failed" + PATH="$MODDIR/bin:$PATH" kpatch kpm info "$_pn_name" >> "$LOG" 2>&1 || { + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || true fail "KPM info failed after load" } - PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$name" >> "$LOG" 2>&1 || fail "KPM unload failed" + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 \ + || fail "KPM unload failed" log "result=KPM_CYCLE_PASS" ;; From 5e43d83dd0ef1d758f82dde869b2c7e9d2e9aa5e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:43:46 +0800 Subject: [PATCH 45/94] fix(recovery): confine synthetic device identity to tests --- module/patch/transaction_safety.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index 30eaeb9..dc77ed0 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -5,10 +5,9 @@ PATCHNEST_ROLLBACK_BINDING_FILE="${PATCHNEST_ROLLBACK_BINDING_FILE:-/data/adb/patchnest/rollback_binding.json}" patchnest_device_binding_sha256() { - # Tests may supply a deterministic synthetic identity. Production uses the - # boot serial plus immutable-ish boot/product context, then stores only the - # digest so the raw serial never enters PatchNest manifests/logs. - if [ -n "${PATCHNEST_DEVICE_IDENTITY:-}" ]; then + # Synthetic identity is an offline-test hook only. Production always uses + # the real boot serial/context and stores only the digest, never the serial. + if [ "${PATCHNEST_TRANSACTION_TEST:-0}" = "1" ] && [ -n "${PATCHNEST_DEVICE_IDENTITY:-}" ]; then _pn_identity=$PATCHNEST_DEVICE_IDENTITY else command -v getprop >/dev/null 2>&1 || return 1 From 63d94f480cddf398e76cfa2ca6826e7ce3e455c4 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:44:23 +0800 Subject: [PATCH 46/94] ci(recovery): scope synthetic device identity to test step --- .github/workflows/flash-safety.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 49e7eac..478733e 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -63,6 +63,8 @@ jobs: tests/runtime_abi_contract.sh - name: Run transactional flash contract + env: + PATCHNEST_TRANSACTION_TEST: '1' run: sh tests/flash_safety_contract.sh - name: Run runtime ABI contract From 928f44c7e129ebe7d9bbae0b77474d74409359a0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:48:03 +0800 Subject: [PATCH 47/94] docs(review): close static gates and isolate physical validation --- FLASH_READINESS.md | 203 +++++++++++++++++++++++++++++++-------------- 1 file changed, 141 insertions(+), 62 deletions(-) diff --git a/FLASH_READINESS.md b/FLASH_READINESS.md index 158178e..d71f5a0 100644 --- a/FLASH_READINESS.md +++ b/FLASH_READINESS.md @@ -1,115 +1,194 @@ # PatchNest flash-readiness review -Status: **NOT FLASH READY** +Status: **NOT FLASH READY — STATIC/CODE GATES CLOSED, PHYSICAL DEVICE GATE OPEN** -This document is the live release gate for `review/flash-readiness-hardening`. A green source/package CI run is necessary but is not sufficient to remove this gate. +This document is the live release gate for `review/flash-readiness-hardening`. +`module/FLASH_REVIEW_BLOCKED` intentionally keeps the review branch non-installable. +Green CI is necessary but is **not** sufficient evidence for a boot-image modifying +release. -## P0 blockers +Reviewed code/package baseline before this documentation commit: -### FR-001 — Packaged kernel and CLI belong to different supercall ABI families +`63d94f480cddf398e76cfa2ca6826e7ce3e455c4` -Current `version.properties` combines: +Verified at that baseline: -- `Zhanfg/KernelPatch-Public` `0.13.3` (`hello1158`, magic `0x11581158`, key/su authentication, KPM event/safemode extensions); -- `Zhanfg/PatchNest` `0.13.5-2`, reproduced from the KPatch-Next userspace snapshot (`hello2026`, magic `0x20262026`, NULL-key userspace call convention, no matching event command). +- `Build` run `31233359168`: **PASS** + - source/release metadata validation; + - shell syntax + ShellCheck; + - WebUI tests + production build; + - pinned external dependency SHA-256 validation; + - exact-source Public1158 userspace rebuild; + - ARM64 ABI/profile inspection; + - complete module validation; + - deterministic double-package byte comparison; + - verified artifact upload. +- `Flash safety` run `31233359159`: **PASS** + - transactional writer tests; + - superkey transaction tests; + - device/transaction-bound rollback tests; + - runtime ABI capability contract; + - physical validation harness syntax/ShellCheck. -KernelPatch-Public masks the packed command to the low 16 bits, so the high token alone is not the blocker. The handshake magic, authentication convention, and extension command surface are different. Package-level compatibility has not been demonstrated. +## Resolved static findings -**Gate:** one reviewed ABI profile must own `kpimg`, `kptools`, `kpatch`, safemode and lifecycle-event behavior. Mixed profiles are forbidden unless an explicit compatibility layer is implemented and tested. +### FR-001 — mixed userspace/kernel ABI family — **RESOLVED** -### FR-002 — Shared patch working directory can reuse stale images +The package no longer combines Public1158 kernel components with the historical +Next2026 `kpatch-android` release binary. -`boot_patch.sh` / `boot_unpatch.sh` currently use shared names such as `kernel`, `kernel.ori`, `ori.img` and `new-boot.img`, and may skip unpacking when `kernel` already exists. +`version.properties` pins reviewed PatchNest source commit: -**Gate:** each patch/unpatch operation must use a fresh private `mktemp -d` workspace, unpack the requested boot image unconditionally, and remove the workspace with a trap. +`7fed93c4e259a6edf191c1a9900874babb232c4b` -### FR-003 — Recovery selection is not bound to the flash target +and the module build independently compiles `kpatch-public1158` for Android ARM64. +The package provenance records the source commit and resulting binary SHA-256. -`auto_unpatch()` currently chooses the newest backup by mtime. Older manifests are optional and `backup_verified=false` is not a hard rejection. Slot/partition/device identity is not bound to the selected backup. +The compatibility target is explicit rather than runtime mutation probing: -**Gate:** automatic restore may only select a backup whose manifest is present, `backup_verified=true`, digest-valid, and bound to the exact target partition/slot/device identity. Otherwise it must refuse to flash. +- Public1158 token: `0x1158`; +- hello: `0x11581158` / `hello1158`; +- superkey-authenticated sensitive operations; +- KPM / kstorage exclusion supported; +- Public KPM event command `0x1150` supported through an event allowlist; +- rehook forbidden because Public `0x1100/0x1101` mean SU grant/revoke, while + Next2026 uses those IDs for rehook operations. -### FR-004 — Destructive flash has no mandatory readback verification +`service.sh` accepts only exact `hello1158` / `hello2026` responses and derives +runtime capability from the resulting profile. Unknown successful hello output is +rejected. -A successful write syscall/pipeline is currently treated as a successful boot flash. +### FR-002 — shared patch workspace / stale image reuse — **RESOLVED** -**Gate:** block-device flash must hash the exact bytes being written, write and sync them, read back the same byte range, and compare the digest. Targets for which reliable readback is unavailable require a separately validated device-specific strategy and are excluded from the general release baseline. +Patch and unpatch use operation-private `mktemp -d` workspaces, freshly unpack the +requested boot target and clean the workspace with traps. The historical +"reuse kernel if present" path is rejected by CI. -## P1 blockers +### FR-003 — rollback not bound to exact target/device/transaction — **RESOLVED IN CODE** -### FR-005 — Root shell `eval` remains in `getvar()` +A destructive write creates a unique validated rollback backup. Automatic restore no +longer scans or chooses a "latest" backup. -The fallback `eval "$VARNAME=\$VALUE"` still evaluates attacker-controlled VALUE content under Android `/system/bin/sh`. Allow-listing only VARNAME does not make VALUE safe. +The committed rollback transaction binds: -**Gate:** replace with explicit case assignments; no `eval` in root-owned config parsing. +- canonical boot target; +- SHA-256 of device identity derived from boot serial + product/vbmeta/slot context; +- one exact rollback backup filename and SHA-256; +- exact patched-image SHA-256 and written byte length; +- superkey digest; +- verified-readback state. -### FR-006 — Boot partition discovery can guess the wrong target +Only the device-identity digest is persisted; the raw serial is not stored. +Synthetic device identity is accepted only when `PATCHNEST_TRANSACTION_TEST=1` for +CI and cannot replace production identity resolution. -When the active slot is unresolved, the current fallback searches `boot_a` / `boot_b` and can also fall back to `vendor_boot` / `init_boot` as though they were interchangeable with boot. +`boot_unpatch.sh --restore-bound-backup` verifies the device/slot/target context, +backup digest and the current patched byte range before any restore write. External +reflash, device mismatch or stale transaction state therefore fails closed. -**Gate:** A/B devices require a resolved active slot. `vendor_boot` / `init_boot` require explicit separate support; they are never generic boot fallbacks. +### FR-004 — destructive flash without mandatory readback — **RESOLVED** -### FR-007 — Backup identity/hash logic does not cover normal block-device targets +The reviewed writer performs payload normalization, capacity/read-only checks, +write + fsync/sync, then SHA-256 readback over the exact written byte range. +Digest mismatch is a hard failure. -Several checks use `[ -f "$BOOT_FILE" ]`, while a real boot target is normally a block device. `original_sha256` can therefore remain null and external-change detection becomes ineffective. +### FR-005 — root-shell `eval` fallback — **RESOLVED** -**Gate:** capture/hash the actual boot bytes used to create the backup, independent of whether the source path is a regular file or block device. +Reviewed patch/unpatch/extract paths source `flash_safety.sh`, whose config assignment +uses an explicit allowlisted switch. No root-shell `eval` is used by this path. -### FR-008 — Backup names can collide within one minute +### FR-006 — ambiguous A/B boot target / vendor_boot guessing — **RESOLVED** -Backup names currently have minute precision and can overwrite a previous good backup. +The resolver accepts only `_a` / `_b` slot suffixes, refuses A/B devices when the +active slot cannot be established, resolves the matching boot partition only and +does not treat `vendor_boot` / `init_boot` as generic boot substitutes. -**Gate:** no-clobber unique name (seconds + PID/random or `mktemp`) followed by atomic manifest/image promotion. +### FR-007 — block-device backup/hash gap — **RESOLVED** -### FR-009 — Embedded-KPM validation can fail open +Backup capture hashes the actual boot target and captured bytes regardless of whether +the source is a regular file or block device. Target and backup digests must match +before patching. Rollback also verifies the currently written patched byte range. -If `kptools -l -M` cannot verify an embedded KPM, patching currently proceeds with a warning. +### FR-008 — backup name collision — **RESOLVED** -**Gate:** release path fails closed. Any development override must be explicit, off by default, and visibly mark the output non-release. +Backup names use UTC second precision plus `mktemp` uniqueness. Rollback selection is +transaction-bound, not filename-order based. -### FR-010 — KPM load argument contract was wrong +### FR-009 — embedded KPM validation fail-open — **RESOLVED** -Module scripts called `kpatch kpm load PATH -- ARGS`, while the current C CLI accepts `PATH [ARGS]`; literal `--` became the KPM argument and the intended string was ignored. +Each embedded `-M` candidate must be an absolute existing file, contain ELF magic, +be AArch64, pass `kptools -l -M` and expose a non-empty module name. Failure aborts +before boot-image mutation. -**Status:** fixed on this review branch in `service.sh` and `install_kpm.sh`. Rust parser foundation supports both call shapes for migration compatibility. +### FR-010 — KPM argument contract mismatch — **RESOLVED** -### FR-011 — Lifecycle event dispatch is not implemented by the packaged CLI +Runtime uses `kpatch kpm load PATH [ARGS]`; a literal `--` is no longer passed as KPM +argument data. -`service.sh` called `kpatch event ...` even though the KPatch-Next-derived PatchNest CLI has no `event` command. Failures were hidden. +WebUI also passes `-A` data directly through `spawn()` argv instead of shell-quoting +values and accidentally embedding quote/backslash characters. Shell escaping remains +only on actual shell command strings. -**Status:** review branch now records the capability as unavailable instead of silently claiming dispatch success. Final behavior depends on FR-001 ABI unification. +### FR-011 — lifecycle event path mismatched packaged ABI — **RESOLVED** -### FR-012 — `kpatch hello` previously returned process success on handshake failure +The Public1158 compatibility CLI implements reviewed KPM event command `0x1150` with +an exact event allowlist. `service.sh` dispatches `POST_FS_DATA` and dispatches +`BOOT_COMPLETED` only after Android reports boot completion. Next2026 never receives +the Public event command. -The C CLI printed the expected echo only on a matching magic but `main()` always returned 0. +### FR-012 — hidden runtime failures / false hello success — **RESOLVED** -**Status:** fixed on `PatchNest/fix/cli-contract-hardening`; syscall failure and foreign hello magic now produce a stable non-zero exit code. +`kpatch hello` fails non-zero on syscall/authentication or foreign magic. Handshake, +exclusion, rehook and Public event failures are surfaced in logs/unresolved state. -## Release-only blockers +### FR-013 — final ZIP reproducibility unproven — **RESOLVED** -### FR-013 — Package output is not proven byte-reproducible +`scripts/package_module.sh` normalizes timestamps, strips host-specific ZIP extras, +uses canonical root entry names and a stable lexical file order. -The same source tree produced a successful Actions module ZIP whose SHA-256 differed from the existing `v0.4.1-rc2` release asset. Normal `zip -r` metadata/timestamps are a likely cause. +The final assembled module tree is packaged twice and must compare byte-for-byte +identical before artifact upload. This passed in Build run `31233359168`. -**Gate:** deterministic file order/timestamps/ZIP metadata and two-build byte comparison before publishing a hash-pinned update. +## Remaining release blocker -### FR-014 — Physical device lifecycle evidence is still missing +### FR-014 — physical device lifecycle evidence — **OPEN / RELEASE-BLOCKING** -Required before declaring fully flash-ready: +Hosted CI cannot prove that a real device survives and correctly rolls back a boot +partition write. The release gate remains closed until a supported ARM64 device +produces evidence for all of the following: -1. read-only preflight and target-slot identity; -2. backup capture + manifest binding; -3. patch without flash and image inspection; -4. flash + exact readback verification; -5. cold boot / warm reboot; -6. KPM load/control/unload/reload; -7. exclusion + rehook behavior; -8. root-manager coexistence for each supported manager; -9. rollback to the bound backup; -10. deliberate failed-boot recovery test; -11. second reboot after rollback; -12. evidence bundle bound to component SHAs and flashed image hashes. +1. read-only preflight and exact boot slot/target resolution; +2. validated rollback backup creation before mutation; +3. patch + destructive write + exact-range readback verification; +4. cold boot with `sys.boot_completed=1`; +5. `kpatch hello == hello1158` and valid `kpver`; +6. superkey mode `0600` and matching transaction digests; +7. KPM query/list behavior; +8. one controlled diagnostic KPM load → info → unload cycle if a separately reviewed + diagnostic KPM is supplied; +9. normal reboot and a second successful userspace/kernel handshake; +10. read-only rollback eligibility verification; +11. transaction-bound restore of the exact captured backup; +12. reboot after restore and confirmation that the original boot/root chain is intact; +13. negative rollback tests: device mismatch and externally modified boot must refuse + automatic restore; +14. evidence bundle tied to the package/source/component hashes used for the test. + +`scripts/device_validation.sh` implements the evidence workflow. Read-only phases are +default-safe. The destructive restore and KPM-cycle modes require exact unlock +environment tokens and are never called automatically by CI or module startup. + +## Unsupported general baseline + +Character/NAND boot targets remain outside the reviewed release baseline. The writer +rejects them until a device-specific erase/write/readback implementation and physical +validation matrix exist. ## Branch policy -`module/FLASH_REVIEW_BLOCKED` intentionally prevents installation of this branch until all P0 blockers are closed. Removing that marker requires a dedicated final review commit with evidence links; it must not be deleted as part of an unrelated change. +Do **not** remove `module/FLASH_REVIEW_BLOCKED`, mark PR #5 ready, merge it, or publish +a release solely because CI is green. + +The marker may be removed only on a dedicated physical-validation candidate after the +exact source/artifact identities are frozen. The review/release branch remains blocked +until FR-014 evidence passes. \ No newline at end of file From a4be6247a88e7658b45953c84c420375108a26ec Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:51:30 +0800 Subject: [PATCH 48/94] fix(auth): make superkey commit one-way atomic --- module/patch/superkey_safety.sh | 95 ++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh index 6f04194..70fb6e9 100644 --- a/module/patch/superkey_safety.sh +++ b/module/patch/superkey_safety.sh @@ -12,25 +12,39 @@ patchnest_validate_superkey() { _pn_len=${#_pn_key} [ "$_pn_len" -ge 32 ] || return 1 [ "$_pn_len" -le 63 ] || return 1 - # Keep the persisted format intentionally narrow. kptools accepts arbitrary - # strings, but a hex-only key avoids shell/log/parser ambiguity everywhere. printf '%s' "$_pn_key" | grep -Eq '^[0-9a-fA-F]+$' } +patchnest_existing_key_is_secure() { + [ -f "$PATCHNEST_SUPERKEY_FILE" ] || return 1 + _pn_mode=$(stat -c '%a' "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null) || return 1 + _pn_owner=$(stat -c '%u' "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null) || return 1 + [ "$_pn_mode" = "600" ] || return 1 + [ "$_pn_owner" = "0" ] || return 1 +} + patchnest_prepare_superkey() { _pn_workdir=$1 PATCHNEST_SUPERKEY='' PATCHNEST_SUPERKEY_IS_NEW=0 - if [ -f "$PATCHNEST_SUPERKEY_FILE" ]; then + if [ -e "$PATCHNEST_SUPERKEY_FILE" ]; then + [ -f "$PATCHNEST_SUPERKEY_FILE" ] || { + >&2 echo "! Existing PatchNest superkey path is not a regular file" + return 1 + } + patchnest_existing_key_is_secure || { + >&2 echo "! Existing PatchNest superkey must be root-owned mode 0600" + return 1 + } _pn_existing=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') - if patchnest_validate_superkey "$_pn_existing"; then - PATCHNEST_SUPERKEY=$_pn_existing - export PATCHNEST_SUPERKEY - return 0 - fi - >&2 echo "! Existing PatchNest superkey file is invalid; refusing to overwrite it implicitly" - return 1 + patchnest_validate_superkey "$_pn_existing" || { + >&2 echo "! Existing PatchNest superkey file is invalid" + return 1 + } + PATCHNEST_SUPERKEY=$_pn_existing + export PATCHNEST_SUPERKEY + return 0 fi command -v xxd >/dev/null 2>&1 || { @@ -53,6 +67,7 @@ patchnest_prepare_superkey() { umask 077 printf '%s\n' "$_pn_generated" > "$_pn_workdir/superkey.candidate" || return 1 chmod 0600 "$_pn_workdir/superkey.candidate" || return 1 + [ "$(stat -c '%a' "$_pn_workdir/superkey.candidate" 2>/dev/null)" = "600" ] || return 1 PATCHNEST_SUPERKEY=$_pn_generated PATCHNEST_SUPERKEY_IS_NEW=1 export PATCHNEST_SUPERKEY @@ -63,12 +78,31 @@ patchnest_superkey_sha256() { printf '%s' "$PATCHNEST_SUPERKEY" | sha256sum | awk '{print $1}' } +patchnest_drop_binding_after_key_failure() { + [ "${1:-0}" -eq 0 ] || patchnest_remove_rollback_binding +} + patchnest_commit_superkey() { [ -n "$PATCHNEST_SUPERKEY" ] || return 1 - # On the destructive patch path, the rollback transaction record is part of - # credential commit. If it cannot be bound to this device/target/backup, - # report failure so boot_patch.sh restores the verified pre-write image. + # Existing credentials are already the committed identity of the current + # installation. Verify them, but never rewrite the file as part of a new + # flash transaction. + if [ "$PATCHNEST_SUPERKEY_IS_NEW" -eq 0 ]; then + patchnest_existing_key_is_secure || return 1 + _pn_existing=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') + [ "$_pn_existing" = "$PATCHNEST_SUPERKEY" ] || return 1 + + if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then + command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || return 1 + patchnest_commit_rollback_binding || return 1 + fi + return 0 + fi + + # New credentials are committed only after the boot write passed readback. + # Bind rollback first; any later key failure removes the binding and causes + # boot_patch.sh to restore the verified pre-write image. _pn_binding_committed=0 if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || return 1 @@ -78,29 +112,50 @@ patchnest_commit_superkey() { _pn_dir=${PATCHNEST_SUPERKEY_FILE%/*} mkdir -p "$_pn_dir" || { - [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" return 1 } + umask 077 _pn_tmp="${PATCHNEST_SUPERKEY_FILE}.tmp.$$" printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_tmp" || { - [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" return 1 } chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp" - [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" return 1 } - mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_FILE" || { + [ "$(stat -c '%a' "$_pn_tmp" 2>/dev/null)" = "600" ] || { rm -f "$_pn_tmp" - [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" return 1 } - chmod 0600 "$PATCHNEST_SUPERKEY_FILE" || { - [ "$_pn_binding_committed" -eq 0 ] || patchnest_remove_rollback_binding + + # Single irreversible key-file transition. mv preserves the already checked + # 0600 mode, so there is no post-mv chmod that could create a half-state. + mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_FILE" || { + rm -f "$_pn_tmp" + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" return 1 } + + # Verification after rename performs no mutation. If it fails, remove the + # newly created credential and rollback authorization before the caller + # restores the verified boot backup. + if ! patchnest_existing_key_is_secure; then + rm -f "$PATCHNEST_SUPERKEY_FILE" + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" + return 1 + fi + _pn_committed=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') + if [ "$_pn_committed" != "$PATCHNEST_SUPERKEY" ]; then + rm -f "$PATCHNEST_SUPERKEY_FILE" + patchnest_drop_binding_after_key_failure "$_pn_binding_committed" + return 1 + fi + return 0 } patchnest_store_export_key() { From 4f5741e6579f6431e6e1c332ece8b1fd84e7782f Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:52:10 +0800 Subject: [PATCH 49/94] test(auth): keep root ownership strict outside CI transaction mode --- module/patch/superkey_safety.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh index 70fb6e9..340f281 100644 --- a/module/patch/superkey_safety.sh +++ b/module/patch/superkey_safety.sh @@ -15,12 +15,21 @@ patchnest_validate_superkey() { printf '%s' "$_pn_key" | grep -Eq '^[0-9a-fA-F]+$' } +patchnest_expected_key_owner() { + if [ "${PATCHNEST_TRANSACTION_TEST:-0}" = "1" ]; then + id -u + else + printf '%s\n' 0 + fi +} + patchnest_existing_key_is_secure() { [ -f "$PATCHNEST_SUPERKEY_FILE" ] || return 1 _pn_mode=$(stat -c '%a' "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null) || return 1 _pn_owner=$(stat -c '%u' "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null) || return 1 + _pn_expected_owner=$(patchnest_expected_key_owner) || return 1 [ "$_pn_mode" = "600" ] || return 1 - [ "$_pn_owner" = "0" ] || return 1 + [ "$_pn_owner" = "$_pn_expected_owner" ] || return 1 } patchnest_prepare_superkey() { @@ -34,7 +43,7 @@ patchnest_prepare_superkey() { return 1 } patchnest_existing_key_is_secure || { - >&2 echo "! Existing PatchNest superkey must be root-owned mode 0600" + >&2 echo "! Existing PatchNest superkey must be securely owned and mode 0600" return 1 } _pn_existing=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') From 1f1d6dc2014ce8f0f5a575f4cddd264e0b0bfb59 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:53:10 +0800 Subject: [PATCH 50/94] fix(recovery): make rollback binding atomic after key commit --- module/patch/transaction_safety.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index dc77ed0..879b5c0 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -39,6 +39,9 @@ patchnest_commit_rollback_binding() { boot_backup_*.img) ;; *) return 1 ;; esac + case "$_pn_backup_name" in + */*|*..*) return 1 ;; + esac _pn_backup_sha=$(sha256sum "$BACKUP_CANDIDATE" 2>/dev/null | awk '{print $1}') _pn_patched_sha=$(sha256sum "$WORKDIR/new-boot.img" 2>/dev/null | awk '{print $1}') @@ -78,8 +81,19 @@ patchnest_commit_rollback_binding() { } EOF chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } - mv -f "$_pn_tmp" "$PATCHNEST_ROLLBACK_BINDING_FILE" || return 1 - chmod 0600 "$PATCHNEST_ROLLBACK_BINDING_FILE" || return 1 + [ "$(stat -c '%a' "$_pn_tmp" 2>/dev/null)" = "600" ] || { + rm -f "$_pn_tmp" + return 1 + } + + # One irreversible binding transition. No chmod or other potentially + # failing mutation is performed after mv, so an existing valid binding is + # either untouched or atomically replaced by the complete new record. + mv -f "$_pn_tmp" "$PATCHNEST_ROLLBACK_BINDING_FILE" || { + rm -f "$_pn_tmp" + return 1 + } + return 0 } patchnest_remove_rollback_binding() { From 410e1911419abd400c8d852adf11c7549bb11ab4 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:54:37 +0800 Subject: [PATCH 51/94] fix(auth): add crash-recoverable pending superkey state --- module/patch/superkey_safety.sh | 183 ++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 79 deletions(-) diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh index 340f281..3d675a3 100644 --- a/module/patch/superkey_safety.sh +++ b/module/patch/superkey_safety.sh @@ -3,6 +3,7 @@ # This file is sourced by boot_patch.sh. It never prints the key value. PATCHNEST_SUPERKEY_FILE="${PATCHNEST_SUPERKEY_FILE:-/data/adb/patchnest/superkey}" +PATCHNEST_SUPERKEY_PENDING_FILE="${PATCHNEST_SUPERKEY_PENDING_FILE:-/data/adb/patchnest/superkey.pending}" PATCHNEST_EXPORT_KEY_DIR="${PATCHNEST_EXPORT_KEY_DIR:-/data/adb/patchnest/export_keys}" PATCHNEST_SUPERKEY='' PATCHNEST_SUPERKEY_IS_NEW=0 @@ -23,35 +24,73 @@ patchnest_expected_key_owner() { fi } -patchnest_existing_key_is_secure() { - [ -f "$PATCHNEST_SUPERKEY_FILE" ] || return 1 - _pn_mode=$(stat -c '%a' "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null) || return 1 - _pn_owner=$(stat -c '%u' "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null) || return 1 +patchnest_key_file_is_secure() { + _pn_key_file=$1 + [ -f "$_pn_key_file" ] || return 1 + [ ! -L "$_pn_key_file" ] || return 1 + _pn_mode=$(stat -c '%a' "$_pn_key_file" 2>/dev/null) || return 1 + _pn_owner=$(stat -c '%u' "$_pn_key_file" 2>/dev/null) || return 1 _pn_expected_owner=$(patchnest_expected_key_owner) || return 1 [ "$_pn_mode" = "600" ] || return 1 [ "$_pn_owner" = "$_pn_expected_owner" ] || return 1 } +patchnest_read_key_file() { + _pn_key_file=$1 + patchnest_key_file_is_secure "$_pn_key_file" || return 1 + _pn_read=$(head -n 1 "$_pn_key_file" 2>/dev/null | tr -d '\r\n') + patchnest_validate_superkey "$_pn_read" || return 1 + printf '%s\n' "$_pn_read" +} + +patchnest_write_pending_key() { + _pn_value=$1 + patchnest_validate_superkey "$_pn_value" || return 1 + _pn_dir=${PATCHNEST_SUPERKEY_PENDING_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_SUPERKEY_PENDING_FILE}.tmp.$$" + printf '%s\n' "$_pn_value" > "$_pn_tmp" || return 1 + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + [ "$(stat -c '%a' "$_pn_tmp" 2>/dev/null)" = "600" ] || { + rm -f "$_pn_tmp" + return 1 + } + mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_PENDING_FILE" || { + rm -f "$_pn_tmp" + return 1 + } + patchnest_key_file_is_secure "$PATCHNEST_SUPERKEY_PENDING_FILE" || return 1 +} + patchnest_prepare_superkey() { _pn_workdir=$1 PATCHNEST_SUPERKEY='' PATCHNEST_SUPERKEY_IS_NEW=0 if [ -e "$PATCHNEST_SUPERKEY_FILE" ]; then - [ -f "$PATCHNEST_SUPERKEY_FILE" ] || { - >&2 echo "! Existing PatchNest superkey path is not a regular file" + _pn_existing=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_FILE") || { + >&2 echo "! Existing PatchNest superkey must be a secure root-owned mode 0600 regular file" return 1 } - patchnest_existing_key_is_secure || { - >&2 echo "! Existing PatchNest superkey must be securely owned and mode 0600" - return 1 - } - _pn_existing=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') - patchnest_validate_superkey "$_pn_existing" || { - >&2 echo "! Existing PatchNest superkey file is invalid" + PATCHNEST_SUPERKEY=$_pn_existing + export PATCHNEST_SUPERKEY + # A committed key wins. A leftover pending file cannot be part of the + # active credential state and is safe to discard. + rm -f "$PATCHNEST_SUPERKEY_PENDING_FILE" + return 0 + fi + + # If a destructive operation was interrupted after staging a pending key, + # reuse that credential. It may already match a boot image written just + # before power loss. + if [ "${FLASH_TO_DEVICE:-false}" = "true" ] && [ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ]; then + _pn_pending=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || { + >&2 echo "! Pending PatchNest superkey is insecure or invalid" return 1 } - PATCHNEST_SUPERKEY=$_pn_existing + PATCHNEST_SUPERKEY=$_pn_pending + PATCHNEST_SUPERKEY_IS_NEW=1 export PATCHNEST_SUPERKEY return 0 fi @@ -73,10 +112,16 @@ patchnest_prepare_superkey() { return 1 } - umask 077 - printf '%s\n' "$_pn_generated" > "$_pn_workdir/superkey.candidate" || return 1 - chmod 0600 "$_pn_workdir/superkey.candidate" || return 1 - [ "$(stat -c '%a' "$_pn_workdir/superkey.candidate" 2>/dev/null)" = "600" ] || return 1 + if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then + # Persist only as pending before the boot write. The active key path is + # left untouched until the written image has passed readback. + patchnest_write_pending_key "$_pn_generated" || return 1 + else + umask 077 + printf '%s\n' "$_pn_generated" > "$_pn_workdir/superkey.candidate" || return 1 + chmod 0600 "$_pn_workdir/superkey.candidate" || return 1 + fi + PATCHNEST_SUPERKEY=$_pn_generated PATCHNEST_SUPERKEY_IS_NEW=1 export PATCHNEST_SUPERKEY @@ -87,21 +132,19 @@ patchnest_superkey_sha256() { printf '%s' "$PATCHNEST_SUPERKEY" | sha256sum | awk '{print $1}' } -patchnest_drop_binding_after_key_failure() { - [ "${1:-0}" -eq 0 ] || patchnest_remove_rollback_binding +patchnest_revert_new_key_to_pending() { + [ "$PATCHNEST_SUPERKEY_IS_NEW" -eq 1 ] || return 0 + if [ -f "$PATCHNEST_SUPERKEY_FILE" ] && [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ]; then + mv -f "$PATCHNEST_SUPERKEY_FILE" "$PATCHNEST_SUPERKEY_PENDING_FILE" 2>/dev/null || true + fi } patchnest_commit_superkey() { [ -n "$PATCHNEST_SUPERKEY" ] || return 1 - # Existing credentials are already the committed identity of the current - # installation. Verify them, but never rewrite the file as part of a new - # flash transaction. if [ "$PATCHNEST_SUPERKEY_IS_NEW" -eq 0 ]; then - patchnest_existing_key_is_secure || return 1 - _pn_existing=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') + _pn_existing=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_FILE") || return 1 [ "$_pn_existing" = "$PATCHNEST_SUPERKEY" ] || return 1 - if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || return 1 patchnest_commit_rollback_binding || return 1 @@ -109,61 +152,41 @@ patchnest_commit_superkey() { return 0 fi - # New credentials are committed only after the boot write passed readback. - # Bind rollback first; any later key failure removes the binding and causes - # boot_patch.sh to restore the verified pre-write image. - _pn_binding_committed=0 + # For destructive writes a durable pending key already exists. Promote it + # only after the image passed readback. This makes power loss before this + # point recoverable by service.sh without weakening kernel authentication. if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then - command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || return 1 - patchnest_commit_rollback_binding || return 1 - _pn_binding_committed=1 - fi - - _pn_dir=${PATCHNEST_SUPERKEY_FILE%/*} - mkdir -p "$_pn_dir" || { - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 - } + _pn_pending=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || return 1 + [ "$_pn_pending" = "$PATCHNEST_SUPERKEY" ] || return 1 - umask 077 - _pn_tmp="${PATCHNEST_SUPERKEY_FILE}.tmp.$$" - printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_tmp" || { - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 - } - chmod 0600 "$_pn_tmp" || { - rm -f "$_pn_tmp" - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 - } - [ "$(stat -c '%a' "$_pn_tmp" 2>/dev/null)" = "600" ] || { - rm -f "$_pn_tmp" - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 - } - - # Single irreversible key-file transition. mv preserves the already checked - # 0600 mode, so there is no post-mv chmod that could create a half-state. - mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_FILE" || { - rm -f "$_pn_tmp" - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 - } + [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 + mv -f "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE" || return 1 + _pn_committed=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_FILE") || { + patchnest_revert_new_key_to_pending + return 1 + } + [ "$_pn_committed" = "$PATCHNEST_SUPERKEY" ] || { + patchnest_revert_new_key_to_pending + return 1 + } - # Verification after rename performs no mutation. If it fails, remove the - # newly created credential and rollback authorization before the caller - # restores the verified boot backup. - if ! patchnest_existing_key_is_secure; then - rm -f "$PATCHNEST_SUPERKEY_FILE" - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 - fi - _pn_committed=$(head -n 1 "$PATCHNEST_SUPERKEY_FILE" 2>/dev/null | tr -d '\r\n') - if [ "$_pn_committed" != "$PATCHNEST_SUPERKEY" ]; then - rm -f "$PATCHNEST_SUPERKEY_FILE" - patchnest_drop_binding_after_key_failure "$_pn_binding_committed" - return 1 + # The key now matches the verified boot image. Commit rollback binding + # last. If binding creation fails, move the key back to pending before + # the caller rolls the boot image back. If power fails in that window, + # the pending-key recovery handshake still reaches the written boot. + command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || { + patchnest_revert_new_key_to_pending + return 1 + } + patchnest_commit_rollback_binding || { + patchnest_revert_new_key_to_pending + return 1 + } + return 0 fi + + # Export-only image: no active device key is committed. The caller stores + # a root-only image-hash keyed credential record instead. return 0 } @@ -176,7 +199,9 @@ patchnest_store_export_key() { mkdir -p "$PATCHNEST_EXPORT_KEY_DIR" || return 1 umask 077 _pn_out="$PATCHNEST_EXPORT_KEY_DIR/${_pn_image_sha}.superkey" - printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_out" || return 1 - chmod 0600 "$_pn_out" || return 1 + _pn_tmp="${_pn_out}.tmp.$$" + printf '%s\n' "$PATCHNEST_SUPERKEY" > "$_pn_tmp" || return 1 + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + mv -f "$_pn_tmp" "$_pn_out" || { rm -f "$_pn_tmp"; return 1; } printf '%s\n' "$_pn_out" } From 3156b00ab93cccab7efc9249e8bb5977fc60a6d4 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:56:23 +0800 Subject: [PATCH 52/94] fix(runtime): recover interrupted Public1158 key promotion safely --- module/service.sh | 70 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/module/service.sh b/module/service.sh index 33830ea..e95d603 100644 --- a/module/service.sh +++ b/module/service.sh @@ -36,6 +36,10 @@ esac # shellcheck disable=SC1091 . "$MODDIR/kpm_verify.sh" 2>/dev/null || true +# Load only key-file validation/promotion helpers. This does not select a key +# or issue a supercall by itself. +# shellcheck disable=SC1091 +. "$MODDIR/patch/superkey_safety.sh" 2>/dev/null || true mkdir -p "$PNDIR" "$KPM_DIR/failed" "$KPM_EVENT_DIR" echo "=== $(date) service.sh started ===" > "$LOG" @@ -59,12 +63,60 @@ if [ ! -x "$MODDIR/bin/kpatch" ]; then exit 0 fi +try_pending_public1158_key() { + command -v patchnest_read_key_file >/dev/null 2>&1 || return 1 + command -v patchnest_key_file_is_secure >/dev/null 2>&1 || return 1 + + # Never override a committed key. Pending recovery is only for the + # power-loss window after a new Public1158 boot was written but before the + # pending credential could be atomically promoted. + [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 + [ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || return 1 + + _pn_pending_key=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || { + echo "[$(date)] ERROR: pending Public1158 key is insecure or invalid" >> "$LOG" + return 1 + } + + _pn_pending_hello=$(PATCHNEST_SUPERKEY="$_pn_pending_key" kpatch hello 2>>"$LOG") + _pn_pending_rc=$? + _pn_pending_key='' + + if [ "$_pn_pending_rc" -ne 0 ] || [ "$_pn_pending_hello" != "hello1158" ]; then + echo "[$(date)] Pending Public1158 key did not authenticate the running kernel" >> "$LOG" + return 1 + fi + + # The read-only hello proved the pending key belongs to the running kernel. + # Promote by one same-filesystem rename; do not chmod/mutate after mv. + if ! mv -f "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE"; then + echo "[$(date)] ERROR: authenticated pending key could not be promoted" >> "$LOG" + return 1 + fi + if ! patchnest_key_file_is_secure "$PATCHNEST_SUPERKEY_FILE"; then + mv -f "$PATCHNEST_SUPERKEY_FILE" "$PATCHNEST_SUPERKEY_PENDING_FILE" 2>/dev/null || true + echo "[$(date)] ERROR: promoted Public1158 key failed security verification" >> "$LOG" + return 1 + fi + + echo "[$(date)] RECOVERY: authenticated pending Public1158 key promoted after interrupted flash transaction" >> "$LOG" + touch "$PNDIR/credential_recovered_pending" + # A crash before normal transaction commit means rollback binding may be + # absent. Keep unresolved visible until physical/operator review even though + # runtime authentication has been recovered. + touch "$MODDIR/unresolved" + hello_out="hello1158" + hello_rc=0 + return 0 +} + # kpatch hello is the package-level ABI readiness gate. Capture the exact echo # and turn it into a capability profile; never infer mutation safety from a # numeric command ID shared by multiple KernelPatch families. retries=0 max_retries=5 hello_out="" +hello_rc=1 while [ "$retries" -lt "$max_retries" ]; do hello_out="$(kpatch hello 2>>"$LOG")" hello_rc=$? @@ -77,10 +129,12 @@ while [ "$retries" -lt "$max_retries" ]; do done if [ "$hello_rc" -ne 0 ] || [ -z "$hello_out" ]; then - echo "[$(date)] ERROR: kpatch/kernel ABI handshake failed after $retries retries" >> "$LOG" - echo "[$(date)] Refusing KPM/exclude/rehook/event operations; package is unresolved." >> "$LOG" - touch "$MODDIR/unresolved" - exit 0 + if ! try_pending_public1158_key; then + echo "[$(date)] ERROR: kpatch/kernel ABI handshake failed after $retries retries" >> "$LOG" + echo "[$(date)] Refusing KPM/exclude/rehook/event operations; package is unresolved." >> "$LOG" + touch "$MODDIR/unresolved" + exit 0 + fi fi case "$hello_out" in @@ -96,8 +150,9 @@ esac echo "[$(date)] kpatch hello OK: $hello_out profile=$ABI_PROFILE" >> "$LOG" printf '%s\n' "$ABI_PROFILE" > "$PNDIR/abi_profile" -# Healthy userspace/kernel handshake. This only clears the userspace marker; -# it does not claim physical boot-loop recovery has been validated. +# Healthy userspace/kernel handshake. This only clears boot counters; it does +# not clear unresolved because pending-key recovery and later subsystem errors +# intentionally remain visible for operator review. echo "0" > "$PNDIR/boot_count" 2>/dev/null rm -f "$PNDIR/autorecovery_active" "$PNDIR/auto_unpatch_requested" @@ -130,8 +185,6 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi fi - # The C CLI accepts `load PATH [ARGS]`; preserve the sanitized args string - # as one argv element rather than sending a literal option terminator. if [ -n "$args" ]; then kpatch kpm load "$kpm" "$args" else @@ -146,7 +199,6 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do done # 0x1100/0x1101 are rehook in Next2026 but SU grant/revoke in Public1158. -# Never dispatch rehook merely because the numeric command exists. if [ -n "$REHOOK" ]; then if [ "$ABI_PROFILE" = "public1158" ]; then echo "[$(date)] rehook request ignored: unsupported and unsafe on Public1158" >> "$LOG" From c31883b4e816e244af77d2cc545c68af3c9a911e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 09:57:22 +0800 Subject: [PATCH 53/94] test(auth): cover pending-key crash consistency --- tests/flash_safety_contract.sh | 183 ++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 59 deletions(-) diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh index f28a681..528693f 100644 --- a/tests/flash_safety_contract.sh +++ b/tests/flash_safety_contract.sh @@ -9,114 +9,179 @@ SUPERKEY="$ROOT/module/patch/superkey_safety.sh" UNPATCH="$ROOT/module/patch/boot_unpatch.sh" EXTRACT="$ROOT/module/patch/boot_extract.sh" +PATCHNEST_TRANSACTION_TEST=1 +export PATCHNEST_TRANSACTION_TEST + fail() { echo "flash safety contract: FAIL: $*" >&2 exit 1 } -# Structural gates: these are release invariants, not documentation hints. +# Structural release invariants. grep -Fq '. "$MODPATH/flash_safety.sh"' "$PATCH" || fail "boot_patch does not activate flash_safety" grep -Fq '. "$MODPATH/superkey_safety.sh"' "$PATCH" || fail "boot_patch does not activate superkey lifecycle" -grep -Fq 'mktemp -d /data/local/tmp/patchnest_patch.XXXXXX' "$PATCH" || fail "patch workspace is not operation-private" +grep -Fq 'mktemp -d /data/local/tmp/patchnest_patch.XXXXXX' "$PATCH" || fail "patch workspace is not private" grep -Fq '"boot_target":' "$PATCH" || fail "backup manifest has no target binding" grep -Fq '"backup_sha256":' "$PATCH" || fail "backup manifest has no backup digest" grep -Fq '"superkey_sha256":' "$PATCH" || fail "backup/receipt has no credential binding" -grep -Fq '"backup_verified": true' "$PATCH" || fail "backup manifest is not explicitly verified" +grep -Fq '"backup_verified": true' "$PATCH" || fail "backup manifest is not verified" grep -Fq 'validate_boot_image "$WORKDIR/new-boot.img"' "$PATCH" || fail "repacked image is not validated" -grep -Fq 'flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"' "$PATCH" || fail "patch path bypasses reviewed flash writer" -grep -Fq -- '-s "$PATCHNEST_SUPERKEY"' "$PATCH" || fail "Public1158 superkey is not embedded by kptools" -grep -Fq 'patchnest_commit_superkey' "$PATCH" || fail "verified flash does not commit its matching superkey" - -# Old fail-open/stale-workspace patterns must never return. +grep -Fq 'flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"' "$PATCH" || fail "patch bypasses reviewed writer" +grep -Fq -- '-s "$PATCHNEST_SUPERKEY"' "$PATCH" || fail "Public1158 key is not embedded" +grep -Fq 'patchnest_commit_superkey' "$PATCH" || fail "verified flash does not commit credential state" ! grep -Fq 'if [ ! -f kernel ]' "$PATCH" || fail "patch may reuse stale kernel" ! grep -Fq 'Cannot verify with kptools' "$PATCH" || fail "embedded KPM validation is fail-open" ! grep -Fq '(proceeding)' "$PATCH" || fail "embedded KPM validation is fail-open" -! grep -Eq 'TMP_DATE=.*%y%m%d%H%M([^%]|$)' "$PATCH" || fail "backup naming is minute-granularity" -# All destructive boot flows must activate the reviewed override layer. for file in "$PATCH" "$UNPATCH" "$EXTRACT"; do grep -Fq 'flash_safety.sh' "$file" || fail "$(basename "$file") does not source flash_safety" done -# Recovery is transaction-bound; no "newest valid backup" selector may return. -grep -Fq -- '--restore-bound-backup' "$UNPATCH" || fail "bound-backup restore entry point missing" -grep -Fq 'rollback_binding.json' "$UNPATCH" || fail "restore does not require rollback transaction binding" +grep -Fq -- '--restore-bound-backup' "$UNPATCH" || fail "bound restore entry point missing" +grep -Fq 'rollback_binding.json' "$UNPATCH" || fail "restore does not require transaction binding" grep -Fq 'device_binding_sha256' "$UNPATCH" || fail "restore does not verify device identity" grep -Fq 'patched_image_sha256' "$UNPATCH" || fail "restore does not verify current patched bytes" -! grep -Fq 'for manifest in' "$UNPATCH" || fail "restore still scans/selects arbitrary backup manifests" +! grep -Fq 'for manifest in' "$UNPATCH" || fail "restore still selects arbitrary manifests" + +grep -Fq 'superkey.pending' "$SUPERKEY" || fail "pending credential crash state missing" +grep -Fq 'PATCHNEST_SUPERKEY_PENDING_FILE' "$SUPERKEY" || fail "pending credential path is not explicit" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT HUP INT TERM -# Exercise the writer against a regular-file target. This validates the same -# digest contract used by offline tests without requiring a privileged block device. +# Writer readback contract on an offline regular-file target. printf '%s\n' 'PatchNest transactional flash contract' > "$TMP/source.img" printf '%s\n' 'old target contents' > "$TMP/target.img" - # shellcheck disable=SC1090 . "$SAFETY" flash_image "$TMP/source.img" "$TMP/target.img" || fail "file-target flash_image failed" cmp -s "$TMP/source.img" "$TMP/target.img" || fail "file-target readback differs" - expected=$(sha256sum "$TMP/source.img" | awk '{print $1}') actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') [ "$expected" = "$actual" ] || fail "digest mismatch after verified write" -# Exercise the Public1158 credential lifecycle entirely in the temp tree. -PATCHNEST_SUPERKEY_FILE="$TMP/state/superkey" -PATCHNEST_EXPORT_KEY_DIR="$TMP/state/export_keys" -export PATCHNEST_SUPERKEY_FILE PATCHNEST_EXPORT_KEY_DIR -# shellcheck disable=SC1090 -. "$SUPERKEY" -patchnest_prepare_superkey "$TMP" || fail "superkey preparation failed" -patchnest_validate_superkey "$PATCHNEST_SUPERKEY" || fail "generated key is invalid" -[ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || fail "new key persisted before flash commit" -key_before=$PATCHNEST_SUPERKEY -key_sha=$(patchnest_superkey_sha256) -printf '%s' "$key_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "superkey digest is invalid" - -patchnest_commit_superkey || fail "superkey commit failed" -[ -f "$PATCHNEST_SUPERKEY_FILE" ] || fail "committed superkey missing" -[ "$(stat -c '%a' "$PATCHNEST_SUPERKEY_FILE")" = "600" ] || fail "committed superkey mode is not 0600" -[ "$(cat "$PATCHNEST_SUPERKEY_FILE")" = "$key_before" ] || fail "committed key changed" - -# A later patch must reuse the persisted credential rather than silently rotate it. -PATCHNEST_SUPERKEY='' -patchnest_prepare_superkey "$TMP" || fail "persisted superkey reload failed" -[ "$PATCHNEST_SUPERKEY" = "$key_before" ] || fail "persisted key was not reused" - -export_record=$(patchnest_store_export_key "$TMP/source.img") || fail "export key record failed" -[ -f "$export_record" ] || fail "export key record missing" -[ "$(stat -c '%a' "$export_record")" = "600" ] || fail "export key record mode is not 0600" - -# Commit a synthetic destructive transaction and ensure only digests/identities -# required for exact rollback are persisted. -PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/state/rollback_binding.json" +# Export-only key generation must not create an active/pending device credential. +( + PATCHNEST_SUPERKEY_FILE="$TMP/export-state/superkey" + PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/export-state/superkey.pending" + PATCHNEST_EXPORT_KEY_DIR="$TMP/export-state/export_keys" + FLASH_TO_DEVICE=false + export PATCHNEST_SUPERKEY_FILE PATCHNEST_SUPERKEY_PENDING_FILE PATCHNEST_EXPORT_KEY_DIR FLASH_TO_DEVICE + # shellcheck disable=SC1090 + . "$SUPERKEY" + patchnest_prepare_superkey "$TMP" || fail "export key preparation failed" + patchnest_validate_superkey "$PATCHNEST_SUPERKEY" || fail "generated export key invalid" + [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || fail "export path created active key" + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "export path created pending device key" + record=$(patchnest_store_export_key "$TMP/source.img") || fail "export key record failed" + [ -f "$record" ] || fail "export key record missing" + [ "$(stat -c '%a' "$record")" = "600" ] || fail "export key record mode is not 0600" +) + +# Successful destructive credential commit: pending exists before commit, then +# atomically becomes the active key. Stub only the independent binding writer. +( + PATCHNEST_SUPERKEY_FILE="$TMP/success-state/superkey" + PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/success-state/superkey.pending" + PATCHNEST_EXPORT_KEY_DIR="$TMP/success-state/export_keys" + FLASH_TO_DEVICE=true + export PATCHNEST_SUPERKEY_FILE PATCHNEST_SUPERKEY_PENDING_FILE PATCHNEST_EXPORT_KEY_DIR FLASH_TO_DEVICE + # shellcheck disable=SC1090 + . "$SUPERKEY" + patchnest_prepare_superkey "$TMP" || fail "destructive key preparation failed" + before=$PATCHNEST_SUPERKEY + [ -f "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "pending key not staged before write" + [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || fail "active key exists before verified write" + [ "$(stat -c '%a' "$PATCHNEST_SUPERKEY_PENDING_FILE")" = "600" ] || fail "pending key mode is not 0600" + patchnest_commit_rollback_binding() { return 0; } + patchnest_commit_superkey || fail "destructive key commit failed" + [ -f "$PATCHNEST_SUPERKEY_FILE" ] || fail "active key missing after commit" + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "pending key remains after commit" + [ "$(cat "$PATCHNEST_SUPERKEY_FILE")" = "$before" ] || fail "promoted key changed" +) + +# If rollback binding commit fails, the new active key must be moved back to +# pending so a power loss cannot strand a boot that already requires it. +( + PATCHNEST_SUPERKEY_FILE="$TMP/failure-state/superkey" + PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/failure-state/superkey.pending" + PATCHNEST_EXPORT_KEY_DIR="$TMP/failure-state/export_keys" + FLASH_TO_DEVICE=true + export PATCHNEST_SUPERKEY_FILE PATCHNEST_SUPERKEY_PENDING_FILE PATCHNEST_EXPORT_KEY_DIR FLASH_TO_DEVICE + # shellcheck disable=SC1090 + . "$SUPERKEY" + patchnest_prepare_superkey "$TMP" || fail "failure-path key preparation failed" + before=$PATCHNEST_SUPERKEY + patchnest_commit_rollback_binding() { return 1; } + if patchnest_commit_superkey; then + fail "key commit succeeded despite rollback binding failure" + fi + [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || fail "failed transaction left active key committed" + [ -f "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "failed transaction lost recoverable pending key" + [ "$(cat "$PATCHNEST_SUPERKEY_PENDING_FILE")" = "$before" ] || fail "reverted pending key changed" +) + +# An existing committed key must be secure and must not be rewritten. +( + mkdir -p "$TMP/existing-state" + PATCHNEST_SUPERKEY_FILE="$TMP/existing-state/superkey" + PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/existing-state/superkey.pending" + PATCHNEST_EXPORT_KEY_DIR="$TMP/existing-state/export_keys" + FLASH_TO_DEVICE=true + export PATCHNEST_SUPERKEY_FILE PATCHNEST_SUPERKEY_PENDING_FILE PATCHNEST_EXPORT_KEY_DIR FLASH_TO_DEVICE + printf '%s\n' '0123456789abcdef0123456789abcdef0123456789abcdef' > "$PATCHNEST_SUPERKEY_FILE" + chmod 0600 "$PATCHNEST_SUPERKEY_FILE" + inode_before=$(stat -c '%i' "$PATCHNEST_SUPERKEY_FILE") + # shellcheck disable=SC1090 + . "$SUPERKEY" + patchnest_prepare_superkey "$TMP" || fail "existing key preparation failed" + patchnest_commit_rollback_binding() { return 0; } + patchnest_commit_superkey || fail "existing key transaction commit failed" + inode_after=$(stat -c '%i' "$PATCHNEST_SUPERKEY_FILE") + [ "$inode_before" = "$inode_after" ] || fail "existing key was rewritten" + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "stale pending key survived committed-key path" +) + +# Symlink credentials must never be accepted. +( + mkdir -p "$TMP/symlink-state" + printf '%s\n' '0123456789abcdef0123456789abcdef0123456789abcdef' > "$TMP/symlink-target" + chmod 0600 "$TMP/symlink-target" + PATCHNEST_SUPERKEY_FILE="$TMP/symlink-state/superkey" + PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/symlink-state/superkey.pending" + FLASH_TO_DEVICE=true + export PATCHNEST_SUPERKEY_FILE PATCHNEST_SUPERKEY_PENDING_FILE FLASH_TO_DEVICE + ln -s "$TMP/symlink-target" "$PATCHNEST_SUPERKEY_FILE" + # shellcheck disable=SC1090 + . "$SUPERKEY" + if patchnest_prepare_superkey "$TMP" >/dev/null 2>&1; then + fail "symlink key path was accepted" + fi +) + +# Commit and inspect the exact rollback transaction record itself. +PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/transaction-state/rollback_binding.json" PATCHNEST_DEVICE_IDENTITY='unit-test-device-A' -export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_DEVICE_IDENTITY +PATCHNEST_SUPERKEY='0123456789abcdef0123456789abcdef0123456789abcdef' +export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_DEVICE_IDENTITY PATCHNEST_SUPERKEY # shellcheck disable=SC1090 . "$TRANSACTION" - BOOT_TARGET="$TMP/transaction-target.img" BACKUP_CANDIDATE="$TMP/boot_backup_20260808T000000Z_TEST.img" WORKDIR="$TMP/transaction-work" -FLASH_TO_DEVICE=true -export BOOT_TARGET BACKUP_CANDIDATE WORKDIR FLASH_TO_DEVICE +export BOOT_TARGET BACKUP_CANDIDATE WORKDIR mkdir -p "$WORKDIR" printf '%s\n' 'original boot bytes' > "$BACKUP_CANDIDATE" printf '%s\n' 'patched boot bytes' > "$WORKDIR/new-boot.img" cp "$WORKDIR/new-boot.img" "$BOOT_TARGET" - -patchnest_commit_superkey || fail "destructive transaction commit failed" -[ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "rollback transaction was not committed" +patchnest_commit_rollback_binding || fail "rollback transaction commit failed" +[ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "rollback transaction missing" [ "$(stat -c '%a' "$PATCHNEST_ROLLBACK_BINDING_FILE")" = "600" ] || fail "rollback binding mode is not 0600" - -grep -Fq '"verified_readback": true' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "rollback binding is not readback-qualified" -grep -Fq '"rollback_backup": "boot_backup_20260808T000000Z_TEST.img"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "rollback binding does not name exact backup" +grep -Fq '"verified_readback": true' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "binding is not readback-qualified" +grep -Fq '"rollback_backup": "boot_backup_20260808T000000Z_TEST.img"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "binding does not name exact backup" grep -Eq '"device_binding_sha256": "[0-9a-f]{64}"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "device digest missing" grep -Eq '"patched_image_sha256": "[0-9a-f]{64}"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "patched digest missing" grep -Eq '"patched_image_size": [1-9][0-9]*' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "patched byte range missing" - binding_a=$(patchnest_device_binding_sha256) PATCHNEST_DEVICE_IDENTITY='unit-test-device-B' export PATCHNEST_DEVICE_IDENTITY From 33d7f11123b214865fdab254256d79d623665937 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:23:15 +0800 Subject: [PATCH 54/94] fix(flash): add durable pending transaction state --- module/patch/transaction_safety.sh | 242 +++++++++++++++++++++++++---- 1 file changed, 209 insertions(+), 33 deletions(-) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index 879b5c0..84dd9aa 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -1,12 +1,79 @@ #!/system/bin/sh -# Transaction identity helpers shared by patch and restore paths. -# No function in this file writes a boot target directly. +# Transaction identity/state helpers shared by patch, service and restore paths. +# This file never writes a boot target directly. PATCHNEST_ROLLBACK_BINDING_FILE="${PATCHNEST_ROLLBACK_BINDING_FILE:-/data/adb/patchnest/rollback_binding.json}" +PATCHNEST_PENDING_TRANSACTION_FILE="${PATCHNEST_PENDING_TRANSACTION_FILE:-/data/adb/patchnest/transaction.pending.json}" +PATCHNEST_RECOVERY_REQUIRED_FILE="${PATCHNEST_RECOVERY_REQUIRED_FILE:-/data/adb/patchnest/flash_recovery_required}" + +patchnest_json_escape() { + printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +patchnest_json_string() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" +} + +patchnest_json_bool() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" +} + +patchnest_json_number() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" +} + +patchnest_state_expected_owner() { + if [ "${PATCHNEST_TRANSACTION_TEST:-0}" = "1" ]; then + id -u + else + printf '%s\n' 0 + fi +} + +patchnest_state_file_is_secure() { + _pn_file=$1 + [ -f "$_pn_file" ] || return 1 + [ ! -L "$_pn_file" ] || return 1 + _pn_mode=$(stat -c '%a' "$_pn_file" 2>/dev/null) || return 1 + _pn_owner=$(stat -c '%u' "$_pn_file" 2>/dev/null) || return 1 + _pn_expected_owner=$(patchnest_state_expected_owner) || return 1 + [ "$_pn_mode" = "600" ] || return 1 + [ "$_pn_owner" = "$_pn_expected_owner" ] || return 1 +} + +patchnest_hash_file() { + _pn_hash=$(sha256sum "$1" 2>/dev/null | awk '{print $1}') + printf '%s' "$_pn_hash" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s\n' "$_pn_hash" +} + +patchnest_hash_prefix() { + _pn_target=$1 + _pn_size=$2 + printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + _pn_blocks=$(((_pn_size + 1048575) / 1048576)) + _pn_digest=$(dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ + | head -c "$_pn_size" \ + | sha256sum \ + | awk '{print $1}') + printf '%s' "$_pn_digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s\n' "$_pn_digest" +} patchnest_device_binding_sha256() { - # Synthetic identity is an offline-test hook only. Production always uses - # the real boot serial/context and stores only the digest, never the serial. + _pn_bind_target=${1:-${BOOT_TARGET:-unknown-target}} if [ "${PATCHNEST_TRANSACTION_TEST:-0}" = "1" ] && [ -n "${PATCHNEST_DEVICE_IDENTITY:-}" ]; then _pn_identity=$PATCHNEST_DEVICE_IDENTITY else @@ -14,19 +81,135 @@ patchnest_device_binding_sha256() { _pn_serial=$(getprop ro.boot.serialno 2>/dev/null | tr -d '\r\n') [ -n "$_pn_serial" ] || _pn_serial=$(getprop ro.serialno 2>/dev/null | tr -d '\r\n') [ -n "$_pn_serial" ] || return 1 - _pn_product=$(getprop ro.product.device 2>/dev/null | tr -d '\r\n') _pn_vbmeta=$(getprop ro.boot.vbmeta.digest 2>/dev/null | tr -d '\r\n') _pn_slot=$(getprop ro.boot.slot_suffix 2>/dev/null | tr -d '\r\n') _pn_identity="$_pn_serial|$_pn_product|$_pn_vbmeta|$_pn_slot" fi - _pn_target=${BOOT_TARGET:-unknown-target} - _pn_digest=$(printf '%s' "$_pn_identity|$_pn_target" | sha256sum | awk '{print $1}') + _pn_digest=$(printf '%s' "$_pn_identity|$_pn_bind_target" | sha256sum | awk '{print $1}') printf '%s' "$_pn_digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 printf '%s\n' "$_pn_digest" } +patchnest_has_unfinished_transaction() { + [ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || [ -e "$PATCHNEST_RECOVERY_REQUIRED_FILE" ] +} + +patchnest_clear_pending_transaction() { + rm -f "$PATCHNEST_PENDING_TRANSACTION_FILE" + [ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] +} + +patchnest_mark_recovery_required() { + _pn_reason=${1:-unknown} + _pn_dir=${PATCHNEST_RECOVERY_REQUIRED_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_RECOVERY_REQUIRED_FILE}.tmp.$$" + { + printf 'reason=%s\n' "$_pn_reason" + printf 'timestamp=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S)" + } > "$_pn_tmp" || return 1 + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + mv -f "$_pn_tmp" "$PATCHNEST_RECOVERY_REQUIRED_FILE" || { rm -f "$_pn_tmp"; return 1; } +} + +patchnest_clear_recovery_required() { + rm -f "$PATCHNEST_RECOVERY_REQUIRED_FILE" +} + +patchnest_stage_pending_transaction() { + _pn_source=$1 + _pn_target=$2 + _pn_backup=$3 + + [ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || return 1 + [ ! -e "$PATCHNEST_RECOVERY_REQUIRED_FILE" ] || return 1 + [ -f "$_pn_source" ] || return 1 + [ -f "$_pn_backup" ] || return 1 + [ -e "$_pn_target" ] || return 1 + + _pn_source_sha=$(patchnest_hash_file "$_pn_source") || return 1 + _pn_backup_sha=$(patchnest_hash_file "$_pn_backup") || return 1 + _pn_source_size=$(stat -c '%s' "$_pn_source" 2>/dev/null) + _pn_device_sha=$(patchnest_device_binding_sha256 "$_pn_target") || return 1 + _pn_key_sha=$(patchnest_superkey_sha256 2>/dev/null) || return 1 + _pn_backup_name=$(basename "$_pn_backup") + + printf '%s' "$_pn_source_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + case "$_pn_backup_name" in + boot_backup_*.img) ;; + *) return 1 ;; + esac + case "$_pn_backup_name" in + */*|*..*) return 1 ;; + esac + + _pn_dir=${PATCHNEST_PENDING_TRANSACTION_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_PENDING_TRANSACTION_FILE}.tmp.$$" + cat > "$_pn_tmp" </dev/null || date +%Y-%m-%dT%H:%M:%S)" +} +EOF + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + mv -f "$_pn_tmp" "$PATCHNEST_PENDING_TRANSACTION_FILE" || { rm -f "$_pn_tmp"; return 1; } + patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" +} + +patchnest_mark_pending_transaction_written() { + patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" || return 1 + [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "prepared" ] || return 1 + + _pn_target=$(patchnest_json_string boot_target "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_sha=$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_size=$(patchnest_json_number patched_image_size "$PATCHNEST_PENDING_TRANSACTION_FILE") + [ -e "$_pn_target" ] || return 1 + _pn_actual=$(patchnest_hash_prefix "$_pn_target" "$_pn_size") || return 1 + [ "$_pn_actual" = "$_pn_sha" ] || return 1 + + _pn_tmp="${PATCHNEST_PENDING_TRANSACTION_FILE}.tmp.$$" + sed 's/"state"[[:space:]]*:[[:space:]]*"prepared"/"state": "written"/' \ + "$PATCHNEST_PENDING_TRANSACTION_FILE" > "$_pn_tmp" || return 1 + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + mv -f "$_pn_tmp" "$PATCHNEST_PENDING_TRANSACTION_FILE" || { rm -f "$_pn_tmp"; return 1; } + [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] +} + +patchnest_pending_transaction_matches_written_key() { + _pn_key=$1 + patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" || return 1 + [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] || return 1 + + _pn_target=$(patchnest_json_string boot_target "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_device=$(patchnest_json_string device_binding_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_sha=$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_size=$(patchnest_json_number patched_image_size "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_key_sha=$(patchnest_json_string superkey_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + [ -e "$_pn_target" ] || return 1 + printf '%s' "$_pn_device$_pn_sha$_pn_key_sha" | grep -Eq '^[0-9a-f]{192}$' || return 1 + printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + + _pn_actual_key=$(printf '%s' "$_pn_key" | sha256sum | awk '{print $1}') + [ "$_pn_actual_key" = "$_pn_key_sha" ] || return 1 + _pn_actual_device=$(patchnest_device_binding_sha256 "$_pn_target") || return 1 + [ "$_pn_actual_device" = "$_pn_device" ] || return 1 + _pn_actual_sha=$(patchnest_hash_prefix "$_pn_target" "$_pn_size") || return 1 + [ "$_pn_actual_sha" = "$_pn_sha" ] +} + patchnest_commit_rollback_binding() { [ -n "${BOOT_TARGET:-}" ] || return 1 [ -n "${BACKUP_CANDIDATE:-}" ] || return 1 @@ -43,35 +226,33 @@ patchnest_commit_rollback_binding() { */*|*..*) return 1 ;; esac - _pn_backup_sha=$(sha256sum "$BACKUP_CANDIDATE" 2>/dev/null | awk '{print $1}') - _pn_patched_sha=$(sha256sum "$WORKDIR/new-boot.img" 2>/dev/null | awk '{print $1}') + _pn_backup_sha=$(patchnest_hash_file "$BACKUP_CANDIDATE") || return 1 + _pn_patched_sha=$(patchnest_hash_file "$WORKDIR/new-boot.img") || return 1 _pn_patched_size=$(stat -c '%s' "$WORKDIR/new-boot.img" 2>/dev/null) - _pn_device_sha=$(patchnest_device_binding_sha256) || return 1 - printf '%s' "$_pn_backup_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 - printf '%s' "$_pn_patched_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + _pn_device_sha=$(patchnest_device_binding_sha256 "$BOOT_TARGET") || return 1 + _pn_key_sha=$(patchnest_superkey_sha256 2>/dev/null) || return 1 printf '%s' "$_pn_patched_size" | grep -Eq '^[1-9][0-9]*$' || return 1 - _pn_key_sha="null" - if command -v patchnest_superkey_sha256 >/dev/null 2>&1; then - _pn_key_sha=$(patchnest_superkey_sha256 2>/dev/null || printf 'null') + if [ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ]; then + patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" || return 1 + [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] || return 1 + [ "$(patchnest_json_string boot_target "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$BOOT_TARGET" ] || return 1 + [ "$(patchnest_json_string rollback_backup_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_backup_sha" ] || return 1 + [ "$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_patched_sha" ] || return 1 + [ "$(patchnest_json_string superkey_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_key_sha" ] || return 1 fi - case "$_pn_key_sha" in - null) ;; - *) printf '%s' "$_pn_key_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 ;; - esac _pn_when=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) _pn_dir=${PATCHNEST_ROLLBACK_BINDING_FILE%/*} mkdir -p "$_pn_dir" || return 1 umask 077 _pn_tmp="${PATCHNEST_ROLLBACK_BINDING_FILE}.tmp.$$" - cat > "$_pn_tmp" </dev/null)" = "600" ] || { - rm -f "$_pn_tmp" - return 1 - } + mv -f "$_pn_tmp" "$PATCHNEST_ROLLBACK_BINDING_FILE" || { rm -f "$_pn_tmp"; return 1; } - # One irreversible binding transition. No chmod or other potentially - # failing mutation is performed after mv, so an existing valid binding is - # either untouched or atomically replaced by the complete new record. - mv -f "$_pn_tmp" "$PATCHNEST_ROLLBACK_BINDING_FILE" || { - rm -f "$_pn_tmp" + if ! patchnest_clear_pending_transaction; then + rm -f "$PATCHNEST_ROLLBACK_BINDING_FILE" return 1 - } + fi + patchnest_clear_recovery_required || true return 0 } From e80a1b54377a5ff0533ca56ebd2a1303620adc3b Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:23:37 +0800 Subject: [PATCH 55/94] fix(flash): add verified destructive write transaction --- module/patch/transactional_flash.sh | 104 ++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 module/patch/transactional_flash.sh diff --git a/module/patch/transactional_flash.sh b/module/patch/transactional_flash.sh new file mode 100644 index 0000000..240e95a --- /dev/null +++ b/module/patch/transactional_flash.sh @@ -0,0 +1,104 @@ +#!/system/bin/sh +# High-level destructive boot write transaction. +# Requires flash_safety.sh, transaction_safety.sh and superkey_safety.sh. + +patchnest_discard_pending_key_if_new() { + if command -v patchnest_discard_pending_key >/dev/null 2>&1; then + patchnest_discard_pending_key || true + fi +} + +patchnest_transaction_cleanup_transient() { + patchnest_clear_pending_transaction || true + patchnest_discard_pending_key_if_new +} + +patchnest_attempt_verified_rollback() { + _pn_target=$1 + _pn_backup=$2 + >&2 echo "! Attempting verified rollback to pre-write boot image" + flash_image "$_pn_backup" "$_pn_target" + _pn_rb=$? + if [ "$_pn_rb" -eq 0 ]; then + patchnest_transaction_cleanup_transient + patchnest_clear_recovery_required || true + echo "- Verified rollback restored the pre-write boot image" + return 0 + fi + + patchnest_mark_recovery_required "automatic_rollback_failed:${_pn_rb}" || true + >&2 echo "! CRITICAL: automatic rollback failed: $_pn_rb" + >&2 echo "! Pending transaction/key evidence was preserved for recovery" + return 1 +} + +# Returns: +# 0 write verified and pending transaction advanced to state=written +# 10 transaction could not be staged; target untouched +# 11 writer rejected before target mutation; transient state removed +# 20 writer may have touched target; verified rollback succeeded +# 21 writer may have touched target; automatic rollback failed (fatal) +# 22 write verified but state advance failed; verified rollback succeeded +# 23 write verified but state advance and rollback both failed (fatal) +patchnest_transactional_flash() { + _pn_source=$1 + _pn_target=$2 + _pn_backup=$3 + + patchnest_stage_pending_transaction "$_pn_source" "$_pn_target" "$_pn_backup" || { + patchnest_discard_pending_key_if_new + return 10 + } + + flash_image "$_pn_source" "$_pn_target" + _pn_rc=$? + if [ "$_pn_rc" -ne 0 ]; then + case "$_pn_rc" in + 1|2|3|4|7) + # Capacity/RO/dependency/payload/unsupported-target failures are + # detected before the writer starts copying target bytes. + patchnest_transaction_cleanup_transient + return 11 + ;; + 5|6) + # Write failure or readback mismatch may already have changed + # target bytes. Never just return to the caller. + if patchnest_attempt_verified_rollback "$_pn_target" "$_pn_backup"; then + return 20 + fi + return 21 + ;; + *) + # Unknown writer status is treated as potentially destructive. + if patchnest_attempt_verified_rollback "$_pn_target" "$_pn_backup"; then + return 20 + fi + return 21 + ;; + esac + fi + + if ! patchnest_mark_pending_transaction_written; then + >&2 echo "! Boot write verified but transaction state could not advance" + if patchnest_attempt_verified_rollback "$_pn_target" "$_pn_backup"; then + return 22 + fi + patchnest_mark_recovery_required "transaction_state_advance_failed" || true + return 23 + fi + + return 0 +} + +# Used after a verified write when credential/binding commit fails. The target +# is definitely mutated, so rollback is mandatory and uses the same verified +# writer contract. +patchnest_rollback_after_commit_failure() { + _pn_target=$1 + _pn_backup=$2 + if patchnest_attempt_verified_rollback "$_pn_target" "$_pn_backup"; then + return 0 + fi + patchnest_mark_recovery_required "postwrite_commit_and_rollback_failed" || true + return 1 +} From 37f338777601502ec5845fbb230ba18a87348c06 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:24:09 +0800 Subject: [PATCH 56/94] fix(auth): stage pending key only at destructive commit --- module/patch/superkey_safety.sh | 105 ++++++++++++++------------------ 1 file changed, 46 insertions(+), 59 deletions(-) diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh index 3d675a3..e4593f0 100644 --- a/module/patch/superkey_safety.sh +++ b/module/patch/superkey_safety.sh @@ -1,12 +1,13 @@ #!/system/bin/sh # PatchNest Public1158 superkey lifecycle. -# This file is sourced by boot_patch.sh. It never prints the key value. +# This file never prints the key value. PATCHNEST_SUPERKEY_FILE="${PATCHNEST_SUPERKEY_FILE:-/data/adb/patchnest/superkey}" PATCHNEST_SUPERKEY_PENDING_FILE="${PATCHNEST_SUPERKEY_PENDING_FILE:-/data/adb/patchnest/superkey.pending}" PATCHNEST_EXPORT_KEY_DIR="${PATCHNEST_EXPORT_KEY_DIR:-/data/adb/patchnest/export_keys}" PATCHNEST_SUPERKEY='' PATCHNEST_SUPERKEY_IS_NEW=0 +PATCHNEST_SUPERKEY_CANDIDATE='' patchnest_validate_superkey() { _pn_key=$1 @@ -46,90 +47,85 @@ patchnest_read_key_file() { patchnest_write_pending_key() { _pn_value=$1 patchnest_validate_superkey "$_pn_value" || return 1 + [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 _pn_dir=${PATCHNEST_SUPERKEY_PENDING_FILE%/*} mkdir -p "$_pn_dir" || return 1 umask 077 _pn_tmp="${PATCHNEST_SUPERKEY_PENDING_FILE}.tmp.$$" printf '%s\n' "$_pn_value" > "$_pn_tmp" || return 1 chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } - [ "$(stat -c '%a' "$_pn_tmp" 2>/dev/null)" = "600" ] || { - rm -f "$_pn_tmp" - return 1 - } - mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_PENDING_FILE" || { - rm -f "$_pn_tmp" - return 1 - } - patchnest_key_file_is_secure "$PATCHNEST_SUPERKEY_PENDING_FILE" || return 1 + mv -f "$_pn_tmp" "$PATCHNEST_SUPERKEY_PENDING_FILE" || { rm -f "$_pn_tmp"; return 1; } + _pn_check=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || return 1 + [ "$_pn_check" = "$_pn_value" ] } patchnest_prepare_superkey() { _pn_workdir=$1 PATCHNEST_SUPERKEY='' PATCHNEST_SUPERKEY_IS_NEW=0 + PATCHNEST_SUPERKEY_CANDIDATE='' if [ -e "$PATCHNEST_SUPERKEY_FILE" ]; then _pn_existing=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_FILE") || { - >&2 echo "! Existing PatchNest superkey must be a secure root-owned mode 0600 regular file" + >&2 echo "! Existing PatchNest superkey must be secure root-owned mode 0600" return 1 } - PATCHNEST_SUPERKEY=$_pn_existing - export PATCHNEST_SUPERKEY - # A committed key wins. A leftover pending file cannot be part of the - # active credential state and is safe to discard. - rm -f "$PATCHNEST_SUPERKEY_PENDING_FILE" - return 0 - fi - - # If a destructive operation was interrupted after staging a pending key, - # reuse that credential. It may already match a boot image written just - # before power loss. - if [ "${FLASH_TO_DEVICE:-false}" = "true" ] && [ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ]; then - _pn_pending=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || { - >&2 echo "! Pending PatchNest superkey is insecure or invalid" + # An active key plus unresolved pending state is not a normal starting + # point for another destructive operation. The patch path checks the + # transaction marker before calling us; refuse an orphan pending key too. + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || { + >&2 echo "! Pending PatchNest credential exists; recovery review required" return 1 } - PATCHNEST_SUPERKEY=$_pn_pending - PATCHNEST_SUPERKEY_IS_NEW=1 + PATCHNEST_SUPERKEY=$_pn_existing export PATCHNEST_SUPERKEY return 0 fi - command -v xxd >/dev/null 2>&1 || { - >&2 echo "! xxd is required to generate a Public1158 superkey" - return 1 - } - [ -r /dev/urandom ] || { - >&2 echo "! /dev/urandom is unavailable" + # Never silently reuse an orphan pending key as a new patch credential. + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || { + >&2 echo "! Pending PatchNest credential exists without committed key" return 1 } - # 24 random bytes -> 48 lowercase hexadecimal characters. This remains - # below Public1158's 0x40-byte key limit while providing 192 random bits. + command -v xxd >/dev/null 2>&1 || return 1 + [ -r /dev/urandom ] || return 1 _pn_generated=$(xxd -p -l 24 /dev/urandom 2>/dev/null | tr -d '\r\n') - patchnest_validate_superkey "$_pn_generated" || { - >&2 echo "! Generated superkey failed local validation" - return 1 - } + patchnest_validate_superkey "$_pn_generated" || return 1 - if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then - # Persist only as pending before the boot write. The active key path is - # left untouched until the written image has passed readback. - patchnest_write_pending_key "$_pn_generated" || return 1 - else - umask 077 - printf '%s\n' "$_pn_generated" > "$_pn_workdir/superkey.candidate" || return 1 - chmod 0600 "$_pn_workdir/superkey.candidate" || return 1 - fi + PATCHNEST_SUPERKEY_CANDIDATE="$_pn_workdir/superkey.candidate" + umask 077 + printf '%s\n' "$_pn_generated" > "$PATCHNEST_SUPERKEY_CANDIDATE" || return 1 + chmod 0600 "$PATCHNEST_SUPERKEY_CANDIDATE" || return 1 + _pn_candidate=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_CANDIDATE") || return 1 + [ "$_pn_candidate" = "$_pn_generated" ] || return 1 PATCHNEST_SUPERKEY=$_pn_generated PATCHNEST_SUPERKEY_IS_NEW=1 export PATCHNEST_SUPERKEY } +patchnest_stage_superkey_for_flash() { + [ -n "$PATCHNEST_SUPERKEY" ] || return 1 + [ "$PATCHNEST_SUPERKEY_IS_NEW" -eq 1 ] || return 0 + [ -n "$PATCHNEST_SUPERKEY_CANDIDATE" ] || return 1 + _pn_candidate=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_CANDIDATE") || return 1 + [ "$_pn_candidate" = "$PATCHNEST_SUPERKEY" ] || return 1 + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || return 1 + patchnest_write_pending_key "$PATCHNEST_SUPERKEY" +} + +patchnest_discard_pending_key() { + [ "$PATCHNEST_SUPERKEY_IS_NEW" -eq 1 ] || return 0 + rm -f "$PATCHNEST_SUPERKEY_PENDING_FILE" + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] +} + patchnest_superkey_sha256() { [ -n "$PATCHNEST_SUPERKEY" ] || return 1 - printf '%s' "$PATCHNEST_SUPERKEY" | sha256sum | awk '{print $1}' + _pn_sha=$(printf '%s' "$PATCHNEST_SUPERKEY" | sha256sum | awk '{print $1}') + printf '%s' "$_pn_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s\n' "$_pn_sha" } patchnest_revert_new_key_to_pending() { @@ -152,14 +148,11 @@ patchnest_commit_superkey() { return 0 fi - # For destructive writes a durable pending key already exists. Promote it - # only after the image passed readback. This makes power loss before this - # point recoverable by service.sh without weakening kernel authentication. if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then _pn_pending=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || return 1 [ "$_pn_pending" = "$PATCHNEST_SUPERKEY" ] || return 1 - [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 + mv -f "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE" || return 1 _pn_committed=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_FILE") || { patchnest_revert_new_key_to_pending @@ -170,10 +163,6 @@ patchnest_commit_superkey() { return 1 } - # The key now matches the verified boot image. Commit rollback binding - # last. If binding creation fails, move the key back to pending before - # the caller rolls the boot image back. If power fails in that window, - # the pending-key recovery handshake still reaches the written boot. command -v patchnest_commit_rollback_binding >/dev/null 2>&1 || { patchnest_revert_new_key_to_pending return 1 @@ -185,8 +174,6 @@ patchnest_commit_superkey() { return 0 fi - # Export-only image: no active device key is committed. The caller stores - # a root-only image-hash keyed credential record instead. return 0 } From 93b3bce3be750d4d872a44d4e641c0c57181fec3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:24:58 +0800 Subject: [PATCH 57/94] fix(flash): make boot patch destructive write transactional --- module/patch/boot_patch.sh | 147 +++++++++++++++---------------------- 1 file changed, 58 insertions(+), 89 deletions(-) diff --git a/module/patch/boot_patch.sh b/module/patch/boot_patch.sh index 14318f0..4c5048f 100644 --- a/module/patch/boot_patch.sh +++ b/module/patch/boot_patch.sh @@ -3,12 +3,7 @@ # PatchNest Boot Image Patcher # Transactional/fail-closed patch path derived from the APatch patching flow. ####################################################################################### -# -# Usage: -# boot_patch.sh [ARGS_PASS_TO_KPTOOLS] -# -# The second argument controls whether the generated image is flashed to the target. -# When false, the validated patched image is copied to Download only. +# Usage: boot_patch.sh [ARGS_PASS_TO_KPTOOLS] ####################################################################################### MODPATH=${0%/*} @@ -23,6 +18,8 @@ INVOCATION_CWD=$(pwd) . "$MODPATH/flash_safety.sh" # shellcheck disable=SC1091 . "$MODPATH/superkey_safety.sh" +# shellcheck disable=SC1091 +. "$MODPATH/transactional_flash.sh" BOOTIMAGE=${1:-} FLASH_TO_DEVICE=${2:-} @@ -43,11 +40,16 @@ command -v kptools >/dev/null 2>&1 || { >&2 echo "! Command kptools not found"; command -v sha256sum >/dev/null 2>&1 || { >&2 echo "! Command sha256sum not found"; exit 1; } command -v xxd >/dev/null 2>&1 || { >&2 echo "! Command xxd not found"; exit 1; } +# Never establish a new baseline while a previous destructive transaction is +# unresolved. This is intentionally checked before backup capture or key work. +if patchnest_has_unfinished_transaction; then + >&2 echo "! An unfinished PatchNest flash transaction exists" + >&2 echo "! Resolve recovery state before patching again" + exit 1 +fi + KPIMG_SOURCE="$MODULE_DIR/bin/kpimg" -[ -s "$KPIMG_SOURCE" ] || { - # Compatibility with older callers that staged kpimg in their cwd. - KPIMG_SOURCE="$INVOCATION_CWD/kpimg" -} +[ -s "$KPIMG_SOURCE" ] || KPIMG_SOURCE="$INVOCATION_CWD/kpimg" [ -s "$KPIMG_SOURCE" ] || { >&2 echo "! kpimg missing or empty"; exit 1; } WORKDIR=$(mktemp -d /data/local/tmp/patchnest_patch.XXXXXX) || { @@ -99,41 +101,28 @@ validate_boot_image() { write_verified_backup() { mkdir -p "$BACKUP_DIR" || return 1 - _pn_stamp=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) BACKUP_CANDIDATE=$(mktemp "$BACKUP_DIR/boot_backup_${_pn_stamp}_XXXXXX.img") || return 1 MANIFEST_CANDIDATE="${BACKUP_CANDIDATE%.img}.json" echo "- Capturing rollback image from $BOOT_TARGET" - if ! cat "$BOOT_TARGET" > "$BACKUP_CANDIDATE"; then - >&2 echo "! Failed to capture boot backup" - return 1 - fi + cat "$BOOT_TARGET" > "$BACKUP_CANDIDATE" || return 1 sync - [ -s "$BACKUP_CANDIDATE" ] || { >&2 echo "! Captured backup is empty"; return 1; } - - if ! validate_boot_image "$BACKUP_CANDIDATE"; then + [ -s "$BACKUP_CANDIDATE" ] || return 1 + validate_boot_image "$BACKUP_CANDIDATE" || { >&2 echo "! Backup validation failed; refusing to patch" return 1 - fi - - _pn_target_sha=$(hash_path "$BOOT_TARGET") || { - >&2 echo "! Cannot hash current boot target" - return 1 - } - _pn_backup_sha=$(hash_path "$BACKUP_CANDIDATE") || { - >&2 echo "! Cannot hash captured backup" - return 1 } + + _pn_target_sha=$(hash_path "$BOOT_TARGET") || return 1 + _pn_backup_sha=$(hash_path "$BACKUP_CANDIDATE") || return 1 [ "$_pn_target_sha" = "$_pn_backup_sha" ] || { >&2 echo "! Backup digest differs from current target" - >&2 echo "! target=$_pn_target_sha backup=$_pn_backup_sha" return 1 } _pn_backup_size=$(wc -c < "$BACKUP_CANDIDATE" 2>/dev/null | tr -d ' ') printf '%s' "$_pn_backup_size" | grep -Eq '^[1-9][0-9]*$' || return 1 - _pn_kp_state="stock" if kptools -i "$WORKDIR/kernel" -l 2>/dev/null | grep -q 'patched=true'; then _pn_kp_state="patched" @@ -144,7 +133,6 @@ write_verified_backup() { _pn_magisk=$(magisk --version 2>/dev/null | head -n 1 | cut -d: -f1) [ -n "$_pn_magisk" ] || _pn_magisk="null" fi - _pn_ksu="null" if command -v ksu >/dev/null 2>&1; then _pn_ksu=$(ksu --version 2>/dev/null | head -n 1 | tr -d '\r\n') @@ -176,7 +164,6 @@ EOF BACKUP_COMMITTED=1 echo "- Verified rollback backup: $BACKUP_CANDIDATE" echo "- Backup digest: $_pn_backup_sha" - return 0 } validate_embedded_kpms() { @@ -189,34 +176,25 @@ validate_embedded_kpms() { *) >&2 echo "! Embedded KPM path must be absolute: $_pn_kpm"; return 1 ;; esac [ -f "$_pn_kpm" ] || { >&2 echo "! Embedded KPM not found: $_pn_kpm"; return 1; } - - _pn_magic=$(xxd -l 4 -p "$_pn_kpm" 2>/dev/null) - [ "$_pn_magic" = "7f454c46" ] || { - >&2 echo "! Embedded KPM is not ELF: $_pn_kpm" - return 1 + [ "$(xxd -l 4 -p "$_pn_kpm" 2>/dev/null)" = "7f454c46" ] || { + >&2 echo "! Embedded KPM is not ELF: $_pn_kpm"; return 1; } - _pn_machine=$(xxd -s 18 -l 2 -e "$_pn_kpm" 2>/dev/null | awk '{print $2}') [ "$_pn_machine" = "000000b7" ] || { - >&2 echo "! Embedded KPM is not AArch64: $_pn_kpm" - return 1 + >&2 echo "! Embedded KPM is not AArch64: $_pn_kpm"; return 1; } - _pn_meta=$(kptools -l -M "$_pn_kpm" 2>/dev/null) || { - >&2 echo "! kptools cannot validate embedded KPM: $_pn_kpm" - return 1 + >&2 echo "! kptools cannot validate embedded KPM: $_pn_kpm"; return 1; } _pn_name=$(printf '%s\n' "$_pn_meta" | sed -n 's/^name=//p' | head -n 1) [ -n "$_pn_name" ] || { - >&2 echo "! Embedded KPM metadata has no name: $_pn_kpm" - return 1 + >&2 echo "! Embedded KPM metadata has no name: $_pn_kpm"; return 1; } echo " - verified embedded KPM: $_pn_name" fi _pn_prev=$_pn_arg done [ "$_pn_prev" != "-M" ] || { >&2 echo "! -M requires a KPM file"; return 1; } - return 0 } write_flash_receipt() { @@ -226,6 +204,7 @@ write_flash_receipt() { _pn_time=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) _pn_key_sha=$(patchnest_superkey_sha256) || return 1 mkdir -p "$PNDIR" || return 1 + umask 077 cat > "$PNDIR/last_flash.json.tmp" <&2 echo "! Failed to stage kpimg"; exit 1; } [ -s "$WORKDIR/kpimg" ] || { >&2 echo "! Staged kpimg is empty"; exit 1; } -# Public1158 KPM management is superkey-authenticated. Prepare a key before -# building the image, but do not persist a newly generated key until a device -# write has passed exact-range readback verification. +# Key generation remains private to this operation until every image/KPM check +# has passed. A persistent pending key is created only immediately before the +# first destructive target write. patchnest_prepare_superkey "$WORKDIR" || exit 1 echo "- Unpacking current boot image into private workspace" -if ! magiskboot unpack "$BOOT_TARGET" >/dev/null 2>&1; then - >&2 echo "! Unpack failed" - exit 1 -fi +magiskboot unpack "$BOOT_TARGET" >/dev/null 2>&1 || { >&2 echo "! Unpack failed"; exit 1; } [ -s kernel ] || { >&2 echo "! Unpack produced no kernel"; exit 1; } if kptools -i kernel -f 2>/dev/null | grep -q 'CONFIG_KPM=y'; then @@ -264,71 +241,63 @@ if ! kptools -i kernel -f 2>/dev/null | grep -q 'CONFIG_KALLSYMS_ALL=y'; then exit 1 fi -echo "- Validating embedded KPM arguments" validate_embedded_kpms "$@" || exit 1 -# A destructive device write always receives a fresh, target-bound rollback -# snapshot. This avoids stale/newest-by-time recovery ambiguity entirely. if [ "$FLASH_TO_DEVICE" = "true" ]; then write_verified_backup || exit 1 fi mv kernel kernel.ori - echo "- Patching kernel" -if ! kptools -p -i kernel.ori -k kpimg -s "$PATCHNEST_SUPERKEY" -o kernel "$@"; then - >&2 echo "! Kernel patch failed" - exit 1 -fi +kptools -p -i kernel.ori -k kpimg -s "$PATCHNEST_SUPERKEY" -o kernel "$@" || { + >&2 echo "! Kernel patch failed"; exit 1; +} [ -s kernel ] || { >&2 echo "! kptools produced an empty kernel"; exit 1; } echo "- Repacking boot image" -if ! magiskboot repack "$BOOT_TARGET" >/dev/null 2>&1; then - >&2 echo "! Repack failed" - exit 1 -fi +magiskboot repack "$BOOT_TARGET" >/dev/null 2>&1 || { >&2 echo "! Repack failed"; exit 1; } [ -s new-boot.img ] || { >&2 echo "! Repack produced no new-boot.img"; exit 1; } - -# Validate the complete repacked boot image before either flashing or exporting it. -echo "- Validating repacked boot image" -if ! validate_boot_image "$WORKDIR/new-boot.img"; then - >&2 echo "! Repacked boot image failed validation" - exit 1 -fi +validate_boot_image "$WORKDIR/new-boot.img" || { + >&2 echo "! Repacked boot image failed validation"; exit 1; +} if [ "$FLASH_TO_DEVICE" = "true" ]; then - echo "- Flashing with mandatory SHA-256 readback verification" - flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET" - _pn_rc=$? - if [ "$_pn_rc" -ne 0 ]; then - >&2 echo "! Flash/readback verification failed: $_pn_rc" + # Persist the candidate credential only now, immediately adjacent to the + # destructive transaction. No earlier validation failure can leave it behind. + patchnest_stage_superkey_for_flash || { + >&2 echo "! Could not stage Public1158 credential for destructive write" + exit 1 + } + + echo "- Flashing through destructive transaction + verified rollback guard" + patchnest_transactional_flash "$WORKDIR/new-boot.img" "$BOOT_TARGET" "$BACKUP_CANDIDATE" + _pn_tx_rc=$? + if [ "$_pn_tx_rc" -ne 0 ]; then + >&2 echo "! Patch transaction failed: $_pn_tx_rc" save_image_to_storage "$WORKDIR/new-boot.img" || true + case "$_pn_tx_rc" in + 21|23) touch "$MODULE_DIR/unresolved" ;; + esac exit 1 fi if ! patchnest_commit_superkey; then - >&2 echo "! Patched image verified, but superkey persistence failed" - >&2 echo "! Rolling back to the verified pre-write boot image" - flash_image "$BACKUP_CANDIDATE" "$BOOT_TARGET" - _pn_rollback_rc=$? - if [ "$_pn_rollback_rc" -ne 0 ]; then - >&2 echo "! CRITICAL: automatic rollback failed: $_pn_rollback_rc" - >&2 echo "! Verified recovery image remains at: $BACKUP_CANDIDATE" - else - echo "- Rollback verified after superkey persistence failure" + >&2 echo "! Boot write verified, but credential/binding commit failed" + if ! patchnest_rollback_after_commit_failure "$BOOT_TARGET" "$BACKUP_CANDIDATE"; then + touch "$MODULE_DIR/unresolved" fi exit 1 fi if ! write_flash_receipt "$WORKDIR/new-boot.img"; then - >&2 echo "! Flash and superkey commit succeeded, but receipt creation failed" + >&2 echo "! Flash committed safely, but evidence receipt creation failed" + touch "$MODULE_DIR/unresolved" exit 1 fi - echo "- Successfully flashed, read back, and committed Public1158 credentials" + echo "- Successfully flashed, read back, and committed Public1158 transaction" else _pn_export_key=$(patchnest_store_export_key "$WORKDIR/new-boot.img") || { - >&2 echo "! Could not store root-only credential record for exported image" - exit 1 + >&2 echo "! Could not store root-only credential record for exported image"; exit 1; } save_image_to_storage "$WORKDIR/new-boot.img" || exit 1 echo "- Successfully patched and validated" From 82897dba30d2998057f3f89ed624ee64952f0469 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:25:32 +0800 Subject: [PATCH 58/94] fix(service): bind pending credential recovery to written transaction --- module/service.sh | 58 ++++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/module/service.sh b/module/service.sh index e95d603..9168dcf 100644 --- a/module/service.sh +++ b/module/service.sh @@ -14,8 +14,6 @@ get_prop() { } KPN_CONFIG="$PNDIR/config" -# Review branch default: surface unsigned KPMs instead of silently loading -# them. Users can still choose off explicitly while developing local KPMs. KPM_SIGNATURE_POLICY=warn if [ -f "$KPN_CONFIG" ]; then _val=$(grep -E '^[[:space:]]*(export[[:space:]]+)?KPM_SIGNATURE_POLICY[[:space:]]*=' \ @@ -29,17 +27,17 @@ if [ -f "$KPN_CONFIG" ]; then fi case "$KPM_SIGNATURE_POLICY" in - off) REQUIRE_KPM_SIGNATURES=0 ;; + off) REQUIRE_KPM_SIGNATURES=0 ;; warn|strict) REQUIRE_KPM_SIGNATURES=1 ;; - *) REQUIRE_KPM_SIGNATURES=1 ;; + *) REQUIRE_KPM_SIGNATURES=1 ;; esac # shellcheck disable=SC1091 . "$MODDIR/kpm_verify.sh" 2>/dev/null || true -# Load only key-file validation/promotion helpers. This does not select a key -# or issue a supercall by itself. # shellcheck disable=SC1091 . "$MODDIR/patch/superkey_safety.sh" 2>/dev/null || true +# shellcheck disable=SC1091 +. "$MODDIR/patch/transaction_safety.sh" 2>/dev/null || true mkdir -p "$PNDIR" "$KPM_DIR/failed" "$KPM_EVENT_DIR" echo "=== $(date) service.sh started ===" > "$LOG" @@ -51,9 +49,7 @@ ROOT_MGR="unknown" if [ -f "$PNDIR/root_manager" ]; then _rm_raw="$(cat "$PNDIR/root_manager" 2>/dev/null || true)" _rm_sane="$(printf '%s' "$_rm_raw" | tr -cd 'a-z')" - if [ -n "$_rm_sane" ]; then - ROOT_MGR="$_rm_sane" - fi + [ -z "$_rm_sane" ] || ROOT_MGR="$_rm_sane" fi echo "[$(date)] root_manager=$ROOT_MGR" >> "$LOG" @@ -65,30 +61,33 @@ fi try_pending_public1158_key() { command -v patchnest_read_key_file >/dev/null 2>&1 || return 1 - command -v patchnest_key_file_is_secure >/dev/null 2>&1 || return 1 + command -v patchnest_pending_transaction_matches_written_key >/dev/null 2>&1 || return 1 - # Never override a committed key. Pending recovery is only for the - # power-loss window after a new Public1158 boot was written but before the - # pending credential could be atomically promoted. + # Pending recovery is only the crash window after a verified boot write and + # before credential/binding commit. It never overrides a committed key. [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 [ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || return 1 + [ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || return 1 _pn_pending_key=$(patchnest_read_key_file "$PATCHNEST_SUPERKEY_PENDING_FILE") || { echo "[$(date)] ERROR: pending Public1158 key is insecure or invalid" >> "$LOG" return 1 } + if ! patchnest_pending_transaction_matches_written_key "$_pn_pending_key"; then + echo "[$(date)] ERROR: pending key has no matching verified written transaction" >> "$LOG" + _pn_pending_key='' + return 1 + fi + _pn_pending_hello=$(PATCHNEST_SUPERKEY="$_pn_pending_key" kpatch hello 2>>"$LOG") _pn_pending_rc=$? _pn_pending_key='' - if [ "$_pn_pending_rc" -ne 0 ] || [ "$_pn_pending_hello" != "hello1158" ]; then echo "[$(date)] Pending Public1158 key did not authenticate the running kernel" >> "$LOG" return 1 fi - # The read-only hello proved the pending key belongs to the running kernel. - # Promote by one same-filesystem rename; do not chmod/mutate after mv. if ! mv -f "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE"; then echo "[$(date)] ERROR: authenticated pending key could not be promoted" >> "$LOG" return 1 @@ -99,20 +98,17 @@ try_pending_public1158_key() { return 1 fi - echo "[$(date)] RECOVERY: authenticated pending Public1158 key promoted after interrupted flash transaction" >> "$LOG" + echo "[$(date)] RECOVERY: pending key matched written transaction and authenticated running kernel" >> "$LOG" touch "$PNDIR/credential_recovered_pending" - # A crash before normal transaction commit means rollback binding may be - # absent. Keep unresolved visible until physical/operator review even though - # runtime authentication has been recovered. + patchnest_mark_recovery_required "credential_recovered_before_binding_commit" || true + # Runtime access has been recovered, but rollback authorization was not + # atomically committed before the crash. Keep operator review mandatory. touch "$MODDIR/unresolved" hello_out="hello1158" hello_rc=0 return 0 } -# kpatch hello is the package-level ABI readiness gate. Capture the exact echo -# and turn it into a capability profile; never infer mutation safety from a -# numeric command ID shared by multiple KernelPatch families. retries=0 max_retries=5 hello_out="" @@ -149,10 +145,6 @@ esac echo "[$(date)] kpatch hello OK: $hello_out profile=$ABI_PROFILE" >> "$LOG" printf '%s\n' "$ABI_PROFILE" > "$PNDIR/abi_profile" - -# Healthy userspace/kernel handshake. This only clears boot counters; it does -# not clear unresolved because pending-key recovery and later subsystem errors -# intentionally remain visible for operator review. echo "0" > "$PNDIR/boot_count" 2>/dev/null rm -f "$PNDIR/autorecovery_active" "$PNDIR/auto_unpatch_requested" @@ -173,10 +165,9 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do echo "[$(date)] REJECTED (strict, unsigned): $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" continue - else - echo "[$(date)] WARN (unsigned, policy=$KPM_SIGNATURE_POLICY): $(basename "$kpm") — loading anyway" >> "$LOG" - echo "unsigned:$(basename "$kpm"):$(date +%s)" >> "$PNDIR/unsigned_modules.log" fi + echo "[$(date)] WARN (unsigned, policy=$KPM_SIGNATURE_POLICY): $(basename "$kpm") — loading anyway" >> "$LOG" + echo "unsigned:$(basename "$kpm"):$(date +%s)" >> "$PNDIR/unsigned_modules.log" elif ! verify_kpm_sig "$kpm" "$_kpm_sig"; then echo "[$(date)] REJECTED (sig invalid): $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" @@ -198,7 +189,6 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi done -# 0x1100/0x1101 are rehook in Next2026 but SU grant/revoke in Public1158. if [ -n "$REHOOK" ]; then if [ "$ABI_PROFILE" = "public1158" ]; then echo "[$(date)] rehook request ignored: unsupported and unsafe on Public1158" >> "$LOG" @@ -221,7 +211,6 @@ dispatch_event() { echo "[$(date)] Event $event_name skipped: ABI $ABI_PROFILE has no reviewed event capability" >> "$LOG" return 0 fi - echo "[$(date)] Dispatching Public1158 event: $event_name" >> "$LOG" if ! kpatch event "$event_name" "PatchNest" "" >>"$LOG" 2>&1; then echo "[$(date)] ERROR: Public1158 event dispatch failed: $event_name" >> "$LOG" @@ -242,7 +231,6 @@ until [ "$(getprop sys.boot_completed)" = "1" ]; do break fi done - if [ "$(getprop sys.boot_completed)" = "1" ]; then dispatch_event "BOOT_COMPLETED" || true fi @@ -273,9 +261,7 @@ if [ -f "$CONFIG" ]; then done < "$_cfg_tmp" rm -f "$_cfg_tmp" echo "[$(date)] exclusion: applied=$excluded_count failed=$excluded_failed" >> "$LOG" - if [ "$excluded_failed" -gt 0 ]; then - touch "$MODDIR/unresolved" - fi + [ "$excluded_failed" -eq 0 ] || touch "$MODDIR/unresolved" fi echo "[$(date)] service.sh completed" >> "$LOG" From 144a6a86c1a28ae1baf655ddc269ad3bcbc6a7c0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:26:43 +0800 Subject: [PATCH 59/94] fix(restore): make unpatch transaction-bound only --- module/patch/boot_unpatch.sh | 278 +++++++++++++---------------------- 1 file changed, 105 insertions(+), 173 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 935d74c..79d035a 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -1,224 +1,156 @@ #!/system/bin/sh ####################################################################################### -# PatchNest Boot Image Unpatcher / bound-backup restorer +# PatchNest transaction-bound boot restorer ####################################################################################### # Usage: -# boot_unpatch.sh -# boot_unpatch.sh --restore-bound-backup +# boot_unpatch.sh # safe bound restore +# boot_unpatch.sh --restore-bound-backup # same, explicit +# boot_unpatch.sh --check-bound-backup # validation only, no write ####################################################################################### MODPATH=${0%/*} PNDIR="/data/adb/patchnest" BACKUP_DIR="$PNDIR/backup" AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" +RESTORE_RECEIPT="$PNDIR/last_restore.json" # shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" # shellcheck disable=SC1091 . "$MODPATH/flash_safety.sh" -RESTORE_BOUND=0 -if [ "${1:-}" = "--restore-bound-backup" ]; then - RESTORE_BOUND=1 - shift -fi +MODE=restore +case "${1:-}" in + --restore-bound-backup) MODE=restore; shift ;; + --check-bound-backup) MODE=check; shift ;; +esac BOOTIMAGE=${1:-} -[ -n "$BOOTIMAGE" ] || { >&2 echo "! BOOTIMAGE is required"; exit 1; } -[ -e "$BOOTIMAGE" ] || { >&2 echo "! $BOOTIMAGE does not exist"; exit 1; } +[ -n "$BOOTIMAGE" ] || { >&2 echo "! BOOTIMAGE is required"; exit 2; } +[ -e "$BOOTIMAGE" ] || { >&2 echo "! $BOOTIMAGE does not exist"; exit 2; } BOOT_TARGET=$(readlink -f "$BOOTIMAGE" 2>/dev/null || printf '%s' "$BOOTIMAGE") -command -v magiskboot >/dev/null 2>&1 || { >&2 echo "! Command magiskboot not found"; exit 1; } -command -v kptools >/dev/null 2>&1 || { >&2 echo "! Command kptools not found"; exit 1; } -command -v sha256sum >/dev/null 2>&1 || { >&2 echo "! Command sha256sum not found"; exit 1; } +command -v sha256sum >/dev/null 2>&1 || { >&2 echo "! sha256sum not found"; exit 1; } command -v patchnest_device_binding_sha256 >/dev/null 2>&1 || { - >&2 echo "! Transaction identity helper is unavailable" - exit 1 -} - -WORKDIR=$(mktemp -d /data/local/tmp/patchnest_unpatch.XXXXXX) || { - >&2 echo "! Cannot create private unpatch workspace" - exit 1 -} -cleanup() { - rm -rf "$WORKDIR" -} -trap cleanup EXIT HUP INT TERM - -json_string() { - key="$1" - file="$2" - grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null \ - | head -n 1 \ - | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" -} - -json_bool() { - key="$1" - file="$2" - grep -o "\"${key}\"[[:space:]]*:[[:space:]]*(true|false)" "$file" 2>/dev/null \ - | head -n 1 \ - | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" + >&2 echo "! transaction helper unavailable"; exit 1; } -json_number() { - key="$1" - file="$2" - grep -o "\"${key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$file" 2>/dev/null \ - | head -n 1 \ - | sed -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" -} +BOUND_BACKUP='' +BOUND_BACKUP_SHA='' +BOUND_BACKUP_SIZE='' +BOUND_PATCHED_SHA='' +BOUND_PATCHED_SIZE='' +BOUND_DEVICE_SHA='' +BOUND_TARGET='' -hash_target_prefix() { - target="$1" - size="$2" - printf '%s' "$size" | grep -Eq '^[1-9][0-9]*$' || return 1 - blocks=$(((size + 1048575) / 1048576)) - digest=$(dd if="$target" bs=1048576 count="$blocks" 2>/dev/null \ - | head -c "$size" \ - | sha256sum \ - | awk '{print $1}') - printf '%s' "$digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 - printf '%s\n' "$digest" -} - -# Resolve exactly the rollback image committed by the last successful verified -# PatchNest write. No mtime/lexical/newest fallback exists here. resolve_bound_backup() { - binding=${PATCHNEST_ROLLBACK_BINDING_FILE:-$PNDIR/rollback_binding.json} - [ -f "$binding" ] || { - >&2 echo "! No committed rollback transaction" - return 1 - } - [ "$(json_bool verified_readback "$binding")" = "true" ] || { - >&2 echo "! Rollback transaction has no verified readback" - return 2 - } - - recorded_target=$(json_string boot_target "$binding") - recorded_device=$(json_string device_binding_sha256 "$binding") - backup_name=$(json_string rollback_backup "$binding") - backup_sha=$(json_string rollback_backup_sha256 "$binding") - patched_sha=$(json_string patched_image_sha256 "$binding") - patched_size=$(json_number patched_image_size "$binding") - - [ "$recorded_target" = "$BOOT_TARGET" ] || { - >&2 echo "! Rollback target mismatch" - return 3 - } - printf '%s' "$recorded_device" | grep -Eq '^[0-9a-f]{64}$' || return 3 - printf '%s' "$backup_sha" | grep -Eq '^[0-9a-f]{64}$' || return 3 - printf '%s' "$patched_sha" | grep -Eq '^[0-9a-f]{64}$' || return 3 - printf '%s' "$patched_size" | grep -Eq '^[1-9][0-9]*$' || return 3 - - current_device=$(patchnest_device_binding_sha256) || { - >&2 echo "! Cannot establish current device identity" - return 4 + _pn_binding=${PATCHNEST_ROLLBACK_BINDING_FILE:-$PNDIR/rollback_binding.json} + patchnest_state_file_is_secure "$_pn_binding" || { + >&2 echo "! No secure committed rollback transaction"; return 1; } - [ "$current_device" = "$recorded_device" ] || { - >&2 echo "! Rollback binding belongs to another device/slot/target context" - return 4 + [ "$(patchnest_json_bool verified_readback "$_pn_binding")" = "true" ] || return 2 + + BOUND_TARGET=$(patchnest_json_string boot_target "$_pn_binding") + BOUND_DEVICE_SHA=$(patchnest_json_string device_binding_sha256 "$_pn_binding") + _pn_backup_name=$(patchnest_json_string rollback_backup "$_pn_binding") + BOUND_BACKUP_SHA=$(patchnest_json_string rollback_backup_sha256 "$_pn_binding") + BOUND_PATCHED_SHA=$(patchnest_json_string patched_image_sha256 "$_pn_binding") + BOUND_PATCHED_SIZE=$(patchnest_json_number patched_image_size "$_pn_binding") + + [ "$BOUND_TARGET" = "$BOOT_TARGET" ] || { >&2 echo "! Rollback target mismatch"; return 3; } + printf '%s' "$BOUND_DEVICE_SHA$BOUND_BACKUP_SHA$BOUND_PATCHED_SHA" | grep -Eq '^[0-9a-f]{192}$' || return 3 + printf '%s' "$BOUND_PATCHED_SIZE" | grep -Eq '^[1-9][0-9]*$' || return 3 + + _pn_current_device=$(patchnest_device_binding_sha256 "$BOOT_TARGET") || return 4 + [ "$_pn_current_device" = "$BOUND_DEVICE_SHA" ] || { + >&2 echo "! Rollback binding belongs to another device/slot/target context"; return 4; } - case "$backup_name" in + case "$_pn_backup_name" in boot_backup_*.img) ;; *) >&2 echo "! Unsafe rollback backup name"; return 5 ;; esac - case "$backup_name" in + case "$_pn_backup_name" in */*|*..*) >&2 echo "! Unsafe rollback backup path"; return 5 ;; esac - backup="$BACKUP_DIR/$backup_name" - [ -f "$backup" ] || { - >&2 echo "! Bound rollback backup is missing: $backup" - return 5 - } - actual_backup_sha=$(sha256sum "$backup" 2>/dev/null | awk '{print $1}') - [ "$actual_backup_sha" = "$backup_sha" ] || { - >&2 echo "! Bound rollback backup digest mismatch" - return 6 + BOUND_BACKUP="$BACKUP_DIR/$_pn_backup_name" + [ -f "$BOUND_BACKUP" ] || { >&2 echo "! Bound rollback backup missing"; return 5; } + _pn_actual_backup=$(patchnest_hash_file "$BOUND_BACKUP") || return 6 + [ "$_pn_actual_backup" = "$BOUND_BACKUP_SHA" ] || { + >&2 echo "! Bound rollback backup digest mismatch"; return 6; } + BOUND_BACKUP_SIZE=$(stat -c '%s' "$BOUND_BACKUP" 2>/dev/null) + printf '%s' "$BOUND_BACKUP_SIZE" | grep -Eq '^[1-9][0-9]*$' || return 6 - # Refuse stale rollback after an external flash or a later transaction. - current_prefix_sha=$(hash_target_prefix "$BOOT_TARGET" "$patched_size") || return 7 - [ "$current_prefix_sha" = "$patched_sha" ] || { - >&2 echo "! Current boot bytes no longer match the committed PatchNest transaction" - >&2 echo "! Refusing stale automatic rollback" + _pn_current_sha=$(patchnest_hash_prefix "$BOOT_TARGET" "$BOUND_PATCHED_SIZE") || return 7 + [ "$_pn_current_sha" = "$BOUND_PATCHED_SHA" ] || { + >&2 echo "! Current boot bytes no longer match committed PatchNest transaction" + >&2 echo "! Refusing stale/external-reflash rollback" return 7 } - - printf '%s\n' "$backup" -} - -restore_bound_backup() { - verified_backup=$(resolve_bound_backup) || return $? - - echo "- restore: transaction-bound backup: $verified_backup" - echo "- restore: target: $BOOT_TARGET" - flash_image "$verified_backup" "$BOOT_TARGET" - rc=$? - if [ "$rc" -ne 0 ]; then - >&2 echo "! restore: verified flash failed: $rc" - return 8 - fi - - patchnest_remove_rollback_binding || { - >&2 echo "! restore completed but rollback binding could not be cleared" - return 9 - } - echo "0" > "$PNDIR/boot_count" 2>/dev/null - touch "$AUTORECOVERY_MARKER" 2>/dev/null || true - echo "- restore: transaction-bound rollback verified" return 0 } -if [ "$RESTORE_BOUND" -eq 1 ]; then - restore_bound_backup - exit $? -fi - -echo "- Target image: $BOOT_TARGET" -cd "$WORKDIR" || exit 1 +write_restore_receipt() { + _pn_when=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) + mkdir -p "$PNDIR" || return 1 + umask 077 + _pn_tmp="${RESTORE_RECEIPT}.tmp.$$" + cat > "$_pn_tmp" </dev/null 2>&1; then - >&2 echo "! Unpack failed" - exit 1 -fi -[ -s kernel ] || { >&2 echo "! Unpack produced no kernel; refusing to continue"; exit 1; } +resolve_bound_backup || exit $? -if ! kptools -i kernel -l 2>/dev/null | grep -q 'patched=true'; then - echo "- Kernel is not PatchNest-patched; no unpatch required" +if [ "$MODE" = "check" ]; then + echo "- rollback transaction eligible" + echo "- target: $BOOT_TARGET" + echo "- backup: $BOUND_BACKUP" + echo "- backup_sha256: $BOUND_BACKUP_SHA" exit 0 fi -echo "- Unpatching kernel" -mv kernel kernel.patched -if ! kptools -u --image kernel.patched --out kernel; then - >&2 echo "! Unpatch failed" - exit 1 +echo "- restore: transaction-bound backup: $BOUND_BACKUP" +echo "- restore: target: $BOOT_TARGET" +flash_image "$BOUND_BACKUP" "$BOOT_TARGET" +_pn_rc=$? +if [ "$_pn_rc" -ne 0 ]; then + >&2 echo "! restore write/readback failed: $_pn_rc" + patchnest_mark_recovery_required "bound_restore_failed:${_pn_rc}" || true + exit 8 fi -[ -s kernel ] || { >&2 echo "! Unpatch produced an empty kernel"; exit 1; } -echo "- Repacking from the current target image" -if ! magiskboot repack "$BOOT_TARGET" >/dev/null 2>&1; then - >&2 echo "! Repack failed" - exit 1 -fi -[ -s new-boot.img ] || { >&2 echo "! Repack produced no new-boot.img"; exit 1; } - -echo "- Flashing unpatched boot image with readback verification" -flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET" -rc=$? -if [ "$rc" -ne 0 ]; then - >&2 echo "! Flash failed: $rc" - save_image_to_storage "$WORKDIR/new-boot.img" - exit 1 -fi +# The low-level writer already compared exact bytes. Persist the receipt before +# revoking rollback authorization so post-reboot validation can prove the boot +# target equals the exact backup that was restored. +write_restore_receipt || { + >&2 echo "! Restore verified, but restore receipt could not be committed" + patchnest_mark_recovery_required "restore_receipt_failed" || true + exit 9 +} -# The current target no longer corresponds to the previously committed patched -# transaction, so that rollback authorization must not remain live. -patchnest_remove_rollback_binding || true +patchnest_remove_rollback_binding || { + >&2 echo "! Restore verified, but rollback binding could not be cleared" + patchnest_mark_recovery_required "restore_binding_cleanup_failed" || true + exit 10 +} +patchnest_clear_pending_transaction || true +patchnest_clear_recovery_required || true +echo "0" > "$PNDIR/boot_count" 2>/dev/null +touch "$AUTORECOVERY_MARKER" 2>/dev/null || true -echo "- Flash successful" +echo "- restore: exact transaction-bound rollback verified" exit 0 From ca4d5eb00880ce7e865525cbf096668a05fe83e4 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:27:33 +0800 Subject: [PATCH 60/94] test(flash): inject destructive write and rollback failures --- tests/destructive_transaction_contract.sh | 180 ++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/destructive_transaction_contract.sh diff --git a/tests/destructive_transaction_contract.sh b/tests/destructive_transaction_contract.sh new file mode 100644 index 0000000..03c6287 --- /dev/null +++ b/tests/destructive_transaction_contract.sh @@ -0,0 +1,180 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TX="$ROOT/module/patch/transaction_safety.sh" +TF="$ROOT/module/patch/transactional_flash.sh" +SK="$ROOT/module/patch/superkey_safety.sh" +PATCH="$ROOT/module/patch/boot_patch.sh" +UNPATCH="$ROOT/module/patch/boot_unpatch.sh" + +fail() { + echo "destructive transaction contract: FAIL: $*" >&2 + exit 1 +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +export PATCHNEST_TRANSACTION_TEST=1 +export PATCHNEST_DEVICE_IDENTITY='synthetic-device-A' +export PATCHNEST_PENDING_TRANSACTION_FILE="$TMP/state/transaction.pending.json" +export PATCHNEST_RECOVERY_REQUIRED_FILE="$TMP/state/flash_recovery_required" +export PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/state/rollback_binding.json" +export PATCHNEST_SUPERKEY_FILE="$TMP/state/superkey" +export PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/state/superkey.pending" +export PATCHNEST_EXPORT_KEY_DIR="$TMP/state/export_keys" +mkdir -p "$TMP/state" "$TMP/work" + +# shellcheck disable=SC1090 +. "$TX" +# shellcheck disable=SC1090 +. "$SK" +# shellcheck disable=SC1090 +. "$TF" + +PATCHNEST_SUPERKEY='0123456789abcdef0123456789abcdef0123456789abcdef' +PATCHNEST_SUPERKEY_IS_NEW=1 +PATCHNEST_SUPERKEY_CANDIDATE="$TMP/work/superkey.candidate" +export PATCHNEST_SUPERKEY PATCHNEST_SUPERKEY_IS_NEW PATCHNEST_SUPERKEY_CANDIDATE +printf '%s\n' "$PATCHNEST_SUPERKEY" > "$PATCHNEST_SUPERKEY_CANDIDATE" +chmod 0600 "$PATCHNEST_SUPERKEY_CANDIDATE" + +SOURCE="$TMP/patched.img" +BACKUP="$TMP/boot_backup_20260808T000000Z_TEST.img" +TARGET="$TMP/boot-target.img" +printf '%s\n' 'PATCHED-IMAGE-BYTES' > "$SOURCE" +printf '%s\n' 'ORIGINAL-BOOT-BYTES' > "$BACKUP" +cp "$BACKUP" "$TARGET" + +stage_pending_key() { + printf '%s\n' "$PATCHNEST_SUPERKEY" > "$PATCHNEST_SUPERKEY_PENDING_FILE" + chmod 0600 "$PATCHNEST_SUPERKEY_PENDING_FILE" +} + +reset_state() { + rm -f "$PATCHNEST_PENDING_TRANSACTION_FILE" "$PATCHNEST_RECOVERY_REQUIRED_FILE" \ + "$PATCHNEST_ROLLBACK_BINDING_FILE" "$PATCHNEST_SUPERKEY_PENDING_FILE" + cp "$BACKUP" "$TARGET" + stage_pending_key +} + +# 1. A writer may partially mutate the target before reporting failure. +# The transaction MUST issue a second verified write using the exact backup. +reset_state +flash_calls=0 +flash_image() { + flash_calls=$((flash_calls + 1)) + if [ "$flash_calls" -eq 1 ]; then + printf '%s\n' 'PARTIAL-CORRUPTION' > "$2" + return 5 + fi + cp "$1" "$2" + return 0 +} +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 20 ] || fail "partial-write failure did not report verified rollback (rc=$rc)" +cmp -s "$TARGET" "$BACKUP" || fail "partial-write failure did not restore backup bytes" +[ "$flash_calls" -eq 2 ] || fail "rollback writer was not invoked exactly once" +[ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "pending transaction survived successful rollback" +[ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "pending key survived successful rollback" + +# 2. If the recovery write also fails, evidence MUST remain and success is forbidden. +reset_state +flash_calls=0 +flash_image() { + flash_calls=$((flash_calls + 1)) + printf '%s\n' "BROKEN-$flash_calls" > "$2" + return 5 +} +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 21 ] || fail "double write failure did not enter fatal state (rc=$rc)" +[ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "fatal transaction evidence was discarded" +[ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "fatal pending credential was discarded" +[ -e "$PATCHNEST_RECOVERY_REQUIRED_FILE" ] || fail "fatal recovery marker missing" + +# 3. Known pre-write rejection must not perform a recovery write. +reset_state +flash_calls=0 +flash_image() { + flash_calls=$((flash_calls + 1)) + return 2 +} +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 11 ] || fail "pre-write failure classification wrong (rc=$rc)" +[ "$flash_calls" -eq 1 ] || fail "pre-write failure incorrectly attempted rollback" +cmp -s "$TARGET" "$BACKUP" || fail "pre-write failure changed target" +[ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "pre-write failure left transaction state" + +# 4. If the write verifies but state cannot advance to written, target must roll back. +reset_state +( + patchnest_mark_pending_transaction_written() { return 1; } + flash_calls=0 + flash_image() { + flash_calls=$((flash_calls + 1)) + cp "$1" "$2" + return 0 + } + set +e + patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" + rc=$? + set -e + [ "$rc" -eq 22 ] || exit 41 + cmp -s "$TARGET" "$BACKUP" || exit 42 + [ "$flash_calls" -eq 2 ] || exit 43 +) || fail "verified-write/state-advance failure did not roll back" + +# 5. Successful write must leave a secure state=written transaction that binds +# the exact key/device/target/patched byte range. +reset_state +flash_image() { cp "$1" "$2"; return 0; } +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" || fail "successful transaction write failed" +[ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] \ + || fail "successful write did not advance transaction to written" +patchnest_pending_transaction_matches_written_key "$PATCHNEST_SUPERKEY" \ + || fail "written transaction does not validate exact key/target bytes" +! patchnest_pending_transaction_matches_written_key 'ffffffffffffffffffffffffffffffffffffffffffffffff' \ + || fail "written transaction accepted the wrong key" + +# Final binding must consume the written transaction rather than leaving a live +# second authorization record. +BOOT_TARGET="$TARGET" +BACKUP_CANDIDATE="$BACKUP" +WORKDIR="$TMP/work" +export BOOT_TARGET BACKUP_CANDIDATE WORKDIR +cp "$SOURCE" "$WORKDIR/new-boot.img" +patchnest_commit_rollback_binding || fail "rollback binding commit failed" +[ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "rollback binding missing" +[ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "committed binding did not clear pending transaction" + +# 6. Orphan pending credentials cannot be reused as a fresh patch identity. +rm -f "$PATCHNEST_SUPERKEY_FILE" "$PATCHNEST_PENDING_TRANSACTION_FILE" "$PATCHNEST_RECOVERY_REQUIRED_FILE" +stage_pending_key +PATCHNEST_SUPERKEY='' +PATCHNEST_SUPERKEY_IS_NEW=0 +set +e +patchnest_prepare_superkey "$TMP/work" +rc=$? +set -e +[ "$rc" -ne 0 ] || fail "orphan pending key was accepted as a new patch credential" + +# Structural invariants for the production entry points. +grep -Fq 'patchnest_has_unfinished_transaction' "$PATCH" || fail "patch does not block unfinished transaction" +grep -Fq 'patchnest_stage_superkey_for_flash' "$PATCH" || fail "pending key is not staged adjacent to destructive write" +grep -Fq 'patchnest_transactional_flash' "$PATCH" || fail "patch bypasses high-level transaction writer" +grep -Fq 'patchnest_rollback_after_commit_failure' "$PATCH" || fail "post-write commit failure has no mandatory rollback" +grep -Fq -- '--check-bound-backup' "$UNPATCH" || fail "read-only bound validator missing" +! grep -Fq 'verified_backup=$(resolve_bound_backup)' "$UNPATCH" || fail "rollback metadata still crosses command-substitution subshell" +! grep -Fq 'kptools -u --image' "$UNPATCH" || fail "release unpatch still has a second destructive live-unpatch implementation" + +echo "destructive transaction contract: PASS" From 8d86e84db23609b783c271b9b5d97a45083d1345 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:27:47 +0800 Subject: [PATCH 61/94] ci(flash): run destructive failure-injection contract --- .github/workflows/flash-safety.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 478733e..e6e91aa 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -12,17 +12,20 @@ on: - 'scripts/device_validation.sh' - 'version.properties' - 'tests/flash_safety_contract.sh' + - 'tests/destructive_transaction_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' push: branches: - 'review/flash-readiness-hardening' + - 'review/flash-readiness-final' paths: - 'module/patch/**' - 'module/service.sh' - 'scripts/device_validation.sh' - 'version.properties' - 'tests/flash_safety_contract.sh' + - 'tests/destructive_transaction_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' workflow_dispatch: @@ -43,7 +46,7 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh scripts/device_validation.sh tests/flash_safety_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh scripts/device_validation.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -56,16 +59,23 @@ jobs: module/patch/boot_unpatch.sh \ module/patch/flash_safety.sh \ module/patch/transaction_safety.sh \ + module/patch/transactional_flash.sh \ module/patch/superkey_safety.sh \ module/service.sh \ scripts/device_validation.sh \ tests/flash_safety_contract.sh \ + tests/destructive_transaction_contract.sh \ tests/runtime_abi_contract.sh - - name: Run transactional flash contract + - name: Run baseline flash contract env: PATCHNEST_TRANSACTION_TEST: '1' run: sh tests/flash_safety_contract.sh + - name: Run destructive failure-injection contract + env: + PATCHNEST_TRANSACTION_TEST: '1' + run: sh tests/destructive_transaction_contract.sh + - name: Run runtime ABI contract run: sh tests/runtime_abi_contract.sh From 9df238e79336516cc10c4320cf9ab128b647f6f7 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:29:36 +0800 Subject: [PATCH 62/94] test(device): close rollback negative and post-restore evidence loop --- scripts/device_validation.sh | 191 +++++++++++++++++------------------ 1 file changed, 93 insertions(+), 98 deletions(-) diff --git a/scripts/device_validation.sh b/scripts/device_validation.sh index 523625c..ee42e2f 100644 --- a/scripts/device_validation.sh +++ b/scripts/device_validation.sh @@ -16,14 +16,8 @@ TARGET='' mkdir -p "$EVIDENCE" || exit 1 chmod 0700 "$EVIDENCE" 2>/dev/null || true -log() { - printf '%s\n' "$*" | tee -a "$LOG" -} - -fail() { - log "FAIL: $*" - exit 1 -} +log() { printf '%s\n' "$*" | tee -a "$LOG"; } +fail() { log "FAIL: $*"; exit 1; } record_cmd() { _pn_name=$1 @@ -53,20 +47,24 @@ json_number() { | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" } +json_bool() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" +} + hash_prefix() { _pn_target=$1 _pn_size=$2 printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 _pn_blocks=$(((_pn_size + 1048575) / 1048576)) dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ - | head -c "$_pn_size" \ - | sha256sum \ - | awk '{print $1}' + | head -c "$_pn_size" | sha256sum | awk '{print $1}' } -require_root() { - [ "$(id -u 2>/dev/null)" = "0" ] || fail "root shell required" -} +require_root() { [ "$(id -u 2>/dev/null)" = "0" ] || fail "root shell required"; } require_module_tree() { [ -d "$MODDIR" ] || fail "module directory missing: $MODDIR" @@ -74,6 +72,7 @@ require_module_tree() { [ -x "$MODDIR/bin/kptools" ] || fail "kptools missing" [ -x "$MODDIR/bin/magiskboot" ] || fail "magiskboot missing" [ -f "$MODDIR/patch/transaction_safety.sh" ] || fail "transaction helper missing" + [ -x "$MODDIR/patch/boot_unpatch.sh" ] || fail "bound restore helper missing" } resolve_target() { @@ -107,7 +106,10 @@ collect_common() { copy_if_present "$MODDIR/module.prop" "module.prop" copy_if_present "$MODDIR/provenance/kpatch-public1158.json" "kpatch-public1158.json" copy_if_present "$PNDIR/last_flash.json" "last_flash.json" + copy_if_present "$PNDIR/last_restore.json" "last_restore.json" copy_if_present "$PNDIR/rollback_binding.json" "rollback_binding.json" + copy_if_present "$PNDIR/transaction.pending.json" "transaction.pending.json" + copy_if_present "$PNDIR/flash_recovery_required" "flash_recovery_required" copy_if_present "$PNDIR/abi_profile" "abi_profile" copy_if_present "$PNDIR/service.log" "service.log" @@ -118,93 +120,34 @@ collect_common() { else log "superkey_present=0" fi - - if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then - log "flash_review_blocked=1" - else - log "flash_review_blocked=0" - fi + [ -f "$PNDIR/superkey.pending" ] && log "pending_superkey_present=1" || log "pending_superkey_present=0" + [ -f "$PNDIR/transaction.pending.json" ] && log "pending_transaction_present=1" || log "pending_transaction_present=0" + [ -f "$PNDIR/flash_recovery_required" ] && log "recovery_required=1" || log "recovery_required=0" + [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ] && log "flash_review_blocked=1" || log "flash_review_blocked=0" } validate_target_unpack() { _pn_tmp=$(mktemp -d /data/local/tmp/patchnest-device-unpack.XXXXXX) || fail "cannot create unpack workspace" if ! (cd "$_pn_tmp" && "$MODDIR/bin/magiskboot" unpack "$TARGET" >/dev/null 2>&1); then - rm -rf "$_pn_tmp" - fail "magiskboot cannot unpack resolved target" + rm -rf "$_pn_tmp"; fail "magiskboot cannot unpack resolved target" fi - [ -s "$_pn_tmp/kernel" ] || { - rm -rf "$_pn_tmp" - fail "resolved target unpack produced no kernel" - } - PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$_pn_tmp/kernel" -l \ - > "$EVIDENCE/kernel-info.txt" 2>&1 || true + [ -s "$_pn_tmp/kernel" ] || { rm -rf "$_pn_tmp"; fail "resolved target unpack produced no kernel"; } + PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$_pn_tmp/kernel" -l > "$EVIDENCE/kernel-info.txt" 2>&1 || true rm -rf "$_pn_tmp" } -load_transaction_context() { - # The installed helpers intentionally key device identity to BOOT_TARGET and - # discover transaction_safety.sh through MODPATH. Map validation state onto - # those exact production variable names before sourcing the reviewed code. - MODPATH="$MODDIR/patch" - BOOT_TARGET="$TARGET" - export MODPATH BOOT_TARGET - # shellcheck disable=SC1090 - . "$MODDIR/patch/util_functions.sh" - # shellcheck disable=SC1090 - . "$MODDIR/patch/flash_safety.sh" - command -v patchnest_device_binding_sha256 >/dev/null 2>&1 \ - || fail "transaction identity helper was not loaded" -} - -validate_binding_read_only() { - _pn_binding="$PNDIR/rollback_binding.json" - [ -f "$_pn_binding" ] || fail "rollback binding is missing" - - _pn_recorded_target=$(json_string boot_target "$_pn_binding") - _pn_recorded_device=$(json_string device_binding_sha256 "$_pn_binding") - _pn_backup_name=$(json_string rollback_backup "$_pn_binding") - _pn_backup_sha=$(json_string rollback_backup_sha256 "$_pn_binding") - _pn_patched_sha=$(json_string patched_image_sha256 "$_pn_binding") - _pn_patched_size=$(json_number patched_image_size "$_pn_binding") - - [ "$_pn_recorded_target" = "$TARGET" ] || fail "binding target mismatch" - printf '%s' "$_pn_recorded_device" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid device binding digest" - printf '%s' "$_pn_backup_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid rollback digest" - printf '%s' "$_pn_patched_sha" | grep -Eq '^[0-9a-f]{64}$' || fail "invalid patched digest" - printf '%s' "$_pn_patched_size" | grep -Eq '^[1-9][0-9]*$' || fail "invalid patched size" - - load_transaction_context - _pn_current_device=$(patchnest_device_binding_sha256) || fail "cannot derive device binding" - [ "$_pn_current_device" = "$_pn_recorded_device" ] || fail "device binding mismatch" - - case "$_pn_backup_name" in - boot_backup_*.img) ;; - *) fail "unsafe backup name in rollback binding" ;; - esac - case "$_pn_backup_name" in - */*|*..*) fail "unsafe backup path in rollback binding" ;; - esac - - _pn_backup="$PNDIR/backup/$_pn_backup_name" - [ -f "$_pn_backup" ] || fail "rollback backup missing" - [ "$(sha256sum "$_pn_backup" | awk '{print $1}')" = "$_pn_backup_sha" ] \ - || fail "rollback backup SHA mismatch" - - _pn_current_sha=$(hash_prefix "$TARGET" "$_pn_patched_size") \ - || fail "cannot hash current patched byte range" - [ "$_pn_current_sha" = "$_pn_patched_sha" ] \ - || fail "current boot no longer matches committed patched transaction" - - log "rollback_binding_eligible=1" - log "rollback_backup=$_pn_backup_name" +run_production_rollback_check() { + _pn_binding=${1:-$PNDIR/rollback_binding.json} + PATCHNEST_ROLLBACK_BINDING_FILE="$_pn_binding" \ + PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_unpatch.sh" --check-bound-backup "$TARGET" } finalize() { _pn_bundle="/storage/emulated/0/Download/PatchNest_Device_Evidence_${STAMP}_${MODE}.tar.gz" if [ -d /storage/emulated/0/Download ] && command -v tar >/dev/null 2>&1; then tar -czf "$_pn_bundle" -C "${EVIDENCE%/*}" "${EVIDENCE##*/}" 2>/dev/null \ - && log "evidence_bundle=$_pn_bundle" \ - || log "evidence_bundle_failed=1" + && log "evidence_bundle=$_pn_bundle" || log "evidence_bundle_failed=1" fi log "evidence_dir=$EVIDENCE" } @@ -220,6 +163,8 @@ case "$MODE" in record_cmd "kpatch file digest" sha256sum "$MODDIR/bin/kpatch" || true record_cmd "kptools file digest" sha256sum "$MODDIR/bin/kptools" || true record_cmd "kpimg file digest" sha256sum "$MODDIR/bin/kpimg" || true + [ ! -f "$PNDIR/transaction.pending.json" ] || fail "unfinished flash transaction already exists" + [ ! -f "$PNDIR/flash_recovery_required" ] || fail "flash recovery is already required" if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then log "result=REVIEW_PACKAGE_INTENTIONALLY_BLOCKED" else @@ -237,28 +182,82 @@ case "$MODE" in record_cmd "kpm list" env PATH="$MODDIR/bin:$PATH" kpatch kpm list || fail "kpm list failed" [ -f "$PNDIR/superkey" ] || fail "superkey was not committed" [ "$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null)" = "600" ] || fail "superkey permissions are not 0600" - validate_binding_read_only + [ ! -e "$PNDIR/superkey.pending" ] || fail "unexpected pending credential after healthy boot" + [ ! -e "$PNDIR/transaction.pending.json" ] || fail "unexpected pending transaction after healthy boot" + [ ! -e "$PNDIR/flash_recovery_required" ] || fail "recovery marker present after healthy boot" + run_production_rollback_check >> "$LOG" 2>&1 || fail "production rollback validator rejected live transaction" [ ! -f "$MODDIR/unresolved" ] || fail "module runtime marked unresolved" log "result=POSTBOOT_PASS" ;; rollback-check) - validate_binding_read_only + run_production_rollback_check >> "$LOG" 2>&1 || fail "production rollback validator rejected live transaction" log "result=ROLLBACK_ELIGIBLE" ;; + rollback-negative) + run_production_rollback_check >> "$LOG" 2>&1 || fail "real binding must be eligible before negative tests" + _pn_real="$PNDIR/rollback_binding.json" + [ -f "$_pn_real" ] || fail "rollback binding missing" + _pn_foreign="$EVIDENCE/rollback.foreign-device.json" + _pn_stale="$EVIDENCE/rollback.stale-bytes.json" + _pn_zero='0000000000000000000000000000000000000000000000000000000000000000' + _pn_ff='ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + + awk -v v="$_pn_zero" '{ + if ($0 ~ /"device_binding_sha256"/) print " \"device_binding_sha256\": \"" v "\","; + else print $0 + }' "$_pn_real" > "$_pn_foreign" + chmod 0600 "$_pn_foreign" + if run_production_rollback_check "$_pn_foreign" >> "$LOG" 2>&1; then + fail "foreign-device binding copy was incorrectly accepted" + fi + + awk -v v="$_pn_ff" '{ + if ($0 ~ /"patched_image_sha256"/) print " \"patched_image_sha256\": \"" v "\","; + else print $0 + }' "$_pn_real" > "$_pn_stale" + chmod 0600 "$_pn_stale" + if run_production_rollback_check "$_pn_stale" >> "$LOG" 2>&1; then + fail "stale-byte binding copy was incorrectly accepted" + fi + log "result=ROLLBACK_NEGATIVE_PASS" + ;; + restore) [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "RESTORE_BOUND_BACKUP" ] \ || fail "restore requires PATCHNEST_DEVICE_TEST_UNLOCK=RESTORE_BOUND_BACKUP" - validate_binding_read_only + run_production_rollback_check >> "$LOG" 2>&1 || fail "rollback not eligible before destructive restore" cp "$PNDIR/rollback_binding.json" "$EVIDENCE/rollback_binding.before-restore.json" log "destructive_restore=START" - PATH="$MODDIR/bin:$PATH" "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$TARGET" \ - >> "$LOG" 2>&1 || fail "bound-backup restore failed" + PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$TARGET" >> "$LOG" 2>&1 \ + || fail "bound-backup restore failed" + [ -f "$PNDIR/last_restore.json" ] || fail "restore receipt missing" log "destructive_restore=PASS" log "result=RESTORE_WRITE_VERIFIED_REBOOT_REQUIRED" ;; + postrestore) + [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android did not reach boot_completed after restore" + _pn_receipt="$PNDIR/last_restore.json" + [ -f "$_pn_receipt" ] || fail "last_restore.json missing" + [ "$(json_bool verified_readback "$_pn_receipt")" = "true" ] || fail "restore receipt is not readback-qualified" + _pn_receipt_target=$(json_string boot_target "$_pn_receipt") + _pn_receipt_device=$(json_string device_binding_sha256 "$_pn_receipt") + _pn_restore_sha=$(json_string restored_backup_sha256 "$_pn_receipt") + _pn_restore_size=$(json_number restored_backup_size "$_pn_receipt") + [ "$_pn_receipt_target" = "$TARGET" ] || fail "postrestore target differs from receipt" + printf '%s' "$_pn_receipt_device$_pn_restore_sha" | grep -Eq '^[0-9a-f]{128}$' || fail "restore receipt digest fields invalid" + printf '%s' "$_pn_restore_size" | grep -Eq '^[1-9][0-9]*$' || fail "restore receipt size invalid" + _pn_now=$(hash_prefix "$TARGET" "$_pn_restore_size") || fail "cannot hash restored boot byte range" + [ "$_pn_now" = "$_pn_restore_sha" ] || fail "current boot bytes differ from restored backup receipt" + [ ! -e "$PNDIR/rollback_binding.json" ] || fail "rollback authorization still live after restore" + [ ! -e "$PNDIR/transaction.pending.json" ] || fail "pending transaction survived restore" + [ ! -e "$PNDIR/flash_recovery_required" ] || fail "recovery marker survived successful restore" + log "result=POSTRESTORE_PASS" + ;; + kpm-cycle) _pn_candidate=${1:-} [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "KPM_CYCLE" ] \ @@ -270,20 +269,16 @@ case "$MODE" in [ -n "$_pn_name" ] || fail "candidate KPM has no name" log "kpm_candidate=$_pn_candidate" log "kpm_name=$_pn_name" - PATH="$MODDIR/bin:$PATH" kpatch kpm load "$_pn_candidate" >> "$LOG" 2>&1 \ - || fail "KPM load failed" + PATH="$MODDIR/bin:$PATH" kpatch kpm load "$_pn_candidate" >> "$LOG" 2>&1 || fail "KPM load failed" PATH="$MODDIR/bin:$PATH" kpatch kpm info "$_pn_name" >> "$LOG" 2>&1 || { PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || true fail "KPM info failed after load" } - PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 \ - || fail "KPM unload failed" + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || fail "KPM unload failed" log "result=KPM_CYCLE_PASS" ;; - *) - fail "unknown mode: $MODE" - ;; + *) fail "unknown mode: $MODE" ;; esac finalize From 81e4cd922173e561103f8d33989420009a14a418 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:31:09 +0800 Subject: [PATCH 63/94] fix(recovery): require written transaction and rebuild binding after crash --- module/patch/transaction_safety.sh | 120 ++++++++++++++++++----------- 1 file changed, 75 insertions(+), 45 deletions(-) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index 84dd9aa..edb0a8f 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -5,6 +5,7 @@ PATCHNEST_ROLLBACK_BINDING_FILE="${PATCHNEST_ROLLBACK_BINDING_FILE:-/data/adb/patchnest/rollback_binding.json}" PATCHNEST_PENDING_TRANSACTION_FILE="${PATCHNEST_PENDING_TRANSACTION_FILE:-/data/adb/patchnest/transaction.pending.json}" PATCHNEST_RECOVERY_REQUIRED_FILE="${PATCHNEST_RECOVERY_REQUIRED_FILE:-/data/adb/patchnest/flash_recovery_required}" +PATCHNEST_BACKUP_DIR="${PATCHNEST_BACKUP_DIR:-/data/adb/patchnest/backup}" patchnest_json_escape() { printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' @@ -65,9 +66,7 @@ patchnest_hash_prefix() { printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 _pn_blocks=$(((_pn_size + 1048575) / 1048576)) _pn_digest=$(dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ - | head -c "$_pn_size" \ - | sha256sum \ - | awk '{print $1}') + | head -c "$_pn_size" | sha256sum | awk '{print $1}') printf '%s' "$_pn_digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 printf '%s\n' "$_pn_digest" } @@ -86,7 +85,6 @@ patchnest_device_binding_sha256() { _pn_slot=$(getprop ro.boot.slot_suffix 2>/dev/null | tr -d '\r\n') _pn_identity="$_pn_serial|$_pn_product|$_pn_vbmeta|$_pn_slot" fi - _pn_digest=$(printf '%s' "$_pn_identity|$_pn_bind_target" | sha256sum | awk '{print $1}') printf '%s' "$_pn_digest" | grep -Eq '^[0-9a-f]{64}$' || return 1 printf '%s\n' "$_pn_digest" @@ -123,7 +121,6 @@ patchnest_stage_pending_transaction() { _pn_source=$1 _pn_target=$2 _pn_backup=$3 - [ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || return 1 [ ! -e "$PATCHNEST_RECOVERY_REQUIRED_FILE" ] || return 1 [ -f "$_pn_source" ] || return 1 @@ -136,15 +133,9 @@ patchnest_stage_pending_transaction() { _pn_device_sha=$(patchnest_device_binding_sha256 "$_pn_target") || return 1 _pn_key_sha=$(patchnest_superkey_sha256 2>/dev/null) || return 1 _pn_backup_name=$(basename "$_pn_backup") - printf '%s' "$_pn_source_size" | grep -Eq '^[1-9][0-9]*$' || return 1 - case "$_pn_backup_name" in - boot_backup_*.img) ;; - *) return 1 ;; - esac - case "$_pn_backup_name" in - */*|*..*) return 1 ;; - esac + case "$_pn_backup_name" in boot_backup_*.img) ;; *) return 1 ;; esac + case "$_pn_backup_name" in */*|*..*) return 1 ;; esac _pn_dir=${PATCHNEST_PENDING_TRANSACTION_FILE%/*} mkdir -p "$_pn_dir" || return 1 @@ -172,7 +163,6 @@ EOF patchnest_mark_pending_transaction_written() { patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" || return 1 [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "prepared" ] || return 1 - _pn_target=$(patchnest_json_string boot_target "$PATCHNEST_PENDING_TRANSACTION_FILE") _pn_sha=$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") _pn_size=$(patchnest_json_number patched_image_size "$PATCHNEST_PENDING_TRANSACTION_FILE") @@ -192,7 +182,6 @@ patchnest_pending_transaction_matches_written_key() { _pn_key=$1 patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" || return 1 [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] || return 1 - _pn_target=$(patchnest_json_string boot_target "$PATCHNEST_PENDING_TRANSACTION_FILE") _pn_device=$(patchnest_json_string device_binding_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") _pn_sha=$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") @@ -201,7 +190,6 @@ patchnest_pending_transaction_matches_written_key() { [ -e "$_pn_target" ] || return 1 printf '%s' "$_pn_device$_pn_sha$_pn_key_sha" | grep -Eq '^[0-9a-f]{192}$' || return 1 printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 - _pn_actual_key=$(printf '%s' "$_pn_key" | sha256sum | awk '{print $1}') [ "$_pn_actual_key" = "$_pn_key_sha" ] || return 1 _pn_actual_device=$(patchnest_device_binding_sha256 "$_pn_target") || return 1 @@ -210,6 +198,40 @@ patchnest_pending_transaction_matches_written_key() { [ "$_pn_actual_sha" = "$_pn_sha" ] } +patchnest_write_binding_record() { + _pn_target=$1 + _pn_device_sha=$2 + _pn_backup_name=$3 + _pn_backup_sha=$4 + _pn_patched_sha=$5 + _pn_patched_size=$6 + _pn_key_sha=$7 + _pn_recovered=${8:-false} + + _pn_dir=${PATCHNEST_ROLLBACK_BINDING_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_ROLLBACK_BINDING_FILE}.tmp.$$" + cat > "$_pn_tmp" </dev/null || date +%Y-%m-%dT%H:%M:%S)" +} +EOF + chmod 0600 "$_pn_tmp" || { rm -f "$_pn_tmp"; return 1; } + mv -f "$_pn_tmp" "$PATCHNEST_ROLLBACK_BINDING_FILE" || { rm -f "$_pn_tmp"; return 1; } + patchnest_state_file_is_secure "$PATCHNEST_ROLLBACK_BINDING_FILE" +} + patchnest_commit_rollback_binding() { [ -n "${BOOT_TARGET:-}" ] || return 1 [ -n "${BACKUP_CANDIDATE:-}" ] || return 1 @@ -218,14 +240,8 @@ patchnest_commit_rollback_binding() { [ -f "$WORKDIR/new-boot.img" ] || return 1 _pn_backup_name=$(basename "$BACKUP_CANDIDATE") - case "$_pn_backup_name" in - boot_backup_*.img) ;; - *) return 1 ;; - esac - case "$_pn_backup_name" in - */*|*..*) return 1 ;; - esac - + case "$_pn_backup_name" in boot_backup_*.img) ;; *) return 1 ;; esac + case "$_pn_backup_name" in */*|*..*) return 1 ;; esac _pn_backup_sha=$(patchnest_hash_file "$BACKUP_CANDIDATE") || return 1 _pn_patched_sha=$(patchnest_hash_file "$WORKDIR/new-boot.img") || return 1 _pn_patched_size=$(stat -c '%s' "$WORKDIR/new-boot.img" 2>/dev/null) @@ -233,37 +249,51 @@ patchnest_commit_rollback_binding() { _pn_key_sha=$(patchnest_superkey_sha256 2>/dev/null) || return 1 printf '%s' "$_pn_patched_size" | grep -Eq '^[1-9][0-9]*$' || return 1 - if [ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ]; then + if [ "${FLASH_TO_DEVICE:-false}" = "true" ]; then patchnest_state_file_is_secure "$PATCHNEST_PENDING_TRANSACTION_FILE" || return 1 [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] || return 1 [ "$(patchnest_json_string boot_target "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$BOOT_TARGET" ] || return 1 + [ "$(patchnest_json_string device_binding_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_device_sha" ] || return 1 [ "$(patchnest_json_string rollback_backup_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_backup_sha" ] || return 1 [ "$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_patched_sha" ] || return 1 + [ "$(patchnest_json_number patched_image_size "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_patched_size" ] || return 1 [ "$(patchnest_json_string superkey_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_key_sha" ] || return 1 fi - _pn_when=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%dT%H:%M:%S) - _pn_dir=${PATCHNEST_ROLLBACK_BINDING_FILE%/*} - mkdir -p "$_pn_dir" || return 1 - umask 077 - _pn_tmp="${PATCHNEST_ROLLBACK_BINDING_FILE}.tmp.$$" - cat > "$_pn_tmp" < Date: Sat, 8 Aug 2026 14:32:59 +0800 Subject: [PATCH 64/94] fix(recovery): rebuild rollback binding after pending key recovery --- module/service.sh | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/module/service.sh b/module/service.sh index 9168dcf..0c77726 100644 --- a/module/service.sh +++ b/module/service.sh @@ -20,9 +20,9 @@ if [ -f "$KPN_CONFIG" ]; then "$KPN_CONFIG" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//' | tr -d '"\r\n' | tr 'A-Z' 'a-z') case "$_val" in off|warn|strict) KPM_SIGNATURE_POLICY="$_val" ;; - 0|false) KPM_SIGNATURE_POLICY=off ;; - 1|true|yes|on) KPM_SIGNATURE_POLICY=strict ;; - *) KPM_SIGNATURE_POLICY=warn ;; + 0|false) KPM_SIGNATURE_POLICY=off ;; + 1|true|yes|on) KPM_SIGNATURE_POLICY=strict ;; + *) KPM_SIGNATURE_POLICY=warn ;; esac fi @@ -62,9 +62,10 @@ fi try_pending_public1158_key() { command -v patchnest_read_key_file >/dev/null 2>&1 || return 1 command -v patchnest_pending_transaction_matches_written_key >/dev/null 2>&1 || return 1 + command -v patchnest_commit_binding_from_pending_written >/dev/null 2>&1 || return 1 - # Pending recovery is only the crash window after a verified boot write and - # before credential/binding commit. It never overrides a committed key. + # This is only the crash window after a verified boot write but before the + # credential/binding commit. A committed key is never replaced here. [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 [ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || return 1 [ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || return 1 @@ -82,28 +83,42 @@ try_pending_public1158_key() { _pn_pending_hello=$(PATCHNEST_SUPERKEY="$_pn_pending_key" kpatch hello 2>>"$LOG") _pn_pending_rc=$? - _pn_pending_key='' if [ "$_pn_pending_rc" -ne 0 ] || [ "$_pn_pending_hello" != "hello1158" ]; then echo "[$(date)] Pending Public1158 key did not authenticate the running kernel" >> "$LOG" + _pn_pending_key='' return 1 fi + # The exact key/device/target/patched-byte tuple is now proven twice: by the + # pending transaction hash checks and by the kernel hello authentication. + # Promote the credential, then reconstruct rollback authorization from that + # same durable written transaction before allowing normal runtime mutation. if ! mv -f "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE"; then echo "[$(date)] ERROR: authenticated pending key could not be promoted" >> "$LOG" + _pn_pending_key='' return 1 fi if ! patchnest_key_file_is_secure "$PATCHNEST_SUPERKEY_FILE"; then mv -f "$PATCHNEST_SUPERKEY_FILE" "$PATCHNEST_SUPERKEY_PENDING_FILE" 2>/dev/null || true echo "[$(date)] ERROR: promoted Public1158 key failed security verification" >> "$LOG" + _pn_pending_key='' return 1 fi - echo "[$(date)] RECOVERY: pending key matched written transaction and authenticated running kernel" >> "$LOG" + if ! patchnest_commit_binding_from_pending_written "$_pn_pending_key"; then + # Keep the active credential: it is the only authenticated access to the + # already-running patched kernel. But do not load KPMs or apply other + # mutations without a valid rollback authorization. + echo "[$(date)] ERROR: pending key recovered, but rollback binding reconstruction failed" >> "$LOG" + patchnest_mark_recovery_required "pending_key_promoted_binding_recovery_failed" || true + touch "$MODDIR/unresolved" + _pn_pending_key='' + return 1 + fi + + _pn_pending_key='' + echo "[$(date)] RECOVERY: authenticated pending key promoted and rollback binding reconstructed" >> "$LOG" touch "$PNDIR/credential_recovered_pending" - patchnest_mark_recovery_required "credential_recovered_before_binding_commit" || true - # Runtime access has been recovered, but rollback authorization was not - # atomically committed before the crash. Keep operator review mandatory. - touch "$MODDIR/unresolved" hello_out="hello1158" hello_rc=0 return 0 From 43c8d0c269e205d4422b75681fb86017ef35327d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:33:32 +0800 Subject: [PATCH 65/94] fix(restore): retry exact backup after possible partial write --- module/patch/boot_unpatch.sh | 43 +++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 79d035a..fb3e513 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -113,6 +113,35 @@ EOF patchnest_state_file_is_secure "$RESTORE_RECEIPT" } +restore_exact_backup_with_retry() { + flash_image "$BOUND_BACKUP" "$BOOT_TARGET" + _pn_first=$? + [ "$_pn_first" -eq 0 ] && return 0 + + case "$_pn_first" in + 1|2|3|4|7) + # These statuses are emitted before the writer starts copying bytes. + >&2 echo "! restore rejected before target mutation: $_pn_first" + patchnest_mark_recovery_required "bound_restore_prewrite_failed:${_pn_first}" || true + return "$_pn_first" + ;; + *) + # 5/6 (and unknown future statuses) may mean target bytes were + # already changed. Retry the exact same verified backup once. + >&2 echo "! restore may have partially touched target (rc=$_pn_first); retrying exact backup" + flash_image "$BOUND_BACKUP" "$BOOT_TARGET" + _pn_retry=$? + if [ "$_pn_retry" -eq 0 ]; then + echo "- restore retry passed exact-range readback" + return 0 + fi + >&2 echo "! CRITICAL: restore retry failed: $_pn_retry" + patchnest_mark_recovery_required "bound_restore_retry_failed:first=${_pn_first},retry=${_pn_retry}" || true + return 8 + ;; + esac +} + resolve_bound_backup || exit $? if [ "$MODE" = "check" ]; then @@ -125,17 +154,11 @@ fi echo "- restore: transaction-bound backup: $BOUND_BACKUP" echo "- restore: target: $BOOT_TARGET" -flash_image "$BOUND_BACKUP" "$BOOT_TARGET" -_pn_rc=$? -if [ "$_pn_rc" -ne 0 ]; then - >&2 echo "! restore write/readback failed: $_pn_rc" - patchnest_mark_recovery_required "bound_restore_failed:${_pn_rc}" || true - exit 8 -fi +restore_exact_backup_with_retry || exit 8 -# The low-level writer already compared exact bytes. Persist the receipt before -# revoking rollback authorization so post-reboot validation can prove the boot -# target equals the exact backup that was restored. +# The low-level writer compared the exact bytes. Persist the receipt before +# revoking rollback authorization so post-reboot validation can prove the target +# equals the exact transaction-bound backup. write_restore_receipt || { >&2 echo "! Restore verified, but restore receipt could not be committed" patchnest_mark_recovery_required "restore_receipt_failed" || true From 865d5f9a9daa61d189addf4ac66563d3abd60455 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:34:42 +0800 Subject: [PATCH 66/94] test(flash): cover crash binding recovery and restore retry --- tests/destructive_transaction_contract.sh | 107 ++++++++++++++++++++-- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/tests/destructive_transaction_contract.sh b/tests/destructive_transaction_contract.sh index 03c6287..2523823 100644 --- a/tests/destructive_transaction_contract.sh +++ b/tests/destructive_transaction_contract.sh @@ -24,6 +24,7 @@ export PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/state/rollback_binding.json" export PATCHNEST_SUPERKEY_FILE="$TMP/state/superkey" export PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/state/superkey.pending" export PATCHNEST_EXPORT_KEY_DIR="$TMP/state/export_keys" +export PATCHNEST_BACKUP_DIR="$TMP" mkdir -p "$TMP/state" "$TMP/work" # shellcheck disable=SC1090 @@ -54,7 +55,7 @@ stage_pending_key() { reset_state() { rm -f "$PATCHNEST_PENDING_TRANSACTION_FILE" "$PATCHNEST_RECOVERY_REQUIRED_FILE" \ - "$PATCHNEST_ROLLBACK_BINDING_FILE" "$PATCHNEST_SUPERKEY_PENDING_FILE" + "$PATCHNEST_ROLLBACK_BINDING_FILE" "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE" cp "$BACKUP" "$TARGET" stage_pending_key } @@ -146,18 +147,38 @@ patchnest_pending_transaction_matches_written_key "$PATCHNEST_SUPERKEY" \ ! patchnest_pending_transaction_matches_written_key 'ffffffffffffffffffffffffffffffffffffffffffffffff' \ || fail "written transaction accepted the wrong key" -# Final binding must consume the written transaction rather than leaving a live -# second authorization record. +# Final binding on a destructive path MUST require and consume that exact +# state=written transaction. BOOT_TARGET="$TARGET" BACKUP_CANDIDATE="$BACKUP" WORKDIR="$TMP/work" -export BOOT_TARGET BACKUP_CANDIDATE WORKDIR +FLASH_TO_DEVICE=true +export BOOT_TARGET BACKUP_CANDIDATE WORKDIR FLASH_TO_DEVICE cp "$SOURCE" "$WORKDIR/new-boot.img" patchnest_commit_rollback_binding || fail "rollback binding commit failed" [ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "rollback binding missing" [ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "committed binding did not clear pending transaction" +set +e +patchnest_commit_rollback_binding +rc=$? +set -e +[ "$rc" -ne 0 ] || fail "destructive binding commit succeeded without written pending transaction" + +# 6. Simulate the power-loss window: boot bytes were verified and state=written, +# but the private patch workspace vanished before rollback binding commit. +reset_state +flash_image() { cp "$1" "$2"; return 0; } +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" || fail "crash-window transaction setup failed" +patchnest_pending_transaction_matches_written_key "$PATCHNEST_SUPERKEY" \ + || fail "crash-window written transaction invalid" +patchnest_commit_binding_from_pending_written "$PATCHNEST_SUPERKEY" \ + || fail "service-style binding reconstruction failed" +[ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "recovered rollback binding missing" +[ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "binding recovery did not consume pending transaction" +grep -Fq '"recovered_from_pending": true' "$PATCHNEST_ROLLBACK_BINDING_FILE" \ + || fail "recovered binding is not auditable" -# 6. Orphan pending credentials cannot be reused as a fresh patch identity. +# 7. Orphan pending credentials cannot be reused as a fresh patch identity. rm -f "$PATCHNEST_SUPERKEY_FILE" "$PATCHNEST_PENDING_TRANSACTION_FILE" "$PATCHNEST_RECOVERY_REQUIRED_FILE" stage_pending_key PATCHNEST_SUPERKEY='' @@ -168,12 +189,86 @@ rc=$? set -e [ "$rc" -ne 0 ] || fail "orphan pending key was accepted as a new patch credential" -# Structural invariants for the production entry points. +# 8. Execute the real restore script with a fake low-level writer. First write +# partially mutates the target and returns 5; the script must retry the exact +# same transaction-bound backup and only then commit its restore receipt. +RESTORE_ROOT="$TMP/restore-harness" +RESTORE_STATE="$RESTORE_ROOT/state" +RESTORE_PATCH="$RESTORE_ROOT/patch" +mkdir -p "$RESTORE_STATE/backup" "$RESTORE_PATCH" +RESTORE_TARGET="$RESTORE_ROOT/boot-target.img" +RESTORE_BACKUP="$RESTORE_STATE/backup/boot_backup_20260808T010000Z_RESTORE.img" +printf '%s\n' 'RESTORE-ORIGINAL-BOOT' > "$RESTORE_BACKUP" +printf '%s\n' 'RESTORE-PATCHED-BOOT' > "$RESTORE_TARGET" +RESTORE_PATCHED_SHA=$(sha256sum "$RESTORE_TARGET" | awk '{print $1}') +RESTORE_BACKUP_SHA=$(sha256sum "$RESTORE_BACKUP" | awk '{print $1}') +RESTORE_PATCHED_SIZE=$(stat -c '%s' "$RESTORE_TARGET") +RESTORE_KEY='abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdef' +RESTORE_KEY_SHA=$(printf '%s' "$RESTORE_KEY" | sha256sum | awk '{print $1}') +RESTORE_DEVICE_SHA=$(PATCHNEST_DEVICE_IDENTITY='synthetic-device-A' patchnest_device_binding_sha256 "$RESTORE_TARGET") + +cat > "$RESTORE_STATE/rollback_binding.json" < "$RESTORE_PATCH/boot_unpatch.sh" +chmod 0755 "$RESTORE_PATCH/boot_unpatch.sh" +printf '%s\n' '#!/bin/sh' > "$RESTORE_PATCH/util_functions.sh" + +RESTORE_CALL_FILE="$RESTORE_ROOT/flash.calls" +printf '0\n' > "$RESTORE_CALL_FILE" +cat > "$RESTORE_PATCH/flash_safety.sh" < "$RESTORE_CALL_FILE" + if [ "\$n" -eq 1 ]; then + printf '%s\n' 'PARTIAL-RESTORE-WRITE' > "\$2" + return 5 + fi + cp "\$1" "\$2" + return 0 +} +EOF +chmod 0755 "$RESTORE_PATCH/flash_safety.sh" + +PATCHNEST_ROLLBACK_BINDING_FILE="$RESTORE_STATE/rollback_binding.json" \ +PATCHNEST_PENDING_TRANSACTION_FILE="$RESTORE_STATE/transaction.pending.json" \ +PATCHNEST_RECOVERY_REQUIRED_FILE="$RESTORE_STATE/flash_recovery_required" \ +PATCHNEST_BACKUP_DIR="$RESTORE_STATE/backup" \ +PATCHNEST_DEVICE_IDENTITY='synthetic-device-A' \ +PATCHNEST_TRANSACTION_TEST=1 \ +sh "$RESTORE_PATCH/boot_unpatch.sh" --restore-bound-backup "$RESTORE_TARGET" \ + >/dev/null 2>&1 || fail "production restore did not recover from partial first write" +[ "$(cat "$RESTORE_CALL_FILE")" -eq 2 ] || fail "production restore did not retry exactly once" +cmp -s "$RESTORE_TARGET" "$RESTORE_BACKUP" || fail "production restore target differs from exact backup" +[ -f "$RESTORE_STATE/last_restore.json" ] || fail "production restore receipt missing" +[ ! -e "$RESTORE_STATE/rollback_binding.json" ] || fail "rollback authorization survived verified restore" +[ ! -e "$RESTORE_STATE/flash_recovery_required" ] || fail "recovery marker survived verified retry" + +# Structural invariants for production entry points. grep -Fq 'patchnest_has_unfinished_transaction' "$PATCH" || fail "patch does not block unfinished transaction" grep -Fq 'patchnest_stage_superkey_for_flash' "$PATCH" || fail "pending key is not staged adjacent to destructive write" grep -Fq 'patchnest_transactional_flash' "$PATCH" || fail "patch bypasses high-level transaction writer" grep -Fq 'patchnest_rollback_after_commit_failure' "$PATCH" || fail "post-write commit failure has no mandatory rollback" grep -Fq -- '--check-bound-backup' "$UNPATCH" || fail "read-only bound validator missing" +grep -Fq 'restore_exact_backup_with_retry' "$UNPATCH" || fail "restore has no partial-write retry guard" ! grep -Fq 'verified_backup=$(resolve_bound_backup)' "$UNPATCH" || fail "rollback metadata still crosses command-substitution subshell" ! grep -Fq 'kptools -u --image' "$UNPATCH" || fail "release unpatch still has a second destructive live-unpatch implementation" From 0cb4d4b76d1026d13ebe761f557812a5f8e8d4d5 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:38:26 +0800 Subject: [PATCH 67/94] feat(device): package physical validation harness --- module/device_validation.sh | 286 ++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 module/device_validation.sh diff --git a/module/device_validation.sh b/module/device_validation.sh new file mode 100644 index 0000000..e1e8ed5 --- /dev/null +++ b/module/device_validation.sh @@ -0,0 +1,286 @@ +#!/system/bin/sh +# PatchNest physical-device validation harness. +# Read-only by default. Destructive modes require exact unlock tokens. + +set -u + +MODE=${1:-preflight} +[ "$#" -gt 0 ] && shift +MODDIR=${PATCHNEST_MODDIR:-/data/adb/modules/PatchNest} +PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} +STAMP=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) +EVIDENCE=${PATCHNEST_EVIDENCE_DIR:-/data/local/tmp/patchnest-evidence-$STAMP} +LOG="$EVIDENCE/validation.log" +TARGET='' + +mkdir -p "$EVIDENCE" || exit 1 +chmod 0700 "$EVIDENCE" 2>/dev/null || true + +log() { printf '%s\n' "$*" | tee -a "$LOG"; } +fail() { log "FAIL: $*"; exit 1; } + +record_cmd() { + _pn_name=$1 + shift + { + printf '### %s\n' "$_pn_name" + "$@" + _pn_rc=$? + printf 'exit=%s\n\n' "$_pn_rc" + return "$_pn_rc" + } >> "$LOG" 2>&1 +} + +json_string() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" +} + +json_number() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" +} + +json_bool() { + _pn_key=$1 + _pn_file=$2 + grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ + | head -n 1 \ + | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" +} + +hash_prefix() { + _pn_target=$1 + _pn_size=$2 + printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + _pn_blocks=$(((_pn_size + 1048575) / 1048576)) + dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ + | head -c "$_pn_size" | sha256sum | awk '{print $1}' +} + +require_root() { [ "$(id -u 2>/dev/null)" = "0" ] || fail "root shell required"; } + +require_module_tree() { + [ -d "$MODDIR" ] || fail "module directory missing: $MODDIR" + [ -x "$MODDIR/bin/kpatch" ] || fail "kpatch missing" + [ -x "$MODDIR/bin/kptools" ] || fail "kptools missing" + [ -x "$MODDIR/bin/magiskboot" ] || fail "magiskboot missing" + [ -f "$MODDIR/patch/transaction_safety.sh" ] || fail "transaction helper missing" + [ -f "$MODDIR/patch/transactional_flash.sh" ] || fail "transactional writer missing" + [ -x "$MODDIR/patch/boot_unpatch.sh" ] || fail "bound restore helper missing or not executable" +} + +resolve_target() { + _pn_out=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_extract.sh" false 2>>"$LOG") || fail "boot target resolution failed" + printf '%s\n' "$_pn_out" >> "$LOG" + TARGET=$(printf '%s\n' "$_pn_out" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) + [ -n "$TARGET" ] || fail "boot target was not emitted" + TARGET=$(readlink -f "$TARGET" 2>/dev/null || printf '%s' "$TARGET") + [ -e "$TARGET" ] || fail "resolved target does not exist: $TARGET" +} + +copy_if_present() { + _pn_src=$1 + _pn_name=$2 + [ -f "$_pn_src" ] || return 0 + cp "$_pn_src" "$EVIDENCE/$_pn_name" 2>/dev/null || true +} + +collect_common() { + log "mode=$MODE" + log "timestamp=$STAMP" + log "module_dir=$MODDIR" + log "state_dir=$PNDIR" + log "boot_target=$TARGET" + log "boot_slot=$(getprop ro.boot.slot_suffix 2>/dev/null)" + log "product_device=$(getprop ro.product.device 2>/dev/null)" + log "boot_completed=$(getprop sys.boot_completed 2>/dev/null)" + log "vbmeta_device_state=$(getprop ro.boot.vbmeta.device_state 2>/dev/null)" + + copy_if_present "$MODDIR/module.prop" "module.prop" + copy_if_present "$MODDIR/provenance/kpatch-public1158.json" "kpatch-public1158.json" + copy_if_present "$PNDIR/last_flash.json" "last_flash.json" + copy_if_present "$PNDIR/last_restore.json" "last_restore.json" + copy_if_present "$PNDIR/rollback_binding.json" "rollback_binding.json" + copy_if_present "$PNDIR/transaction.pending.json" "transaction.pending.json" + copy_if_present "$PNDIR/flash_recovery_required" "flash_recovery_required" + copy_if_present "$PNDIR/abi_profile" "abi_profile" + copy_if_present "$PNDIR/service.log" "service.log" + + if [ -f "$PNDIR/superkey" ]; then + log "superkey_present=1" + log "superkey_mode=$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null || printf unknown)" + log "superkey_file_sha256=$(sha256sum "$PNDIR/superkey" 2>/dev/null | awk '{print $1}')" + else + log "superkey_present=0" + fi + [ -f "$PNDIR/superkey.pending" ] && log "pending_superkey_present=1" || log "pending_superkey_present=0" + [ -f "$PNDIR/transaction.pending.json" ] && log "pending_transaction_present=1" || log "pending_transaction_present=0" + [ -f "$PNDIR/flash_recovery_required" ] && log "recovery_required=1" || log "recovery_required=0" + [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ] && log "flash_review_blocked=1" || log "flash_review_blocked=0" +} + +validate_target_unpack() { + _pn_tmp=$(mktemp -d /data/local/tmp/patchnest-device-unpack.XXXXXX) || fail "cannot create unpack workspace" + if ! (cd "$_pn_tmp" && "$MODDIR/bin/magiskboot" unpack "$TARGET" >/dev/null 2>&1); then + rm -rf "$_pn_tmp"; fail "magiskboot cannot unpack resolved target" + fi + [ -s "$_pn_tmp/kernel" ] || { rm -rf "$_pn_tmp"; fail "resolved target unpack produced no kernel"; } + PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$_pn_tmp/kernel" -l > "$EVIDENCE/kernel-info.txt" 2>&1 || true + rm -rf "$_pn_tmp" +} + +run_production_rollback_check() { + _pn_binding=${1:-$PNDIR/rollback_binding.json} + PATCHNEST_ROLLBACK_BINDING_FILE="$_pn_binding" \ + PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_unpatch.sh" --check-bound-backup "$TARGET" +} + +finalize() { + _pn_bundle="/storage/emulated/0/Download/PatchNest_Device_Evidence_${STAMP}_${MODE}.tar.gz" + if [ -d /storage/emulated/0/Download ] && command -v tar >/dev/null 2>&1; then + tar -czf "$_pn_bundle" -C "${EVIDENCE%/*}" "${EVIDENCE##*/}" 2>/dev/null \ + && log "evidence_bundle=$_pn_bundle" || log "evidence_bundle_failed=1" + fi + log "evidence_dir=$EVIDENCE" +} + +require_root +require_module_tree +resolve_target +collect_common + +case "$MODE" in + preflight) + validate_target_unpack + record_cmd "kpatch file digest" sha256sum "$MODDIR/bin/kpatch" || true + record_cmd "kptools file digest" sha256sum "$MODDIR/bin/kptools" || true + record_cmd "kpimg file digest" sha256sum "$MODDIR/bin/kpimg" || true + [ ! -f "$PNDIR/transaction.pending.json" ] || fail "unfinished flash transaction already exists" + [ ! -f "$PNDIR/flash_recovery_required" ] || fail "flash recovery is already required" + if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then + log "result=REVIEW_PACKAGE_INTENTIONALLY_BLOCKED" + else + log "result=PREFLIGHT_PASS" + fi + ;; + + postboot) + [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android boot_completed is not 1" + _pn_hello=$(PATH="$MODDIR/bin:$PATH" kpatch hello 2>>"$LOG") || fail "kpatch hello failed" + [ "$_pn_hello" = "hello1158" ] || fail "unexpected ABI hello: $_pn_hello" + log "hello=$_pn_hello" + record_cmd "kpver" env PATH="$MODDIR/bin:$PATH" kpatch kpver || fail "kpver failed" + record_cmd "kpm num" env PATH="$MODDIR/bin:$PATH" kpatch kpm num || fail "kpm num failed" + record_cmd "kpm list" env PATH="$MODDIR/bin:$PATH" kpatch kpm list || fail "kpm list failed" + [ -f "$PNDIR/superkey" ] || fail "superkey was not committed" + [ "$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null)" = "600" ] || fail "superkey permissions are not 0600" + [ ! -e "$PNDIR/superkey.pending" ] || fail "unexpected pending credential after healthy boot" + [ ! -e "$PNDIR/transaction.pending.json" ] || fail "unexpected pending transaction after healthy boot" + [ ! -e "$PNDIR/flash_recovery_required" ] || fail "recovery marker present after healthy boot" + run_production_rollback_check >> "$LOG" 2>&1 || fail "production rollback validator rejected live transaction" + [ ! -f "$MODDIR/unresolved" ] || fail "module runtime marked unresolved" + log "result=POSTBOOT_PASS" + ;; + + rollback-check) + run_production_rollback_check >> "$LOG" 2>&1 || fail "production rollback validator rejected live transaction" + log "result=ROLLBACK_ELIGIBLE" + ;; + + rollback-negative) + run_production_rollback_check >> "$LOG" 2>&1 || fail "real binding must be eligible before negative tests" + _pn_real="$PNDIR/rollback_binding.json" + [ -f "$_pn_real" ] || fail "rollback binding missing" + _pn_foreign="$EVIDENCE/rollback.foreign-device.json" + _pn_stale="$EVIDENCE/rollback.stale-bytes.json" + _pn_zero='0000000000000000000000000000000000000000000000000000000000000000' + _pn_ff='ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + + awk -v v="$_pn_zero" '{ + if ($0 ~ /"device_binding_sha256"/) print " \"device_binding_sha256\": \"" v "\","; + else print $0 + }' "$_pn_real" > "$_pn_foreign" + chmod 0600 "$_pn_foreign" + if run_production_rollback_check "$_pn_foreign" >> "$LOG" 2>&1; then + fail "foreign-device binding copy was incorrectly accepted" + fi + + awk -v v="$_pn_ff" '{ + if ($0 ~ /"patched_image_sha256"/) print " \"patched_image_sha256\": \"" v "\","; + else print $0 + }' "$_pn_real" > "$_pn_stale" + chmod 0600 "$_pn_stale" + if run_production_rollback_check "$_pn_stale" >> "$LOG" 2>&1; then + fail "stale-byte binding copy was incorrectly accepted" + fi + log "result=ROLLBACK_NEGATIVE_PASS" + ;; + + restore) + [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "RESTORE_BOUND_BACKUP" ] \ + || fail "restore requires PATCHNEST_DEVICE_TEST_UNLOCK=RESTORE_BOUND_BACKUP" + run_production_rollback_check >> "$LOG" 2>&1 || fail "rollback not eligible before destructive restore" + cp "$PNDIR/rollback_binding.json" "$EVIDENCE/rollback_binding.before-restore.json" + log "destructive_restore=START" + PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$TARGET" >> "$LOG" 2>&1 \ + || fail "bound-backup restore failed" + [ -f "$PNDIR/last_restore.json" ] || fail "restore receipt missing" + log "destructive_restore=PASS" + log "result=RESTORE_WRITE_VERIFIED_REBOOT_REQUIRED" + ;; + + postrestore) + [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android did not reach boot_completed after restore" + _pn_receipt="$PNDIR/last_restore.json" + [ -f "$_pn_receipt" ] || fail "last_restore.json missing" + [ "$(json_bool verified_readback "$_pn_receipt")" = "true" ] || fail "restore receipt is not readback-qualified" + _pn_receipt_target=$(json_string boot_target "$_pn_receipt") + _pn_receipt_device=$(json_string device_binding_sha256 "$_pn_receipt") + _pn_restore_sha=$(json_string restored_backup_sha256 "$_pn_receipt") + _pn_restore_size=$(json_number restored_backup_size "$_pn_receipt") + [ "$_pn_receipt_target" = "$TARGET" ] || fail "postrestore target differs from receipt" + printf '%s' "$_pn_receipt_device$_pn_restore_sha" | grep -Eq '^[0-9a-f]{128}$' || fail "restore receipt digest fields invalid" + printf '%s' "$_pn_restore_size" | grep -Eq '^[1-9][0-9]*$' || fail "restore receipt size invalid" + _pn_now=$(hash_prefix "$TARGET" "$_pn_restore_size") || fail "cannot hash restored boot byte range" + [ "$_pn_now" = "$_pn_restore_sha" ] || fail "current boot bytes differ from restored backup receipt" + [ ! -e "$PNDIR/rollback_binding.json" ] || fail "rollback authorization still live after restore" + [ ! -e "$PNDIR/transaction.pending.json" ] || fail "pending transaction survived restore" + [ ! -e "$PNDIR/flash_recovery_required" ] || fail "recovery marker survived successful restore" + log "result=POSTRESTORE_PASS" + ;; + + kpm-cycle) + _pn_candidate=${1:-} + [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "KPM_CYCLE" ] \ + || fail "KPM cycle requires PATCHNEST_DEVICE_TEST_UNLOCK=KPM_CYCLE" + [ -f "$_pn_candidate" ] || fail "KPM candidate missing: $_pn_candidate" + _pn_meta=$(PATH="$MODDIR/bin:$PATH" kptools -l -M "$_pn_candidate" 2>>"$LOG") \ + || fail "candidate metadata validation failed" + _pn_name=$(printf '%s\n' "$_pn_meta" | sed -n 's/^name=//p' | head -n 1) + [ -n "$_pn_name" ] || fail "candidate KPM has no name" + log "kpm_candidate=$_pn_candidate" + log "kpm_name=$_pn_name" + PATH="$MODDIR/bin:$PATH" kpatch kpm load "$_pn_candidate" >> "$LOG" 2>&1 || fail "KPM load failed" + PATH="$MODDIR/bin:$PATH" kpatch kpm info "$_pn_name" >> "$LOG" 2>&1 || { + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || true + fail "KPM info failed after load" + } + PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || fail "KPM unload failed" + log "result=KPM_CYCLE_PASS" + ;; + + *) fail "unknown mode: $MODE" ;; +esac + +finalize +exit 0 From b2b7328f8401ce00aaf5456fcbefbfbf5b514a33 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:38:41 +0800 Subject: [PATCH 68/94] refactor(device): make packaged harness canonical --- scripts/device_validation.sh | 290 +---------------------------------- 1 file changed, 6 insertions(+), 284 deletions(-) diff --git a/scripts/device_validation.sh b/scripts/device_validation.sh index ee42e2f..a57b67a 100644 --- a/scripts/device_validation.sh +++ b/scripts/device_validation.sh @@ -1,285 +1,7 @@ -#!/system/bin/sh -# PatchNest physical-device validation harness. -# Read-only by default. Destructive modes require exact unlock tokens. +#!/bin/sh +# Source-tree convenience wrapper. The canonical, packaged physical-device +# validation harness lives at module/device_validation.sh. -set -u - -MODE=${1:-preflight} -[ "$#" -gt 0 ] && shift -MODDIR=${PATCHNEST_MODDIR:-/data/adb/modules/PatchNest} -PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} -STAMP=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) -EVIDENCE=${PATCHNEST_EVIDENCE_DIR:-/data/local/tmp/patchnest-evidence-$STAMP} -LOG="$EVIDENCE/validation.log" -TARGET='' - -mkdir -p "$EVIDENCE" || exit 1 -chmod 0700 "$EVIDENCE" 2>/dev/null || true - -log() { printf '%s\n' "$*" | tee -a "$LOG"; } -fail() { log "FAIL: $*"; exit 1; } - -record_cmd() { - _pn_name=$1 - shift - { - printf '### %s\n' "$_pn_name" - "$@" - _pn_rc=$? - printf 'exit=%s\n\n' "$_pn_rc" - return "$_pn_rc" - } >> "$LOG" 2>&1 -} - -json_string() { - _pn_key=$1 - _pn_file=$2 - grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$_pn_file" 2>/dev/null \ - | head -n 1 \ - | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\\1/" -} - -json_number() { - _pn_key=$1 - _pn_file=$2 - grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" "$_pn_file" 2>/dev/null \ - | head -n 1 \ - | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*([0-9][0-9]*).*/\\1/" -} - -json_bool() { - _pn_key=$1 - _pn_file=$2 - grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ - | head -n 1 \ - | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" -} - -hash_prefix() { - _pn_target=$1 - _pn_size=$2 - printf '%s' "$_pn_size" | grep -Eq '^[1-9][0-9]*$' || return 1 - _pn_blocks=$(((_pn_size + 1048575) / 1048576)) - dd if="$_pn_target" bs=1048576 count="$_pn_blocks" 2>/dev/null \ - | head -c "$_pn_size" | sha256sum | awk '{print $1}' -} - -require_root() { [ "$(id -u 2>/dev/null)" = "0" ] || fail "root shell required"; } - -require_module_tree() { - [ -d "$MODDIR" ] || fail "module directory missing: $MODDIR" - [ -x "$MODDIR/bin/kpatch" ] || fail "kpatch missing" - [ -x "$MODDIR/bin/kptools" ] || fail "kptools missing" - [ -x "$MODDIR/bin/magiskboot" ] || fail "magiskboot missing" - [ -f "$MODDIR/patch/transaction_safety.sh" ] || fail "transaction helper missing" - [ -x "$MODDIR/patch/boot_unpatch.sh" ] || fail "bound restore helper missing" -} - -resolve_target() { - _pn_out=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ - "$MODDIR/patch/boot_extract.sh" false 2>>"$LOG") || fail "boot target resolution failed" - printf '%s\n' "$_pn_out" >> "$LOG" - TARGET=$(printf '%s\n' "$_pn_out" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) - [ -n "$TARGET" ] || fail "boot target was not emitted" - TARGET=$(readlink -f "$TARGET" 2>/dev/null || printf '%s' "$TARGET") - [ -e "$TARGET" ] || fail "resolved target does not exist: $TARGET" -} - -copy_if_present() { - _pn_src=$1 - _pn_name=$2 - [ -f "$_pn_src" ] || return 0 - cp "$_pn_src" "$EVIDENCE/$_pn_name" 2>/dev/null || true -} - -collect_common() { - log "mode=$MODE" - log "timestamp=$STAMP" - log "module_dir=$MODDIR" - log "state_dir=$PNDIR" - log "boot_target=$TARGET" - log "boot_slot=$(getprop ro.boot.slot_suffix 2>/dev/null)" - log "product_device=$(getprop ro.product.device 2>/dev/null)" - log "boot_completed=$(getprop sys.boot_completed 2>/dev/null)" - log "vbmeta_device_state=$(getprop ro.boot.vbmeta.device_state 2>/dev/null)" - - copy_if_present "$MODDIR/module.prop" "module.prop" - copy_if_present "$MODDIR/provenance/kpatch-public1158.json" "kpatch-public1158.json" - copy_if_present "$PNDIR/last_flash.json" "last_flash.json" - copy_if_present "$PNDIR/last_restore.json" "last_restore.json" - copy_if_present "$PNDIR/rollback_binding.json" "rollback_binding.json" - copy_if_present "$PNDIR/transaction.pending.json" "transaction.pending.json" - copy_if_present "$PNDIR/flash_recovery_required" "flash_recovery_required" - copy_if_present "$PNDIR/abi_profile" "abi_profile" - copy_if_present "$PNDIR/service.log" "service.log" - - if [ -f "$PNDIR/superkey" ]; then - log "superkey_present=1" - log "superkey_mode=$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null || printf unknown)" - log "superkey_file_sha256=$(sha256sum "$PNDIR/superkey" 2>/dev/null | awk '{print $1}')" - else - log "superkey_present=0" - fi - [ -f "$PNDIR/superkey.pending" ] && log "pending_superkey_present=1" || log "pending_superkey_present=0" - [ -f "$PNDIR/transaction.pending.json" ] && log "pending_transaction_present=1" || log "pending_transaction_present=0" - [ -f "$PNDIR/flash_recovery_required" ] && log "recovery_required=1" || log "recovery_required=0" - [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ] && log "flash_review_blocked=1" || log "flash_review_blocked=0" -} - -validate_target_unpack() { - _pn_tmp=$(mktemp -d /data/local/tmp/patchnest-device-unpack.XXXXXX) || fail "cannot create unpack workspace" - if ! (cd "$_pn_tmp" && "$MODDIR/bin/magiskboot" unpack "$TARGET" >/dev/null 2>&1); then - rm -rf "$_pn_tmp"; fail "magiskboot cannot unpack resolved target" - fi - [ -s "$_pn_tmp/kernel" ] || { rm -rf "$_pn_tmp"; fail "resolved target unpack produced no kernel"; } - PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$_pn_tmp/kernel" -l > "$EVIDENCE/kernel-info.txt" 2>&1 || true - rm -rf "$_pn_tmp" -} - -run_production_rollback_check() { - _pn_binding=${1:-$PNDIR/rollback_binding.json} - PATCHNEST_ROLLBACK_BINDING_FILE="$_pn_binding" \ - PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ - "$MODDIR/patch/boot_unpatch.sh" --check-bound-backup "$TARGET" -} - -finalize() { - _pn_bundle="/storage/emulated/0/Download/PatchNest_Device_Evidence_${STAMP}_${MODE}.tar.gz" - if [ -d /storage/emulated/0/Download ] && command -v tar >/dev/null 2>&1; then - tar -czf "$_pn_bundle" -C "${EVIDENCE%/*}" "${EVIDENCE##*/}" 2>/dev/null \ - && log "evidence_bundle=$_pn_bundle" || log "evidence_bundle_failed=1" - fi - log "evidence_dir=$EVIDENCE" -} - -require_root -require_module_tree -resolve_target -collect_common - -case "$MODE" in - preflight) - validate_target_unpack - record_cmd "kpatch file digest" sha256sum "$MODDIR/bin/kpatch" || true - record_cmd "kptools file digest" sha256sum "$MODDIR/bin/kptools" || true - record_cmd "kpimg file digest" sha256sum "$MODDIR/bin/kpimg" || true - [ ! -f "$PNDIR/transaction.pending.json" ] || fail "unfinished flash transaction already exists" - [ ! -f "$PNDIR/flash_recovery_required" ] || fail "flash recovery is already required" - if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then - log "result=REVIEW_PACKAGE_INTENTIONALLY_BLOCKED" - else - log "result=PREFLIGHT_PASS" - fi - ;; - - postboot) - [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android boot_completed is not 1" - _pn_hello=$(PATH="$MODDIR/bin:$PATH" kpatch hello 2>>"$LOG") || fail "kpatch hello failed" - [ "$_pn_hello" = "hello1158" ] || fail "unexpected ABI hello: $_pn_hello" - log "hello=$_pn_hello" - record_cmd "kpver" env PATH="$MODDIR/bin:$PATH" kpatch kpver || fail "kpver failed" - record_cmd "kpm num" env PATH="$MODDIR/bin:$PATH" kpatch kpm num || fail "kpm num failed" - record_cmd "kpm list" env PATH="$MODDIR/bin:$PATH" kpatch kpm list || fail "kpm list failed" - [ -f "$PNDIR/superkey" ] || fail "superkey was not committed" - [ "$(stat -c '%a' "$PNDIR/superkey" 2>/dev/null)" = "600" ] || fail "superkey permissions are not 0600" - [ ! -e "$PNDIR/superkey.pending" ] || fail "unexpected pending credential after healthy boot" - [ ! -e "$PNDIR/transaction.pending.json" ] || fail "unexpected pending transaction after healthy boot" - [ ! -e "$PNDIR/flash_recovery_required" ] || fail "recovery marker present after healthy boot" - run_production_rollback_check >> "$LOG" 2>&1 || fail "production rollback validator rejected live transaction" - [ ! -f "$MODDIR/unresolved" ] || fail "module runtime marked unresolved" - log "result=POSTBOOT_PASS" - ;; - - rollback-check) - run_production_rollback_check >> "$LOG" 2>&1 || fail "production rollback validator rejected live transaction" - log "result=ROLLBACK_ELIGIBLE" - ;; - - rollback-negative) - run_production_rollback_check >> "$LOG" 2>&1 || fail "real binding must be eligible before negative tests" - _pn_real="$PNDIR/rollback_binding.json" - [ -f "$_pn_real" ] || fail "rollback binding missing" - _pn_foreign="$EVIDENCE/rollback.foreign-device.json" - _pn_stale="$EVIDENCE/rollback.stale-bytes.json" - _pn_zero='0000000000000000000000000000000000000000000000000000000000000000' - _pn_ff='ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' - - awk -v v="$_pn_zero" '{ - if ($0 ~ /"device_binding_sha256"/) print " \"device_binding_sha256\": \"" v "\","; - else print $0 - }' "$_pn_real" > "$_pn_foreign" - chmod 0600 "$_pn_foreign" - if run_production_rollback_check "$_pn_foreign" >> "$LOG" 2>&1; then - fail "foreign-device binding copy was incorrectly accepted" - fi - - awk -v v="$_pn_ff" '{ - if ($0 ~ /"patched_image_sha256"/) print " \"patched_image_sha256\": \"" v "\","; - else print $0 - }' "$_pn_real" > "$_pn_stale" - chmod 0600 "$_pn_stale" - if run_production_rollback_check "$_pn_stale" >> "$LOG" 2>&1; then - fail "stale-byte binding copy was incorrectly accepted" - fi - log "result=ROLLBACK_NEGATIVE_PASS" - ;; - - restore) - [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "RESTORE_BOUND_BACKUP" ] \ - || fail "restore requires PATCHNEST_DEVICE_TEST_UNLOCK=RESTORE_BOUND_BACKUP" - run_production_rollback_check >> "$LOG" 2>&1 || fail "rollback not eligible before destructive restore" - cp "$PNDIR/rollback_binding.json" "$EVIDENCE/rollback_binding.before-restore.json" - log "destructive_restore=START" - PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ - "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$TARGET" >> "$LOG" 2>&1 \ - || fail "bound-backup restore failed" - [ -f "$PNDIR/last_restore.json" ] || fail "restore receipt missing" - log "destructive_restore=PASS" - log "result=RESTORE_WRITE_VERIFIED_REBOOT_REQUIRED" - ;; - - postrestore) - [ "$(getprop sys.boot_completed 2>/dev/null)" = "1" ] || fail "Android did not reach boot_completed after restore" - _pn_receipt="$PNDIR/last_restore.json" - [ -f "$_pn_receipt" ] || fail "last_restore.json missing" - [ "$(json_bool verified_readback "$_pn_receipt")" = "true" ] || fail "restore receipt is not readback-qualified" - _pn_receipt_target=$(json_string boot_target "$_pn_receipt") - _pn_receipt_device=$(json_string device_binding_sha256 "$_pn_receipt") - _pn_restore_sha=$(json_string restored_backup_sha256 "$_pn_receipt") - _pn_restore_size=$(json_number restored_backup_size "$_pn_receipt") - [ "$_pn_receipt_target" = "$TARGET" ] || fail "postrestore target differs from receipt" - printf '%s' "$_pn_receipt_device$_pn_restore_sha" | grep -Eq '^[0-9a-f]{128}$' || fail "restore receipt digest fields invalid" - printf '%s' "$_pn_restore_size" | grep -Eq '^[1-9][0-9]*$' || fail "restore receipt size invalid" - _pn_now=$(hash_prefix "$TARGET" "$_pn_restore_size") || fail "cannot hash restored boot byte range" - [ "$_pn_now" = "$_pn_restore_sha" ] || fail "current boot bytes differ from restored backup receipt" - [ ! -e "$PNDIR/rollback_binding.json" ] || fail "rollback authorization still live after restore" - [ ! -e "$PNDIR/transaction.pending.json" ] || fail "pending transaction survived restore" - [ ! -e "$PNDIR/flash_recovery_required" ] || fail "recovery marker survived successful restore" - log "result=POSTRESTORE_PASS" - ;; - - kpm-cycle) - _pn_candidate=${1:-} - [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "KPM_CYCLE" ] \ - || fail "KPM cycle requires PATCHNEST_DEVICE_TEST_UNLOCK=KPM_CYCLE" - [ -f "$_pn_candidate" ] || fail "KPM candidate missing: $_pn_candidate" - _pn_meta=$(PATH="$MODDIR/bin:$PATH" kptools -l -M "$_pn_candidate" 2>>"$LOG") \ - || fail "candidate metadata validation failed" - _pn_name=$(printf '%s\n' "$_pn_meta" | sed -n 's/^name=//p' | head -n 1) - [ -n "$_pn_name" ] || fail "candidate KPM has no name" - log "kpm_candidate=$_pn_candidate" - log "kpm_name=$_pn_name" - PATH="$MODDIR/bin:$PATH" kpatch kpm load "$_pn_candidate" >> "$LOG" 2>&1 || fail "KPM load failed" - PATH="$MODDIR/bin:$PATH" kpatch kpm info "$_pn_name" >> "$LOG" 2>&1 || { - PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || true - fail "KPM info failed after load" - } - PATH="$MODDIR/bin:$PATH" kpatch kpm unload "$_pn_name" >> "$LOG" 2>&1 || fail "KPM unload failed" - log "result=KPM_CYCLE_PASS" - ;; - - *) fail "unknown mode: $MODE" ;; -esac - -finalize -exit 0 +set -eu +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +exec "$ROOT/module/device_validation.sh" "$@" From 48633f878f916206b08bfca77a5325bbfe219e21 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:39:09 +0800 Subject: [PATCH 69/94] fix(install): set executable permissions for flash and validation scripts --- module/customize.sh | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/module/customize.sh b/module/customize.sh index 5952df7..8c3e355 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -1,13 +1,13 @@ #!/system/bin/sh MODDIR="/data/adb/modules/PatchNest" -# This review branch is intentionally non-flashable until the P0 gates in -# FLASH_READINESS.md are closed. The marker is packaged into the module so a -# review artifact cannot be mistaken for a release artifact. +# This review branch is intentionally non-flashable until physical-device +# lifecycle validation is complete. The marker is checked before any persistent +# PatchNest state is created. if [ -f "${MODPATH:-$MODDIR}/FLASH_REVIEW_BLOCKED" ]; then ui_print "! PatchNest review build: flashing is intentionally blocked" - ui_print "! P0 flash-readiness gates are still open" - ui_print "! Use a reviewed release artifact, not this branch" + ui_print "! Physical-device flash-readiness gate is still open" + ui_print "! Use the isolated device-validation candidate only for FR-014" abort "! FLASH_REVIEW_BLOCKED" fi @@ -33,6 +33,8 @@ ui_print "- Root manager: $ROOT_MGR" ui_print "- Architecture: $ARCH" set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 +set_perm_recursive "$MODPATH/patch" 0 0 0755 0755 +[ ! -f "$MODPATH/device_validation.sh" ] || set_perm "$MODPATH/device_validation.sh" 0 0 0755 mkdir -p /data/adb/patchnest @@ -54,22 +56,29 @@ fi if [ ! -x "$MODPATH/bin/kptools" ]; then abort "! kptools binary missing or not executable in $MODPATH/bin" fi +if [ ! -x "$MODPATH/patch/boot_patch.sh" ] || [ ! -x "$MODPATH/patch/boot_unpatch.sh" ]; then + abort "! PatchNest boot transaction scripts are not executable" +fi +if [ ! -x "$MODPATH/device_validation.sh" ]; then + abort "! Physical-device validation harness is missing or not executable" +fi echo "$ROOT_MGR" > /data/adb/patchnest/root_manager cp "$MODPATH/module.prop" "$MODPATH/module.prop.bak" rm -rf "$MODDIR/webroot"/* 2>/dev/null || true -rm -rf "$MODDIR/bin"/* 2>/dev/null || true -rm -rf "$MODDIR/patch"/* 2>/dev/null || true +rm -rf "$MODDIR/bin"/* 2>/dev/null || true +rm -rf "$MODDIR/patch"/* 2>/dev/null || true [ -d "$MODDIR/webroot" ] || mkdir -p "$MODDIR/webroot" -[ -d "$MODDIR/bin" ] || mkdir -p "$MODDIR/bin" -[ -d "$MODDIR/patch" ] || mkdir -p "$MODDIR/patch" +[ -d "$MODDIR/bin" ] || mkdir -p "$MODDIR/bin" +[ -d "$MODDIR/patch" ] || mkdir -p "$MODDIR/patch" cp -rf "$MODPATH/webroot"/* "$MODDIR/webroot/" 2>/dev/null || true -cp -rf "$MODPATH/bin"/* "$MODDIR/bin/" 2>/dev/null || true -cp -rf "$MODPATH/patch"/* "$MODDIR/patch/" 2>/dev/null || true - +cp -rf "$MODPATH/bin"/* "$MODDIR/bin/" 2>/dev/null || true +cp -rf "$MODPATH/patch"/* "$MODDIR/patch/" 2>/dev/null || true cp -f "$MODPATH/detect_env.sh" "$MODDIR/detect_env.sh" 2>/dev/null || true +cp -f "$MODPATH/device_validation.sh" "$MODDIR/device_validation.sh" 2>/dev/null || true +chmod 0755 "$MODDIR/patch"/*.sh "$MODDIR/device_validation.sh" 2>/dev/null || true ui_print "- Installation complete" ui_print "" From 5300c3027fa352442da090ce4dcc14b724c1ae54 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:39:46 +0800 Subject: [PATCH 70/94] test(package): enforce flash-safety files in final ZIP --- scripts/package_module.sh | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index 62ca455..0f9a7c8 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -19,6 +19,7 @@ case "$OUTPUT" in esac command -v zip >/dev/null 2>&1 || { echo "zip is required" >&2; exit 1; } +command -v unzip >/dev/null 2>&1 || { echo "unzip is required" >&2; exit 1; } command -v sort >/dev/null 2>&1 || { echo "sort is required" >&2; exit 1; } STAGE=$(mktemp -d) @@ -42,7 +43,6 @@ rm -f "$OUTPUT_ABS" # Stable lexical path order + -X (no UID/GID/extra timestamp fields). # Strip the find(1) "./" prefix so module.prop and META-INF live at the # canonical ZIP root expected by Android root-manager installers. - # File modes are preserved by cp -a and stored by zip on Unix. find . -type f -print | sed 's#^\./##' | LC_ALL=C sort > "$STAGE/file-list" [ -s "$STAGE/file-list" ] || { echo "module tree contains no files" >&2 @@ -52,3 +52,30 @@ rm -f "$OUTPUT_ABS" ) [ -s "$OUTPUT_ABS" ] || { echo "deterministic package is empty" >&2; exit 1; } + +# The archive itself is the release boundary. Fail even when the source tree is +# correct if any flash-safety/runtime artifact was omitted from the ZIP. +unzip -Z1 "$OUTPUT_ABS" > "$STAGE/zip-list" +for required in \ + module.prop \ + FLASH_REVIEW_BLOCKED \ + customize.sh \ + service.sh \ + device_validation.sh \ + patch/boot_patch.sh \ + patch/boot_unpatch.sh \ + patch/flash_safety.sh \ + patch/transaction_safety.sh \ + patch/transactional_flash.sh \ + patch/superkey_safety.sh; do + grep -Fxq "$required" "$STAGE/zip-list" || { + echo "required package entry missing: $required" >&2 + exit 1 + } +done + +# Canonical archive paths only: no traversal and no find(1) ./ prefixes. +if grep -Eq '(^|/)\.\.(/|$)|^\./' "$STAGE/zip-list"; then + echo "unsafe or non-canonical path found in module ZIP" >&2 + exit 1 +fi From a0b787a4da5cc3d594550e1febe85ab172d04833 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:40:08 +0800 Subject: [PATCH 71/94] ci(flash): validate packaged device harness and archive gate --- .github/workflows/flash-safety.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index e6e91aa..46efd41 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -9,7 +9,10 @@ on: paths: - 'module/patch/**' - 'module/service.sh' + - 'module/customize.sh' + - 'module/device_validation.sh' - 'scripts/device_validation.sh' + - 'scripts/package_module.sh' - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' @@ -22,7 +25,10 @@ on: paths: - 'module/patch/**' - 'module/service.sh' + - 'module/customize.sh' + - 'module/device_validation.sh' - 'scripts/device_validation.sh' + - 'scripts/package_module.sh' - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' @@ -41,12 +47,12 @@ jobs: - name: Install shell validation tools run: | sudo apt-get update - sudo apt-get install -y shellcheck + sudo apt-get install -y shellcheck zip unzip - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh scripts/device_validation.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh module/customize.sh module/device_validation.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -62,7 +68,10 @@ jobs: module/patch/transactional_flash.sh \ module/patch/superkey_safety.sh \ module/service.sh \ + module/customize.sh \ + module/device_validation.sh \ scripts/device_validation.sh \ + scripts/package_module.sh \ tests/flash_safety_contract.sh \ tests/destructive_transaction_contract.sh \ tests/runtime_abi_contract.sh @@ -79,3 +88,10 @@ jobs: - name: Run runtime ABI contract run: sh tests/runtime_abi_contract.sh + + - name: Prove flash-safety files enter deterministic ZIP + run: | + set -euo pipefail + mkdir -p /tmp/patchnest-package-test + sh scripts/package_module.sh module /tmp/patchnest-package-test/module.zip + test -s /tmp/patchnest-package-test/module.zip From 32d55010d5ef773e426eccebbe79b0fee37c44c9 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:42:39 +0800 Subject: [PATCH 72/94] test(flash): align baseline contract with transactional writer --- tests/flash_safety_contract.sh | 55 +++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh index 528693f..4010e44 100644 --- a/tests/flash_safety_contract.sh +++ b/tests/flash_safety_contract.sh @@ -5,9 +5,11 @@ ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) PATCH="$ROOT/module/patch/boot_patch.sh" SAFETY="$ROOT/module/patch/flash_safety.sh" TRANSACTION="$ROOT/module/patch/transaction_safety.sh" +TRANSACTIONAL="$ROOT/module/patch/transactional_flash.sh" SUPERKEY="$ROOT/module/patch/superkey_safety.sh" UNPATCH="$ROOT/module/patch/boot_unpatch.sh" EXTRACT="$ROOT/module/patch/boot_extract.sh" +DEVICE_VALIDATION="$ROOT/module/device_validation.sh" PATCHNEST_TRANSACTION_TEST=1 export PATCHNEST_TRANSACTION_TEST @@ -20,15 +22,21 @@ fail() { # Structural release invariants. grep -Fq '. "$MODPATH/flash_safety.sh"' "$PATCH" || fail "boot_patch does not activate flash_safety" grep -Fq '. "$MODPATH/superkey_safety.sh"' "$PATCH" || fail "boot_patch does not activate superkey lifecycle" +grep -Fq '. "$MODPATH/transactional_flash.sh"' "$PATCH" || fail "boot_patch does not activate destructive transaction layer" grep -Fq 'mktemp -d /data/local/tmp/patchnest_patch.XXXXXX' "$PATCH" || fail "patch workspace is not private" grep -Fq '"boot_target":' "$PATCH" || fail "backup manifest has no target binding" grep -Fq '"backup_sha256":' "$PATCH" || fail "backup manifest has no backup digest" grep -Fq '"superkey_sha256":' "$PATCH" || fail "backup/receipt has no credential binding" grep -Fq '"backup_verified": true' "$PATCH" || fail "backup manifest is not verified" grep -Fq 'validate_boot_image "$WORKDIR/new-boot.img"' "$PATCH" || fail "repacked image is not validated" -grep -Fq 'flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"' "$PATCH" || fail "patch bypasses reviewed writer" +grep -Fq 'patchnest_transactional_flash "$WORKDIR/new-boot.img" "$BOOT_TARGET" "$BACKUP_CANDIDATE"' "$PATCH" \ + || fail "patch bypasses reviewed transaction writer" +! grep -Fq 'flash_image "$WORKDIR/new-boot.img" "$BOOT_TARGET"' "$PATCH" \ + || fail "patch directly invokes low-level writer outside transaction layer" grep -Fq -- '-s "$PATCHNEST_SUPERKEY"' "$PATCH" || fail "Public1158 key is not embedded" +grep -Fq 'patchnest_stage_superkey_for_flash' "$PATCH" || fail "destructive key is not staged adjacent to write" grep -Fq 'patchnest_commit_superkey' "$PATCH" || fail "verified flash does not commit credential state" +grep -Fq 'patchnest_has_unfinished_transaction' "$PATCH" || fail "patch does not block unfinished transactions" ! grep -Fq 'if [ ! -f kernel ]' "$PATCH" || fail "patch may reuse stale kernel" ! grep -Fq 'Cannot verify with kptools' "$PATCH" || fail "embedded KPM validation is fail-open" ! grep -Fq '(proceeding)' "$PATCH" || fail "embedded KPM validation is fail-open" @@ -38,18 +46,24 @@ for file in "$PATCH" "$UNPATCH" "$EXTRACT"; do done grep -Fq -- '--restore-bound-backup' "$UNPATCH" || fail "bound restore entry point missing" -grep -Fq 'rollback_binding.json' "$UNPATCH" || fail "restore does not require transaction binding" +grep -Fq -- '--check-bound-backup' "$UNPATCH" || fail "read-only rollback validator missing" +grep -Fq 'restore_exact_backup_with_retry' "$UNPATCH" || fail "restore has no partial-write retry" grep -Fq 'device_binding_sha256' "$UNPATCH" || fail "restore does not verify device identity" grep -Fq 'patched_image_sha256' "$UNPATCH" || fail "restore does not verify current patched bytes" ! grep -Fq 'for manifest in' "$UNPATCH" || fail "restore still selects arbitrary manifests" +! grep -Fq 'kptools -u --image' "$UNPATCH" || fail "release restore still has an independent live-unpatch writer" +grep -Fq 'PATCHNEST_PENDING_TRANSACTION_FILE' "$TRANSACTION" || fail "durable pending transaction path missing" +grep -Fq 'patchnest_commit_binding_from_pending_written' "$TRANSACTION" || fail "crash binding recovery missing" +grep -Fq 'patchnest_attempt_verified_rollback' "$TRANSACTIONAL" || fail "transaction layer has no mandatory rollback path" grep -Fq 'superkey.pending' "$SUPERKEY" || fail "pending credential crash state missing" -grep -Fq 'PATCHNEST_SUPERKEY_PENDING_FILE' "$SUPERKEY" || fail "pending credential path is not explicit" +grep -Fq 'patchnest_stage_superkey_for_flash' "$SUPERKEY" || fail "late credential staging helper missing" +[ -s "$DEVICE_VALIDATION" ] || fail "packaged physical-device validation harness missing" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT HUP INT TERM -# Writer readback contract on an offline regular-file target. +# Low-level writer readback contract on an offline regular-file target. printf '%s\n' 'PatchNest transactional flash contract' > "$TMP/source.img" printf '%s\n' 'old target contents' > "$TMP/target.img" # shellcheck disable=SC1090 @@ -60,7 +74,7 @@ expected=$(sha256sum "$TMP/source.img" | awk '{print $1}') actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') [ "$expected" = "$actual" ] || fail "digest mismatch after verified write" -# Export-only key generation must not create an active/pending device credential. +# Export-only key generation must never create an active/pending device credential. ( PATCHNEST_SUPERKEY_FILE="$TMP/export-state/superkey" PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/export-state/superkey.pending" @@ -78,8 +92,8 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') [ "$(stat -c '%a' "$record")" = "600" ] || fail "export key record mode is not 0600" ) -# Successful destructive credential commit: pending exists before commit, then -# atomically becomes the active key. Stub only the independent binding writer. +# New destructive key is private during preparation and only becomes pending +# immediately before the destructive transaction. ( PATCHNEST_SUPERKEY_FILE="$TMP/success-state/superkey" PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/success-state/superkey.pending" @@ -90,8 +104,10 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') . "$SUPERKEY" patchnest_prepare_superkey "$TMP" || fail "destructive key preparation failed" before=$PATCHNEST_SUPERKEY - [ -f "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "pending key not staged before write" + [ ! -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "pending key was persisted before destructive boundary" [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || fail "active key exists before verified write" + patchnest_stage_superkey_for_flash || fail "pending key staging failed" + [ -f "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || fail "pending key not staged at destructive boundary" [ "$(stat -c '%a' "$PATCHNEST_SUPERKEY_PENDING_FILE")" = "600" ] || fail "pending key mode is not 0600" patchnest_commit_rollback_binding() { return 0; } patchnest_commit_superkey || fail "destructive key commit failed" @@ -101,7 +117,7 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') ) # If rollback binding commit fails, the new active key must be moved back to -# pending so a power loss cannot strand a boot that already requires it. +# pending so the already-written kernel credential remains recoverable. ( PATCHNEST_SUPERKEY_FILE="$TMP/failure-state/superkey" PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/failure-state/superkey.pending" @@ -112,6 +128,7 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') . "$SUPERKEY" patchnest_prepare_superkey "$TMP" || fail "failure-path key preparation failed" before=$PATCHNEST_SUPERKEY + patchnest_stage_superkey_for_flash || fail "failure-path pending key staging failed" patchnest_commit_rollback_binding() { return 1; } if patchnest_commit_superkey; then fail "key commit succeeded despite rollback binding failure" @@ -121,7 +138,7 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') [ "$(cat "$PATCHNEST_SUPERKEY_PENDING_FILE")" = "$before" ] || fail "reverted pending key changed" ) -# An existing committed key must be secure and must not be rewritten. +# Existing committed key must be secure and must not be rewritten. ( mkdir -p "$TMP/existing-state" PATCHNEST_SUPERKEY_FILE="$TMP/existing-state/superkey" @@ -159,11 +176,17 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') fi ) -# Commit and inspect the exact rollback transaction record itself. +# Commit and inspect a real destructive rollback record. A state=written +# transaction is mandatory before the binding can be committed. PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/transaction-state/rollback_binding.json" +PATCHNEST_PENDING_TRANSACTION_FILE="$TMP/transaction-state/transaction.pending.json" +PATCHNEST_RECOVERY_REQUIRED_FILE="$TMP/transaction-state/flash_recovery_required" PATCHNEST_DEVICE_IDENTITY='unit-test-device-A' PATCHNEST_SUPERKEY='0123456789abcdef0123456789abcdef0123456789abcdef' -export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_DEVICE_IDENTITY PATCHNEST_SUPERKEY +PATCHNEST_BACKUP_DIR="$TMP" +FLASH_TO_DEVICE=true +export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_PENDING_TRANSACTION_FILE PATCHNEST_RECOVERY_REQUIRED_FILE +export PATCHNEST_DEVICE_IDENTITY PATCHNEST_SUPERKEY PATCHNEST_BACKUP_DIR FLASH_TO_DEVICE # shellcheck disable=SC1090 . "$TRANSACTION" BOOT_TARGET="$TMP/transaction-target.img" @@ -174,18 +197,22 @@ mkdir -p "$WORKDIR" printf '%s\n' 'original boot bytes' > "$BACKUP_CANDIDATE" printf '%s\n' 'patched boot bytes' > "$WORKDIR/new-boot.img" cp "$WORKDIR/new-boot.img" "$BOOT_TARGET" +patchnest_stage_pending_transaction "$WORKDIR/new-boot.img" "$BOOT_TARGET" "$BACKUP_CANDIDATE" \ + || fail "pending rollback transaction staging failed" +patchnest_mark_pending_transaction_written || fail "pending rollback transaction did not advance to written" patchnest_commit_rollback_binding || fail "rollback transaction commit failed" [ -f "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || fail "rollback transaction missing" +[ ! -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || fail "committed rollback left pending transaction" [ "$(stat -c '%a' "$PATCHNEST_ROLLBACK_BINDING_FILE")" = "600" ] || fail "rollback binding mode is not 0600" grep -Fq '"verified_readback": true' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "binding is not readback-qualified" grep -Fq '"rollback_backup": "boot_backup_20260808T000000Z_TEST.img"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "binding does not name exact backup" grep -Eq '"device_binding_sha256": "[0-9a-f]{64}"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "device digest missing" grep -Eq '"patched_image_sha256": "[0-9a-f]{64}"' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "patched digest missing" grep -Eq '"patched_image_size": [1-9][0-9]*' "$PATCHNEST_ROLLBACK_BINDING_FILE" || fail "patched byte range missing" -binding_a=$(patchnest_device_binding_sha256) +binding_a=$(patchnest_device_binding_sha256 "$BOOT_TARGET") PATCHNEST_DEVICE_IDENTITY='unit-test-device-B' export PATCHNEST_DEVICE_IDENTITY -binding_b=$(patchnest_device_binding_sha256) +binding_b=$(patchnest_device_binding_sha256 "$BOOT_TARGET") [ "$binding_a" != "$binding_b" ] || fail "device binding does not distinguish device identity" echo "flash safety contract: PASS" From c75c6c796707be8cb303d182e47362d9f027d527 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:44:26 +0800 Subject: [PATCH 73/94] fix(boot): make bootloop recovery actually restore bound backup --- module/service.sh | 104 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/module/service.sh b/module/service.sh index 0c77726..6a24ed3 100644 --- a/module/service.sh +++ b/module/service.sh @@ -8,6 +8,9 @@ REHOOK="$(cat "$PNDIR/rehook" 2>/dev/null || true)" LOG="$PNDIR/service.log" KPM_DIR="$PNDIR/kpm" KPM_EVENT_DIR="$PNDIR/kpm_events" +BOOT_COUNT_FILE="$PNDIR/boot_count" +AUTO_UNPATCH_REQUEST="$PNDIR/auto_unpatch_requested" +AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" get_prop() { grep "^${1}=" "$2" 2>/dev/null | head -1 | cut -d'=' -f2- @@ -63,9 +66,6 @@ try_pending_public1158_key() { command -v patchnest_read_key_file >/dev/null 2>&1 || return 1 command -v patchnest_pending_transaction_matches_written_key >/dev/null 2>&1 || return 1 command -v patchnest_commit_binding_from_pending_written >/dev/null 2>&1 || return 1 - - # This is only the crash window after a verified boot write but before the - # credential/binding commit. A committed key is never replaced here. [ ! -e "$PATCHNEST_SUPERKEY_FILE" ] || return 1 [ -e "$PATCHNEST_SUPERKEY_PENDING_FILE" ] || return 1 [ -e "$PATCHNEST_PENDING_TRANSACTION_FILE" ] || return 1 @@ -74,7 +74,6 @@ try_pending_public1158_key() { echo "[$(date)] ERROR: pending Public1158 key is insecure or invalid" >> "$LOG" return 1 } - if ! patchnest_pending_transaction_matches_written_key "$_pn_pending_key"; then echo "[$(date)] ERROR: pending key has no matching verified written transaction" >> "$LOG" _pn_pending_key='' @@ -89,10 +88,6 @@ try_pending_public1158_key() { return 1 fi - # The exact key/device/target/patched-byte tuple is now proven twice: by the - # pending transaction hash checks and by the kernel hello authentication. - # Promote the credential, then reconstruct rollback authorization from that - # same durable written transaction before allowing normal runtime mutation. if ! mv -f "$PATCHNEST_SUPERKEY_PENDING_FILE" "$PATCHNEST_SUPERKEY_FILE"; then echo "[$(date)] ERROR: authenticated pending key could not be promoted" >> "$LOG" _pn_pending_key='' @@ -106,9 +101,8 @@ try_pending_public1158_key() { fi if ! patchnest_commit_binding_from_pending_written "$_pn_pending_key"; then - # Keep the active credential: it is the only authenticated access to the - # already-running patched kernel. But do not load KPMs or apply other - # mutations without a valid rollback authorization. + # Keep the active key: it is the only authenticated access to the + # already-running patched kernel. Block all further mutations instead. echo "[$(date)] ERROR: pending key recovered, but rollback binding reconstruction failed" >> "$LOG" patchnest_mark_recovery_required "pending_key_promoted_binding_recovery_failed" || true touch "$MODDIR/unresolved" @@ -124,6 +118,79 @@ try_pending_public1158_key() { return 0 } +resolve_runtime_boot_target() { + _pn_out=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_extract.sh" false 2>>"$LOG") || return 1 + printf '%s\n' "$_pn_out" >> "$LOG" + _pn_target=$(printf '%s\n' "$_pn_out" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) + [ -n "$_pn_target" ] || return 1 + _pn_target=$(readlink -f "$_pn_target" 2>/dev/null || printf '%s' "$_pn_target") + [ -e "$_pn_target" ] || return 1 + printf '%s\n' "$_pn_target" +} + +request_reboot_after_recovery() { + sync + if command -v setprop >/dev/null 2>&1; then + setprop sys.powerctl reboot 2>>"$LOG" || true + elif command -v reboot >/dev/null 2>&1; then + reboot 2>>"$LOG" || true + fi +} + +handle_requested_auto_recovery() { + [ -f "$AUTO_UNPATCH_REQUEST" ] || return 0 + echo "[$(date)] AUTO-RECOVERY: boot failure threshold reached; refusing normal runtime mutations" >> "$LOG" + + _pn_target=$(resolve_runtime_boot_target) || { + echo "[$(date)] ERROR: auto-recovery cannot resolve exact boot target" >> "$LOG" + patchnest_mark_recovery_required "auto_recovery_target_resolution_failed" || true + return 1 + } + + # Normal case: a committed binding exists and recovery needs no working + # kernel ABI at all. This is deliberately attempted before hello/KPM work. + if PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$_pn_target" >>"$LOG" 2>&1; then + echo "[$(date)] AUTO-RECOVERY: exact rollback restored and read back" >> "$LOG" + touch "$PNDIR/auto_recovery_restored" + rm -f "$AUTO_UNPATCH_REQUEST" + request_reboot_after_recovery + return 10 + fi + + # Crash-window fallback: the boot write may have completed before key and + # binding commit. Only a state=written transaction + authenticated pending + # key is allowed to reconstruct rollback authorization, then retry restore. + echo "[$(date)] AUTO-RECOVERY: committed binding unavailable; checking verified pending transaction" >> "$LOG" + if try_pending_public1158_key; then + if PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_unpatch.sh" --restore-bound-backup "$_pn_target" >>"$LOG" 2>&1; then + echo "[$(date)] AUTO-RECOVERY: recovered binding and restored exact backup" >> "$LOG" + touch "$PNDIR/auto_recovery_restored" + rm -f "$AUTO_UNPATCH_REQUEST" + request_reboot_after_recovery + return 10 + fi + fi + + echo "[$(date)] CRITICAL: automatic transaction-bound recovery failed" >> "$LOG" + patchnest_mark_recovery_required "automatic_bootloop_recovery_failed" || true + touch "$MODDIR/unresolved" + return 1 +} + +# A boot-loop recovery request is a higher-priority safety action than ABI +# probing or KPM/exclusion mutations. Attempt rollback before normal service. +if [ -f "$AUTO_UNPATCH_REQUEST" ]; then + handle_requested_auto_recovery + _pn_auto_rc=$? + case "$_pn_auto_rc" in + 10) exit 0 ;; + *) exit 0 ;; + esac +fi + retries=0 max_retries=5 hello_out="" @@ -160,8 +227,8 @@ esac echo "[$(date)] kpatch hello OK: $hello_out profile=$ABI_PROFILE" >> "$LOG" printf '%s\n' "$ABI_PROFILE" > "$PNDIR/abi_profile" -echo "0" > "$PNDIR/boot_count" 2>/dev/null -rm -f "$PNDIR/autorecovery_active" "$PNDIR/auto_unpatch_requested" +# IMPORTANT: hello does not prove a healthy Android boot. boot_count and +# autorecovery markers are cleared only after sys.boot_completed=1 below. for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do [ -e "$kpm" ] || continue @@ -238,16 +305,23 @@ dispatch_event() { dispatch_event "POST_FS_DATA" || true wait_count=0 +boot_completed=0 until [ "$(getprop sys.boot_completed)" = "1" ]; do sleep 1 wait_count=$((wait_count + 1)) if [ "$wait_count" -ge 300 ]; then - echo "[$(date)] WARN: boot_completed timeout; BOOT_COMPLETED event will not be forged" >> "$LOG" + echo "[$(date)] ERROR: boot_completed timeout; failed-boot counter intentionally retained" >> "$LOG" + touch "$MODDIR/unresolved" break fi done + if [ "$(getprop sys.boot_completed)" = "1" ]; then + boot_completed=1 dispatch_event "BOOT_COMPLETED" || true + echo "0" > "$BOOT_COUNT_FILE" 2>/dev/null + rm -f "$AUTORECOVERY_MARKER" "$AUTO_UNPATCH_REQUEST" + echo "[$(date)] healthy boot confirmed; bootloop counter reset" >> "$LOG" fi if [ -f "$CONFIG" ]; then @@ -279,4 +353,4 @@ if [ -f "$CONFIG" ]; then [ "$excluded_failed" -eq 0 ] || touch "$MODDIR/unresolved" fi -echo "[$(date)] service.sh completed" >> "$LOG" +echo "[$(date)] service.sh completed boot_completed=$boot_completed" >> "$LOG" From d1d226f43cc42aaaed2d5ed8a8c84e2524744637 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:45:13 +0800 Subject: [PATCH 74/94] test(boot): execute automatic rollback trigger contract --- tests/bootloop_recovery_contract.sh | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/bootloop_recovery_contract.sh diff --git a/tests/bootloop_recovery_contract.sh b/tests/bootloop_recovery_contract.sh new file mode 100644 index 0000000..1583b69 --- /dev/null +++ b/tests/bootloop_recovery_contract.sh @@ -0,0 +1,114 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +POSTFS="$ROOT/module/post-fs-data.sh" +SERVICE="$ROOT/module/service.sh" + +fail() { + echo "bootloop recovery contract: FAIL: $*" >&2 + exit 1 +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +# ---- Phase 1: three unconfirmed boots must arm recovery --------------------- +POSTDIR="$TMP/postfs-module" +POSTSTATE="$TMP/postfs-state" +SERVICED="$TMP/service.d" +mkdir -p "$POSTDIR" "$POSTSTATE" "$SERVICED" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$POSTDIR/status.sh" +chmod 0755 "$POSTDIR/status.sh" + +# Redirect only hard-coded persistent paths; execute production logic otherwise. +sed \ + -e "s#SERVICE_D=\"/data/adb/service.d\"#SERVICE_D=\"$SERVICED\"#" \ + -e "s#PNDIR=\"/data/adb/patchnest\"#PNDIR=\"$POSTSTATE\"#" \ + "$POSTFS" > "$POSTDIR/post-fs-data.sh" +chmod 0755 "$POSTDIR/post-fs-data.sh" + +sh "$POSTDIR/post-fs-data.sh" +[ "$(cat "$POSTSTATE/boot_count")" = "1" ] || fail "first boot did not increment counter to 1" +[ ! -e "$POSTSTATE/auto_unpatch_requested" ] || fail "recovery armed too early after first boot" +sh "$POSTDIR/post-fs-data.sh" +[ "$(cat "$POSTSTATE/boot_count")" = "2" ] || fail "second boot did not increment counter to 2" +[ ! -e "$POSTSTATE/auto_unpatch_requested" ] || fail "recovery armed too early after second boot" +sh "$POSTDIR/post-fs-data.sh" +[ "$(cat "$POSTSTATE/boot_count")" = "3" ] || fail "third boot did not reach threshold 3" +[ -e "$POSTSTATE/auto_unpatch_requested" ] || fail "third failed boot did not arm auto rollback" +[ -e "$POSTSTATE/autorecovery_active" ] || fail "third failed boot did not surface recovery marker" + +# ---- Phase 2: service must restore before any normal mutation --------------- +MOD="$TMP/runtime-module" +STATE="$TMP/runtime-state" +TARGET="$TMP/boot-target.img" +mkdir -p "$MOD/bin" "$MOD/patch" "$STATE/kpm/failed" "$STATE/kpm_events" +printf '%s\n' 'patched boot' > "$TARGET" +touch "$STATE/auto_unpatch_requested" +printf '3\n' > "$STATE/boot_count" + +# service requires an executable kpatch, but auto-recovery must exit before it +# is invoked. Any invocation is recorded as a contract failure. +cat > "$MOD/bin/kpatch" <> "$TMP/kpatch.calls" +exit 99 +EOF +chmod 0755 "$MOD/bin/kpatch" + +# Fake Android power-control command: record reboot request without rebooting CI. +cat > "$MOD/bin/setprop" <> "$TMP/setprop.calls" +exit 0 +EOF +chmod 0755 "$MOD/bin/setprop" + +cat > "$MOD/patch/boot_extract.sh" < "$MOD/patch/boot_unpatch.sh" <> "$TMP/restore.calls" +[ "\${1:-}" = '--restore-bound-backup' ] || exit 91 +[ "\${2:-}" = '$TARGET' ] || exit 92 +exit 0 +EOF +chmod 0755 "$MOD/patch/boot_unpatch.sh" + +# Optional sourced helpers may be absent on the success path; create no-op +# files to keep this harness explicit and close to package layout. +printf '%s\n' '#!/bin/sh' > "$MOD/kpm_verify.sh" +printf '%s\n' '#!/bin/sh' > "$MOD/patch/superkey_safety.sh" +printf '%s\n' '#!/bin/sh' > "$MOD/patch/transaction_safety.sh" + +sed "s#PNDIR=\"/data/adb/patchnest\"#PNDIR=\"$STATE\"#" "$SERVICE" > "$MOD/service.sh" +chmod 0755 "$MOD/service.sh" + +PATH="$MOD/bin:$PATH" sh "$MOD/service.sh" + +[ -s "$TMP/restore.calls" ] || fail "service did not invoke transaction-bound restore" +grep -Fxq -- "--restore-bound-backup $TARGET" "$TMP/restore.calls" \ + || fail "service restore did not bind exact target" +[ ! -e "$TMP/kpatch.calls" ] || fail "service invoked kpatch before automatic rollback" +[ -e "$STATE/auto_recovery_restored" ] || fail "service did not record successful auto recovery" +[ ! -e "$STATE/auto_unpatch_requested" ] || fail "service left auto-unpatch request armed after restore" +grep -Fxq 'sys.powerctl reboot' "$TMP/setprop.calls" || fail "service did not request reboot after rollback" + +# The boot counter may only be reset inside the boot_completed branch, never +# immediately after hello. Keep this ordering assertion alongside execution. +complete_line=$(grep -n 'if \[ "$(getprop sys.boot_completed)" = "1" \]; then' "$SERVICE" | tail -n1 | cut -d: -f1) +reset_line=$(grep -n 'echo "0" > "$BOOT_COUNT_FILE"' "$SERVICE" | tail -n1 | cut -d: -f1) +[ -n "$complete_line" ] && [ -n "$reset_line" ] || fail "healthy-boot reset structure missing" +[ "$reset_line" -gt "$complete_line" ] || fail "boot counter resets before boot_completed proof" + +# Auto-recovery handling must precede the normal hello retry loop. +auto_line=$(grep -n 'handle_requested_auto_recovery' "$SERVICE" | tail -n1 | cut -d: -f1) +hello_loop_line=$(grep -n '^while \[ "$retries" -lt "$max_retries" \]; do' "$SERVICE" | head -n1 | cut -d: -f1) +[ "$auto_line" -lt "$hello_loop_line" ] || fail "normal ABI probing can run before auto-recovery gate" + +echo "bootloop recovery contract: PASS" From 7c2f9a74afb3cb018657870609897e37b7f105f1 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:45:33 +0800 Subject: [PATCH 75/94] ci(boot): execute bootloop automatic recovery contract --- .github/workflows/flash-safety.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 46efd41..5ee7c71 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -9,6 +9,7 @@ on: paths: - 'module/patch/**' - 'module/service.sh' + - 'module/post-fs-data.sh' - 'module/customize.sh' - 'module/device_validation.sh' - 'scripts/device_validation.sh' @@ -16,6 +17,7 @@ on: - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' + - 'tests/bootloop_recovery_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' push: @@ -25,6 +27,7 @@ on: paths: - 'module/patch/**' - 'module/service.sh' + - 'module/post-fs-data.sh' - 'module/customize.sh' - 'module/device_validation.sh' - 'scripts/device_validation.sh' @@ -32,6 +35,7 @@ on: - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' + - 'tests/bootloop_recovery_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' workflow_dispatch: @@ -52,7 +56,7 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh module/customize.sh module/device_validation.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -68,12 +72,14 @@ jobs: module/patch/transactional_flash.sh \ module/patch/superkey_safety.sh \ module/service.sh \ + module/post-fs-data.sh \ module/customize.sh \ module/device_validation.sh \ scripts/device_validation.sh \ scripts/package_module.sh \ tests/flash_safety_contract.sh \ tests/destructive_transaction_contract.sh \ + tests/bootloop_recovery_contract.sh \ tests/runtime_abi_contract.sh - name: Run baseline flash contract @@ -86,6 +92,9 @@ jobs: PATCHNEST_TRANSACTION_TEST: '1' run: sh tests/destructive_transaction_contract.sh + - name: Run bootloop automatic recovery contract + run: sh tests/bootloop_recovery_contract.sh + - name: Run runtime ABI contract run: sh tests/runtime_abi_contract.sh From d5fcf3ef889777c443192669df9eb00751a764d6 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:46:21 +0800 Subject: [PATCH 76/94] feat(device): add controlled auto-recovery validation trigger --- module/arm_auto_recovery.sh | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 module/arm_auto_recovery.sh diff --git a/module/arm_auto_recovery.sh b/module/arm_auto_recovery.sh new file mode 100644 index 0000000..1b2de1c --- /dev/null +++ b/module/arm_auto_recovery.sh @@ -0,0 +1,35 @@ +#!/system/bin/sh +# Controlled FR-014 test trigger. This script never writes the boot partition. +# It only arms the same bootloop request that post-fs-data.sh would create after +# three failed boots. service.sh performs the actual transaction-bound restore. + +set -eu +MODDIR=${0%/*} +PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} + +[ "$(id -u 2>/dev/null)" = "0" ] || { echo "! root shell required" >&2; exit 1; } +[ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "AUTO_RECOVERY" ] || { + echo "! Refusing to arm automatic rollback without:" >&2 + echo "! PATCHNEST_DEVICE_TEST_UNLOCK=AUTO_RECOVERY" >&2 + exit 2 +} + +[ -x "$MODDIR/device_validation.sh" ] || { echo "! device_validation.sh missing" >&2; exit 1; } +[ ! -e "$PNDIR/transaction.pending.json" ] || { echo "! unfinished transaction exists" >&2; exit 1; } +[ ! -e "$PNDIR/flash_recovery_required" ] || { echo "! recovery is already required" >&2; exit 1; } + +# Prove that a live exact rollback is eligible before arming the bootloop path. +sh "$MODDIR/device_validation.sh" rollback-check || { + echo "! rollback is not eligible; auto-recovery test not armed" >&2 + exit 1 +} + +mkdir -p "$PNDIR" +printf '3\n' > "$PNDIR/boot_count" +touch "$PNDIR/autorecovery_active" "$PNDIR/auto_unpatch_requested" +sync + +echo "AUTO_RECOVERY_ARMED" +echo "Reboot once. PatchNest service must restore the transaction-bound backup" +echo "and request a second reboot. After the device reaches Android again, run:" +echo " sh $MODDIR/verify_auto_recovery.sh" From 2b86657e40c03f6c68ad2ebc928e55816ae3e072 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:46:34 +0800 Subject: [PATCH 77/94] feat(device): verify automatic rollback after reboot --- module/verify_auto_recovery.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 module/verify_auto_recovery.sh diff --git a/module/verify_auto_recovery.sh b/module/verify_auto_recovery.sh new file mode 100644 index 0000000..3d0dfa9 --- /dev/null +++ b/module/verify_auto_recovery.sh @@ -0,0 +1,26 @@ +#!/system/bin/sh +# FR-014 automatic bootloop-recovery verifier. + +set -eu +MODDIR=${0%/*} +PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} + +[ "$(id -u 2>/dev/null)" = "0" ] || { echo "! root shell required" >&2; exit 1; } +[ -x "$MODDIR/device_validation.sh" ] || { echo "! device_validation.sh missing" >&2; exit 1; } +[ -e "$PNDIR/auto_recovery_restored" ] || { + echo "! automatic rollback evidence marker is missing" >&2 + exit 1 +} + +sh "$MODDIR/device_validation.sh" postrestore + +[ ! -e "$PNDIR/auto_unpatch_requested" ] || { + echo "! auto-unpatch request is still armed after recovery" >&2 + exit 1 +} +[ ! -e "$PNDIR/rollback_binding.json" ] || { + echo "! rollback authorization still exists after automatic restore" >&2 + exit 1 +} + +printf '%s\n' "AUTO_RECOVERY_VERIFIED" From 51f04ecb6522becff4fba85e776860b6dab34c5d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:46:57 +0800 Subject: [PATCH 78/94] fix(install): install automatic recovery validation tools --- module/customize.sh | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/module/customize.sh b/module/customize.sh index 8c3e355..21e05ba 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -34,7 +34,9 @@ ui_print "- Architecture: $ARCH" set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 set_perm_recursive "$MODPATH/patch" 0 0 0755 0755 -[ ! -f "$MODPATH/device_validation.sh" ] || set_perm "$MODPATH/device_validation.sh" 0 0 0755 +for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do + [ ! -f "$MODPATH/$_pn_tool" ] || set_perm "$MODPATH/$_pn_tool" 0 0 0755 +done mkdir -p /data/adb/patchnest @@ -59,9 +61,11 @@ fi if [ ! -x "$MODPATH/patch/boot_patch.sh" ] || [ ! -x "$MODPATH/patch/boot_unpatch.sh" ]; then abort "! PatchNest boot transaction scripts are not executable" fi -if [ ! -x "$MODPATH/device_validation.sh" ]; then - abort "! Physical-device validation harness is missing or not executable" -fi +for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do + if [ ! -x "$MODPATH/$_pn_tool" ]; then + abort "! Required physical-validation tool missing or not executable: $_pn_tool" + fi +done echo "$ROOT_MGR" > /data/adb/patchnest/root_manager @@ -77,8 +81,10 @@ cp -rf "$MODPATH/webroot"/* "$MODDIR/webroot/" 2>/dev/null || true cp -rf "$MODPATH/bin"/* "$MODDIR/bin/" 2>/dev/null || true cp -rf "$MODPATH/patch"/* "$MODDIR/patch/" 2>/dev/null || true cp -f "$MODPATH/detect_env.sh" "$MODDIR/detect_env.sh" 2>/dev/null || true -cp -f "$MODPATH/device_validation.sh" "$MODDIR/device_validation.sh" 2>/dev/null || true -chmod 0755 "$MODDIR/patch"/*.sh "$MODDIR/device_validation.sh" 2>/dev/null || true +for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do + cp -f "$MODPATH/$_pn_tool" "$MODDIR/$_pn_tool" 2>/dev/null || true +done +chmod 0755 "$MODDIR/patch"/*.sh "$MODDIR/device_validation.sh" "$MODDIR/arm_auto_recovery.sh" "$MODDIR/verify_auto_recovery.sh" 2>/dev/null || true ui_print "- Installation complete" ui_print "" From e984ae9eec27a1bf443c3fcb2f7e1568b73626bc Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:47:19 +0800 Subject: [PATCH 79/94] test(package): require automatic recovery validation tools --- scripts/package_module.sh | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index 0f9a7c8..6536813 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -23,16 +23,11 @@ command -v unzip >/dev/null 2>&1 || { echo "unzip is required" >&2; exit 1; } command -v sort >/dev/null 2>&1 || { echo "sort is required" >&2; exit 1; } STAGE=$(mktemp -d) -cleanup() { - rm -rf "$STAGE" -} +cleanup() { rm -rf "$STAGE"; } trap cleanup EXIT HUP INT TERM mkdir -p "$STAGE/module" cp -a "$SOURCE_DIR/." "$STAGE/module/" - -# ZIP's DOS timestamp field cannot represent dates before 1980. A fixed UTC -# timestamp makes package bytes independent from checkout/build wall-clock time. find "$STAGE/module" -exec touch -h -t 200001010000.00 {} + mkdir -p "$(dirname "$OUTPUT_ABS")" @@ -40,21 +35,12 @@ rm -f "$OUTPUT_ABS" ( cd "$STAGE/module" - # Stable lexical path order + -X (no UID/GID/extra timestamp fields). - # Strip the find(1) "./" prefix so module.prop and META-INF live at the - # canonical ZIP root expected by Android root-manager installers. find . -type f -print | sed 's#^\./##' | LC_ALL=C sort > "$STAGE/file-list" - [ -s "$STAGE/file-list" ] || { - echo "module tree contains no files" >&2 - exit 1 - } + [ -s "$STAGE/file-list" ] || { echo "module tree contains no files" >&2; exit 1; } zip -X -q "$OUTPUT_ABS" -@ < "$STAGE/file-list" ) [ -s "$OUTPUT_ABS" ] || { echo "deterministic package is empty" >&2; exit 1; } - -# The archive itself is the release boundary. Fail even when the source tree is -# correct if any flash-safety/runtime artifact was omitted from the ZIP. unzip -Z1 "$OUTPUT_ABS" > "$STAGE/zip-list" for required in \ module.prop \ @@ -62,6 +48,8 @@ for required in \ customize.sh \ service.sh \ device_validation.sh \ + arm_auto_recovery.sh \ + verify_auto_recovery.sh \ patch/boot_patch.sh \ patch/boot_unpatch.sh \ patch/flash_safety.sh \ @@ -74,7 +62,6 @@ for required in \ } done -# Canonical archive paths only: no traversal and no find(1) ./ prefixes. if grep -Eq '(^|/)\.\.(/|$)|^\./' "$STAGE/zip-list"; then echo "unsafe or non-canonical path found in module ZIP" >&2 exit 1 From 78566327ff31d173336ce442354c8ffeaa146118 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:47:35 +0800 Subject: [PATCH 80/94] ci(device): validate packaged automatic recovery tools --- .github/workflows/flash-safety.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 5ee7c71..39636c6 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -12,6 +12,8 @@ on: - 'module/post-fs-data.sh' - 'module/customize.sh' - 'module/device_validation.sh' + - 'module/arm_auto_recovery.sh' + - 'module/verify_auto_recovery.sh' - 'scripts/device_validation.sh' - 'scripts/package_module.sh' - 'version.properties' @@ -30,6 +32,8 @@ on: - 'module/post-fs-data.sh' - 'module/customize.sh' - 'module/device_validation.sh' + - 'module/arm_auto_recovery.sh' + - 'module/verify_auto_recovery.sh' - 'scripts/device_validation.sh' - 'scripts/package_module.sh' - 'version.properties' @@ -56,7 +60,7 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh module/arm_auto_recovery.sh module/verify_auto_recovery.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -75,6 +79,8 @@ jobs: module/post-fs-data.sh \ module/customize.sh \ module/device_validation.sh \ + module/arm_auto_recovery.sh \ + module/verify_auto_recovery.sh \ scripts/device_validation.sh \ scripts/package_module.sh \ tests/flash_safety_contract.sh \ From 5f770cf7126a03263a110eec2569860cdade2ef5 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:51:34 +0800 Subject: [PATCH 81/94] test(flash): source superkey helper in parent transaction scope --- tests/flash_safety_contract.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh index 4010e44..5d1924a 100644 --- a/tests/flash_safety_contract.sh +++ b/tests/flash_safety_contract.sh @@ -177,7 +177,11 @@ actual=$(sha256sum "$TMP/target.img" | awk '{print $1}') ) # Commit and inspect a real destructive rollback record. A state=written -# transaction is mandatory before the binding can be committed. +# transaction is mandatory before the binding can be committed. Source the +# superkey helper in this parent shell because transaction staging depends on +# patchnest_superkey_sha256(). +# shellcheck disable=SC1090 +. "$SUPERKEY" PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/transaction-state/rollback_binding.json" PATCHNEST_PENDING_TRANSACTION_FILE="$TMP/transaction-state/transaction.pending.json" PATCHNEST_RECOVERY_REQUIRED_FILE="$TMP/transaction-state/flash_recovery_required" From 399b88a9c6585676de067535934baa6b3354a581 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:52:43 +0800 Subject: [PATCH 82/94] fix(install): use manager-provided MODPATH without hardcoded module copy --- module/customize.sh | 114 ++++++++++++++++++++++++-------------------- 1 file changed, 61 insertions(+), 53 deletions(-) diff --git a/module/customize.sh b/module/customize.sh index 21e05ba..f771f43 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -1,91 +1,99 @@ #!/system/bin/sh -MODDIR="/data/adb/modules/PatchNest" +# PatchNest installer customization. This file is sourced by the root manager +# after the module ZIP has already been extracted into $MODPATH. -# This review branch is intentionally non-flashable until physical-device -# lifecycle validation is complete. The marker is checked before any persistent -# PatchNest state is created. -if [ -f "${MODPATH:-$MODDIR}/FLASH_REVIEW_BLOCKED" ]; then +if [ -z "${MODPATH:-}" ] || [ ! -d "$MODPATH" ]; then + abort "! MODPATH is empty or missing: '${MODPATH:-}'" +fi + +# Review packages are deliberately non-installable. This check must remain +# before every persistent PatchNest write. +if [ -f "$MODPATH/FLASH_REVIEW_BLOCKED" ]; then ui_print "! PatchNest review build: flashing is intentionally blocked" ui_print "! Physical-device flash-readiness gate is still open" ui_print "! Use the isolated device-validation candidate only for FR-014" abort "! FLASH_REVIEW_BLOCKED" fi -[ -z "${MODPATH:-}" ] && MODPATH="$MODDIR" -if [ -z "$MODPATH" ] || [ ! -d "$MODPATH" ]; then - abort "! MODPATH is empty or missing: '$MODPATH'" +if [ "${ARCH:-}" != "arm64" ]; then + abort "! Only arm64 is supported" fi -if [ "$ARCH" != "arm64" ]; then - abort "! Only arm64 is supported" +# Production state is fixed. The alternate path is accepted only by the +# repository's installer contract and cannot be selected accidentally in a +# normal manager installation. +PNDIR="/data/adb/patchnest" +if [ "${PATCHNEST_INSTALL_TEST:-0}" = "1" ]; then + if [ -z "${PATCHNEST_STATE_DIR:-}" ]; then + abort "! PATCHNEST_STATE_DIR is required in installer-test mode" + fi + PNDIR=$PATCHNEST_STATE_DIR fi ROOT_MGR="unknown" -if [ -n "$APATCH" ]; then +if [ -n "${APATCH:-}" ]; then ROOT_MGR="apatch" -elif [ -n "$KSU" ]; then +elif [ -n "${KSU:-}" ]; then ROOT_MGR="ksu" -elif [ -n "$MAGISK_VER" ]; then +elif [ -n "${MAGISK_VER:-}" ]; then ROOT_MGR="magisk" fi ui_print "- Root manager: $ROOT_MGR" ui_print "- Architecture: $ARCH" +# The manager already extracted the final module tree into MODPATH. Only set +# explicit permissions; never copy the tree into a hard-coded manager path. set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 set_perm_recursive "$MODPATH/patch" 0 0 0755 0755 for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do [ ! -f "$MODPATH/$_pn_tool" ] || set_perm "$MODPATH/$_pn_tool" 0 0 0755 done -mkdir -p /data/adb/patchnest - -if [ -f "$MODPATH/repos.json" ]; then - cp "$MODPATH/repos.json" /data/adb/patchnest/repos.json - ui_print "- Installed system repos.json" -fi - -if [ -f "/data/adb/ap/package_config" ] && [ ! -f "/data/adb/patchnest/package_config" ]; then - cp "/data/adb/ap/package_config" /data/adb/patchnest/package_config - ui_print "- Migrated APatch package_config" -fi - -ui_print "- Installing KernelPatch binaries..." - -if [ ! -x "$MODPATH/bin/kpatch" ]; then - abort "! kpatch binary missing or not executable in $MODPATH/bin" -fi -if [ ! -x "$MODPATH/bin/kptools" ]; then - abort "! kptools binary missing or not executable in $MODPATH/bin" -fi -if [ ! -x "$MODPATH/patch/boot_patch.sh" ] || [ ! -x "$MODPATH/patch/boot_unpatch.sh" ]; then - abort "! PatchNest boot transaction scripts are not executable" +# Fail before creating persistent state if the extracted install tree is not a +# complete runnable package. +for _pn_bin in kpatch kptools magiskboot; do + if [ ! -x "$MODPATH/bin/$_pn_bin" ]; then + abort "! Required binary missing or not executable: bin/$_pn_bin" + fi +done +if [ ! -s "$MODPATH/bin/kpimg" ]; then + abort "! Required KernelPatch image missing or empty: bin/kpimg" fi +for _pn_script in \ + boot_patch.sh \ + boot_extract.sh \ + boot_unpatch.sh \ + flash_safety.sh \ + transaction_safety.sh \ + transactional_flash.sh \ + superkey_safety.sh; do + if [ ! -x "$MODPATH/patch/$_pn_script" ]; then + abort "! Required patch helper missing or not executable: patch/$_pn_script" + fi +done for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do if [ ! -x "$MODPATH/$_pn_tool" ]; then abort "! Required physical-validation tool missing or not executable: $_pn_tool" fi done -echo "$ROOT_MGR" > /data/adb/patchnest/root_manager +mkdir -p "$PNDIR" || abort "! Cannot create PatchNest state directory" +chmod 0700 "$PNDIR" 2>/dev/null || true -cp "$MODPATH/module.prop" "$MODPATH/module.prop.bak" +if [ -f "$MODPATH/repos.json" ]; then + cp "$MODPATH/repos.json" "$PNDIR/repos.json" || abort "! Cannot install repos.json" +fi -rm -rf "$MODDIR/webroot"/* 2>/dev/null || true -rm -rf "$MODDIR/bin"/* 2>/dev/null || true -rm -rf "$MODDIR/patch"/* 2>/dev/null || true -[ -d "$MODDIR/webroot" ] || mkdir -p "$MODDIR/webroot" -[ -d "$MODDIR/bin" ] || mkdir -p "$MODDIR/bin" -[ -d "$MODDIR/patch" ] || mkdir -p "$MODDIR/patch" -cp -rf "$MODPATH/webroot"/* "$MODDIR/webroot/" 2>/dev/null || true -cp -rf "$MODPATH/bin"/* "$MODDIR/bin/" 2>/dev/null || true -cp -rf "$MODPATH/patch"/* "$MODDIR/patch/" 2>/dev/null || true -cp -f "$MODPATH/detect_env.sh" "$MODDIR/detect_env.sh" 2>/dev/null || true -for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do - cp -f "$MODPATH/$_pn_tool" "$MODDIR/$_pn_tool" 2>/dev/null || true -done -chmod 0755 "$MODDIR/patch"/*.sh "$MODDIR/device_validation.sh" "$MODDIR/arm_auto_recovery.sh" "$MODDIR/verify_auto_recovery.sh" 2>/dev/null || true +if [ "$ROOT_MGR" = "apatch" ] && [ -f "/data/adb/ap/package_config" ] && [ ! -f "$PNDIR/package_config" ]; then + cp "/data/adb/ap/package_config" "$PNDIR/package_config" || abort "! Cannot migrate APatch package_config" +fi + +printf '%s\n' "$ROOT_MGR" > "$PNDIR/root_manager" || abort "! Cannot persist root manager identity" +chmod 0600 "$PNDIR/root_manager" 2>/dev/null || true +ui_print "- PatchNest files validated in manager-provided MODPATH" +ui_print "- Persistent state initialized" ui_print "- Installation complete" ui_print "" ui_print " Next steps:" @@ -97,5 +105,5 @@ if [ "$ROOT_MGR" = "magisk" ]; then else ui_print " 2. Open WebUI via Manager → PatchNest → Action" fi -ui_print " 4. Click 'Start' to patch kernel" -ui_print " 5. Reboot again to activate" +ui_print " 4. Run read-only device_validation.sh preflight before patching" +ui_print " 5. Do not remove the review blocker outside the FR-014 candidate" From d87866f0dbdd5c2db34899ccf53f2593456ffd86 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:53:27 +0800 Subject: [PATCH 83/94] test(install): execute cross-manager MODPATH installer contract --- tests/installer_contract.sh | 124 ++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/installer_contract.sh diff --git a/tests/installer_contract.sh b/tests/installer_contract.sh new file mode 100644 index 0000000..0fff79e --- /dev/null +++ b/tests/installer_contract.sh @@ -0,0 +1,124 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +CUSTOMIZE="$ROOT/module/customize.sh" + +fail() { + echo "installer contract: FAIL: $*" >&2 + exit 1 +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +# Production installer must never hard-code the final module directory or copy +# the extracted module tree into another location. MODPATH is the install root. +! grep -Fq '/data/adb/modules/PatchNest' "$CUSTOMIZE" \ + || fail "installer hard-codes /data/adb/modules/PatchNest" +! grep -Eq 'cp[[:space:]].*\$MODPATH/(bin|patch|webroot).*\/data\/adb\/modules' "$CUSTOMIZE" \ + || fail "installer copies extracted module tree to a second manager path" +grep -Fq 'set_perm_recursive "$MODPATH/bin"' "$CUSTOMIZE" || fail "installer does not permission binaries in MODPATH" +grep -Fq 'set_perm_recursive "$MODPATH/patch"' "$CUSTOMIZE" || fail "installer does not permission patch helpers in MODPATH" + +make_fake_module() { + _pn_dir=$1 + mkdir -p "$_pn_dir/bin" "$_pn_dir/patch" + for _pn_bin in kpatch kptools magiskboot; do + printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_dir/bin/$_pn_bin" + chmod 0644 "$_pn_dir/bin/$_pn_bin" + done + printf '%s\n' 'fake-kpimg' > "$_pn_dir/bin/kpimg" + chmod 0644 "$_pn_dir/bin/kpimg" + + for _pn_script in \ + boot_patch.sh boot_extract.sh boot_unpatch.sh flash_safety.sh \ + transaction_safety.sh transactional_flash.sh superkey_safety.sh; do + printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_dir/patch/$_pn_script" + chmod 0644 "$_pn_dir/patch/$_pn_script" + done + for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do + printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_dir/$_pn_tool" + chmod 0644 "$_pn_dir/$_pn_tool" + done + printf '%s\n' '{"repos":[]}' > "$_pn_dir/repos.json" +} + +run_installer() { + _pn_mod=$1 + _pn_state=$2 + _pn_manager=$3 + _pn_expected=$4 + _pn_log=$5 + + ( + ui_print() { printf 'UI:%s\n' "$*" >> "$_pn_log"; } + abort() { printf 'ABORT:%s\n' "$*" >> "$_pn_log"; exit 99; } + set_perm() { + # target owner group mode [context] + chmod "$4" "$1" + } + set_perm_recursive() { + # dir owner group dirmode filemode [context] + _d=$1; _dm=$4; _fm=$5 + find "$_d" -type d -exec chmod "$_dm" {} + + find "$_d" -type f -exec chmod "$_fm" {} + + } + + MODPATH=$_pn_mod + ARCH=arm64 + PATCHNEST_INSTALL_TEST=1 + PATCHNEST_STATE_DIR=$_pn_state + APATCH='' + KSU='' + MAGISK_VER='' + case "$_pn_manager" in + magisk) MAGISK_VER='v30.0' ;; + ksu) KSU='true'; MAGISK_VER='v25.2' ;; + apatch) APATCH='true' ;; + *) exit 98 ;; + esac + export MODPATH ARCH PATCHNEST_INSTALL_TEST PATCHNEST_STATE_DIR APATCH KSU MAGISK_VER + # shellcheck disable=SC1090 + . "$CUSTOMIZE" + ) + _pn_rc=$? + [ "$_pn_rc" -eq 0 ] || return "$_pn_rc" + [ -f "$_pn_state/root_manager" ] || return 90 + [ "$(cat "$_pn_state/root_manager")" = "$_pn_expected" ] || return 91 + [ "$(stat -c '%a' "$_pn_state/root_manager")" = "600" ] || return 92 + [ "$(stat -c '%a' "$_pn_mod/bin/kpatch")" = "755" ] || return 93 + [ "$(stat -c '%a' "$_pn_mod/patch/boot_patch.sh")" = "755" ] || return 94 + [ "$(stat -c '%a' "$_pn_mod/device_validation.sh")" = "755" ] || return 95 + [ ! -e "$_pn_mod/module.prop.bak" ] || return 96 + return 0 +} + +# Review marker must abort before the state directory is created. +BLOCKED="$TMP/blocked-module" +BLOCKED_STATE="$TMP/blocked-state" +make_fake_module "$BLOCKED" +touch "$BLOCKED/FLASH_REVIEW_BLOCKED" +set +e +run_installer "$BLOCKED" "$BLOCKED_STATE" magisk magisk "$TMP/blocked.log" +blocked_rc=$? +set -e +[ "$blocked_rc" -eq 99 ] || fail "review blocker did not terminate installer (rc=$blocked_rc)" +[ ! -e "$BLOCKED_STATE" ] || fail "review blocker allowed persistent state creation" +grep -Fq 'ABORT:! FLASH_REVIEW_BLOCKED' "$TMP/blocked.log" || fail "review blocker abort reason missing" + +# Candidate behavior: the same already-extracted MODPATH must work under each +# manager convention without copying to a hard-coded final module directory. +for spec in 'magisk:magisk' 'ksu:ksu' 'apatch:apatch'; do + manager=${spec%%:*} + expected=${spec#*:} + mod="$TMP/module-$manager" + state="$TMP/state-$manager" + make_fake_module "$mod" + if ! run_installer "$mod" "$state" "$manager" "$expected" "$TMP/$manager.log"; then + fail "$manager installer simulation failed" + fi + grep -Fq 'UI:- Installation complete' "$TMP/$manager.log" || fail "$manager install did not complete" +done + +echo "installer contract: PASS" From 7ac7206aa4f6ab7fb26cda59449822c55d7bdcc5 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:53:55 +0800 Subject: [PATCH 84/94] ci(install): execute cross-manager installer contract --- .github/workflows/flash-safety.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 39636c6..81d5edb 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -20,6 +20,7 @@ on: - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' - 'tests/bootloop_recovery_contract.sh' + - 'tests/installer_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' push: @@ -40,6 +41,7 @@ on: - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' - 'tests/bootloop_recovery_contract.sh' + - 'tests/installer_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' workflow_dispatch: @@ -60,7 +62,7 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh module/arm_auto_recovery.sh module/verify_auto_recovery.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh module/arm_auto_recovery.sh module/verify_auto_recovery.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/installer_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -86,6 +88,7 @@ jobs: tests/flash_safety_contract.sh \ tests/destructive_transaction_contract.sh \ tests/bootloop_recovery_contract.sh \ + tests/installer_contract.sh \ tests/runtime_abi_contract.sh - name: Run baseline flash contract @@ -101,6 +104,9 @@ jobs: - name: Run bootloop automatic recovery contract run: sh tests/bootloop_recovery_contract.sh + - name: Run cross-manager installer contract + run: sh tests/installer_contract.sh + - name: Run runtime ABI contract run: sh tests/runtime_abi_contract.sh From 9a298d00f61cdce59c8642afa36cd87906b11f87 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:57:54 +0800 Subject: [PATCH 85/94] fix(restore): parse JSON booleans with extended regex --- module/patch/transaction_safety.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index edb0a8f..0731a3c 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -22,7 +22,7 @@ patchnest_json_string() { patchnest_json_bool() { _pn_key=$1 _pn_file=$2 - grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ + grep -Eo "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ | head -n 1 \ | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" } From 91e2b6a1eda43468862452e58591b5b9db5e64e3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 14:58:53 +0800 Subject: [PATCH 86/94] fix(device): parse restore receipt booleans correctly --- module/device_validation.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/device_validation.sh b/module/device_validation.sh index e1e8ed5..a9788d0 100644 --- a/module/device_validation.sh +++ b/module/device_validation.sh @@ -50,7 +50,7 @@ json_number() { json_bool() { _pn_key=$1 _pn_file=$2 - grep -o "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ + grep -Eo "\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false)" "$_pn_file" 2>/dev/null \ | head -n 1 \ | sed -E "s/.*\"${_pn_key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/" } From 3a3fd50583b9a0e2601e443bcd1df815757e1bd7 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:00:09 +0800 Subject: [PATCH 87/94] fix(device): derive module directory from packaged harness path --- module/device_validation.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/device_validation.sh b/module/device_validation.sh index a9788d0..75d6f6b 100644 --- a/module/device_validation.sh +++ b/module/device_validation.sh @@ -6,7 +6,9 @@ set -u MODE=${1:-preflight} [ "$#" -gt 0 ] && shift -MODDIR=${PATCHNEST_MODDIR:-/data/adb/modules/PatchNest} +SCRIPT_DIR=${0%/*} +MODDIR=${PATCHNEST_MODDIR:-$SCRIPT_DIR} +MODDIR=$(readlink -f "$MODDIR" 2>/dev/null || printf '%s' "$MODDIR") PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} STAMP=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) EVIDENCE=${PATCHNEST_EVIDENCE_DIR:-/data/local/tmp/patchnest-evidence-$STAMP} From 386e3df29009b4abba07150d4ae2f1888943528a Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:04:28 +0800 Subject: [PATCH 88/94] feat(recovery): export verified pre-reboot boot recovery image --- module/export_recovery_boot.sh | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 module/export_recovery_boot.sh diff --git a/module/export_recovery_boot.sh b/module/export_recovery_boot.sh new file mode 100644 index 0000000..ce17767 --- /dev/null +++ b/module/export_recovery_boot.sh @@ -0,0 +1,116 @@ +#!/system/bin/sh +# Export an exact, validated copy of the current boot target before any +# destructive PatchNest test. This script never writes the boot partition. + +set -eu +MODDIR=${0%/*} +PNDIR=${PATCHNEST_STATE_DIR:-/data/adb/patchnest} + +[ "$(id -u 2>/dev/null)" = "0" ] || { echo "! root shell required" >&2; exit 1; } +[ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" = "RECOVERY_EXPORT" ] || { + echo "! Refusing recovery export without:" >&2 + echo "! PATCHNEST_DEVICE_TEST_UNLOCK=RECOVERY_EXPORT" >&2 + exit 2 +} + +OUTDIR=/storage/emulated/0/Download +if [ "${PATCHNEST_TRANSACTION_TEST:-0}" = "1" ] && [ -n "${PATCHNEST_RECOVERY_OUTPUT_DIR:-}" ]; then + OUTDIR=$PATCHNEST_RECOVERY_OUTPUT_DIR +fi + +[ -x "$MODDIR/patch/boot_extract.sh" ] || { echo "! boot_extract.sh missing" >&2; exit 1; } +[ -x "$MODDIR/bin/magiskboot" ] || { echo "! magiskboot missing" >&2; exit 1; } +command -v sha256sum >/dev/null 2>&1 || { echo "! sha256sum missing" >&2; exit 1; } + +mkdir -p "$OUTDIR" "$PNDIR" || exit 1 +chmod 0700 "$PNDIR" 2>/dev/null || true + +_resolved=$(PATH="$MODDIR/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH" \ + "$MODDIR/patch/boot_extract.sh" false) || { + echo "! exact boot target resolution failed" >&2 + exit 1 +} +TARGET=$(printf '%s\n' "$_resolved" | sed -n 's/^BOOTIMAGE=//p' | tail -n 1) +[ -n "$TARGET" ] || { echo "! boot target was not emitted" >&2; exit 1; } +TARGET=$(readlink -f "$TARGET" 2>/dev/null || printf '%s' "$TARGET") +[ -e "$TARGET" ] || { echo "! resolved boot target missing: $TARGET" >&2; exit 1; } + +STAMP=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || date +%Y%m%dT%H%M%S) +OUT="$OUTDIR/PatchNest_Recovery_Boot_${STAMP}.img" +MANIFEST="$OUTDIR/PatchNest_Recovery_Boot_${STAMP}.json" +TMP_OUT="${OUT}.partial.$$" +TMP_VALIDATE=$(mktemp -d /data/local/tmp/patchnest-recovery-validate.XXXXXX) || exit 1 +cleanup() { + rm -f "$TMP_OUT" + rm -rf "$TMP_VALIDATE" +} +trap cleanup EXIT HUP INT TERM + +# Read only. The copy is complete before it receives its final visible name. +cat "$TARGET" > "$TMP_OUT" || { echo "! boot recovery copy failed" >&2; exit 1; } +sync +[ -s "$TMP_OUT" ] || { echo "! boot recovery copy is empty" >&2; exit 1; } + +TARGET_SHA=$(sha256sum "$TARGET" | awk '{print $1}') +COPY_SHA=$(sha256sum "$TMP_OUT" | awk '{print $1}') +[ "$TARGET_SHA" = "$COPY_SHA" ] || { + echo "! recovery copy digest differs from live boot target" >&2 + exit 1 +} +SIZE=$(stat -c '%s' "$TMP_OUT" 2>/dev/null) +printf '%s' "$SIZE" | grep -Eq '^[1-9][0-9]*$' || exit 1 + +if ! (cd "$TMP_VALIDATE" && "$MODDIR/bin/magiskboot" unpack "$TMP_OUT" >/dev/null 2>&1); then + echo "! recovery image cannot be unpacked by magiskboot" >&2 + exit 1 +fi +[ -s "$TMP_VALIDATE/kernel" ] || { + echo "! recovery image unpack produced no kernel" >&2 + exit 1 +} + +mv -f "$TMP_OUT" "$OUT" || exit 1 +chmod 0644 "$OUT" 2>/dev/null || true +PRODUCT=$(getprop ro.product.device 2>/dev/null | tr -d '\r\n') +SLOT=$(getprop ro.boot.slot_suffix 2>/dev/null | tr -d '\r\n') + +json_escape() { + printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +cat > "${MANIFEST}.tmp.$$" </dev/null || date +%Y-%m-%dT%H:%M:%S)" +} +EOF +mv -f "${MANIFEST}.tmp.$$" "$MANIFEST" || exit 1 +chmod 0644 "$MANIFEST" 2>/dev/null || true + +umask 077 +cat > "$PNDIR/recovery_export.json.tmp.$$" < Date: Sat, 8 Aug 2026 15:05:07 +0800 Subject: [PATCH 89/94] fix(install): require off-device recovery export tool --- module/customize.sh | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/module/customize.sh b/module/customize.sh index f771f43..80b0217 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -46,7 +46,7 @@ ui_print "- Architecture: $ARCH" # explicit permissions; never copy the tree into a hard-coded manager path. set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 set_perm_recursive "$MODPATH/patch" 0 0 0755 0755 -for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do +for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh export_recovery_boot.sh; do [ ! -f "$MODPATH/$_pn_tool" ] || set_perm "$MODPATH/$_pn_tool" 0 0 0755 done @@ -72,7 +72,7 @@ for _pn_script in \ abort "! Required patch helper missing or not executable: patch/$_pn_script" fi done -for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do +for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh export_recovery_boot.sh; do if [ ! -x "$MODPATH/$_pn_tool" ]; then abort "! Required physical-validation tool missing or not executable: $_pn_tool" fi @@ -96,14 +96,8 @@ ui_print "- PatchNest files validated in manager-provided MODPATH" ui_print "- Persistent state initialized" ui_print "- Installation complete" ui_print "" -ui_print " Next steps:" -ui_print " 1. Reboot your device" -if [ "$ROOT_MGR" = "magisk" ]; then - ui_print " 2. Install KSUWebUIStandalone app" - ui_print " (no native WebUI support in Magisk)" - ui_print " 3. Open WebUI via Manager → Action button" -else - ui_print " 2. Open WebUI via Manager → PatchNest → Action" -fi -ui_print " 4. Run read-only device_validation.sh preflight before patching" -ui_print " 5. Do not remove the review blocker outside the FR-014 candidate" +ui_print " Before the first destructive FR-014 flash:" +ui_print " 1. Run device_validation.sh preflight" +ui_print " 2. Run export_recovery_boot.sh with the explicit RECOVERY_EXPORT unlock" +ui_print " 3. Copy the recovery image + manifest off-device and verify SHA-256" +ui_print " 4. Only then start the controlled flash lifecycle" From 79268482f1f4b48cf4f1cfe2f0a1aa4cbb105d5a Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:05:30 +0800 Subject: [PATCH 90/94] test(package): require off-device recovery export tool --- scripts/package_module.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index 6536813..a91359c 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -50,6 +50,7 @@ for required in \ device_validation.sh \ arm_auto_recovery.sh \ verify_auto_recovery.sh \ + export_recovery_boot.sh \ patch/boot_patch.sh \ patch/boot_unpatch.sh \ patch/flash_safety.sh \ From c626f0a2bc74fba295463b0e3cd300e5b43042fe Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:05:51 +0800 Subject: [PATCH 91/94] test(recovery): execute verified boot recovery export --- tests/recovery_export_contract.sh | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/recovery_export_contract.sh diff --git a/tests/recovery_export_contract.sh b/tests/recovery_export_contract.sh new file mode 100644 index 0000000..39df139 --- /dev/null +++ b/tests/recovery_export_contract.sh @@ -0,0 +1,71 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +EXPORT_SCRIPT="$ROOT/module/export_recovery_boot.sh" + +fail() { + echo "recovery export contract: FAIL: $*" >&2 + exit 1 +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +MOD="$TMP/module" +STATE="$TMP/state" +OUT="$TMP/output" +TARGET="$TMP/boot-target.img" +mkdir -p "$MOD/bin" "$MOD/patch" "$STATE" "$OUT" +printf '%s\n' 'synthetic boot image for recovery export' > "$TARGET" + +cat > "$MOD/patch/boot_extract.sh" < "$MOD/bin/magiskboot" <<'EOF' +#!/bin/sh +if [ "${1:-}" = "unpack" ]; then + printf '%s\n' 'synthetic-kernel' > kernel + exit 0 +fi +exit 1 +EOF +chmod 0755 "$MOD/bin/magiskboot" +cp "$EXPORT_SCRIPT" "$MOD/export_recovery_boot.sh" +chmod 0755 "$MOD/export_recovery_boot.sh" + +PATCHNEST_TRANSACTION_TEST=1 \ +PATCHNEST_DEVICE_TEST_UNLOCK=RECOVERY_EXPORT \ +PATCHNEST_RECOVERY_OUTPUT_DIR="$OUT" \ +PATCHNEST_STATE_DIR="$STATE" \ +sh "$MOD/export_recovery_boot.sh" > "$TMP/export.log" + +grep -Fq 'RECOVERY_EXPORT_VERIFIED' "$TMP/export.log" || fail "export did not report verified state" +IMAGE=$(sed -n 's/^image=//p' "$TMP/export.log" | tail -n1) +MANIFEST=$(sed -n 's/^manifest=//p' "$TMP/export.log" | tail -n1) +SHA=$(sed -n 's/^sha256=//p' "$TMP/export.log" | tail -n1) +[ -f "$IMAGE" ] || fail "recovery image missing" +[ -f "$MANIFEST" ] || fail "recovery manifest missing" +[ -f "$STATE/recovery_export.json" ] || fail "root-only recovery receipt missing" +cmp -s "$IMAGE" "$TARGET" || fail "exported recovery bytes differ from live target" +[ "$(sha256sum "$IMAGE" | awk '{print $1}')" = "$SHA" ] || fail "reported recovery SHA differs from image" +[ "$(stat -c '%a' "$STATE/recovery_export.json")" = "600" ] || fail "recovery receipt mode is not 0600" +grep -Fq '"magiskboot_unpack_verified": true' "$MANIFEST" || fail "manifest lacks unpack verification" +grep -Fq '"verified": true' "$STATE/recovery_export.json" || fail "state receipt lacks verified marker" + +# Wrong unlock must fail before creating another export. +rm -rf "$OUT"/* "$STATE"/* +set +e +PATCHNEST_TRANSACTION_TEST=1 \ +PATCHNEST_DEVICE_TEST_UNLOCK=WRONG \ +PATCHNEST_RECOVERY_OUTPUT_DIR="$OUT" \ +PATCHNEST_STATE_DIR="$STATE" \ +sh "$MOD/export_recovery_boot.sh" >/dev/null 2>&1 +rc=$? +set -e +[ "$rc" -eq 2 ] || fail "wrong unlock did not fail with usage/safety status" +[ -z "$(find "$OUT" -type f -print -quit)" ] || fail "wrong unlock still wrote recovery export" + +echo "recovery export contract: PASS" From f1703c950a3958187e0fe0bf07fec6f177f7d904 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:06:23 +0800 Subject: [PATCH 92/94] ci(recovery): execute off-device recovery export contract --- .github/workflows/flash-safety.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 81d5edb..e14c09c 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -14,6 +14,7 @@ on: - 'module/device_validation.sh' - 'module/arm_auto_recovery.sh' - 'module/verify_auto_recovery.sh' + - 'module/export_recovery_boot.sh' - 'scripts/device_validation.sh' - 'scripts/package_module.sh' - 'version.properties' @@ -21,6 +22,7 @@ on: - 'tests/destructive_transaction_contract.sh' - 'tests/bootloop_recovery_contract.sh' - 'tests/installer_contract.sh' + - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' push: @@ -35,6 +37,7 @@ on: - 'module/device_validation.sh' - 'module/arm_auto_recovery.sh' - 'module/verify_auto_recovery.sh' + - 'module/export_recovery_boot.sh' - 'scripts/device_validation.sh' - 'scripts/package_module.sh' - 'version.properties' @@ -42,6 +45,7 @@ on: - 'tests/destructive_transaction_contract.sh' - 'tests/bootloop_recovery_contract.sh' - 'tests/installer_contract.sh' + - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' - '.github/workflows/flash-safety.yml' workflow_dispatch: @@ -62,7 +66,7 @@ jobs: - name: Shell syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh module/arm_auto_recovery.sh module/verify_auto_recovery.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/installer_contract.sh tests/runtime_abi_contract.sh; do + for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/device_validation.sh module/arm_auto_recovery.sh module/verify_auto_recovery.sh module/export_recovery_boot.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/bootloop_recovery_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -83,12 +87,14 @@ jobs: module/device_validation.sh \ module/arm_auto_recovery.sh \ module/verify_auto_recovery.sh \ + module/export_recovery_boot.sh \ scripts/device_validation.sh \ scripts/package_module.sh \ tests/flash_safety_contract.sh \ tests/destructive_transaction_contract.sh \ tests/bootloop_recovery_contract.sh \ tests/installer_contract.sh \ + tests/recovery_export_contract.sh \ tests/runtime_abi_contract.sh - name: Run baseline flash contract @@ -107,6 +113,9 @@ jobs: - name: Run cross-manager installer contract run: sh tests/installer_contract.sh + - name: Run off-device recovery export contract + run: sudo sh tests/recovery_export_contract.sh + - name: Run runtime ABI contract run: sh tests/runtime_abi_contract.sh From 3b88ce820c7a0f00025005316a947b9b11e28ec6 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:07:52 +0800 Subject: [PATCH 93/94] test(install): include recovery export in fake module --- tests/installer_contract.sh | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/installer_contract.sh b/tests/installer_contract.sh index 0fff79e..8dd03f9 100644 --- a/tests/installer_contract.sh +++ b/tests/installer_contract.sh @@ -12,8 +12,6 @@ fail() { TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT HUP INT TERM -# Production installer must never hard-code the final module directory or copy -# the extracted module tree into another location. MODPATH is the install root. ! grep -Fq '/data/adb/modules/PatchNest' "$CUSTOMIZE" \ || fail "installer hard-codes /data/adb/modules/PatchNest" ! grep -Eq 'cp[[:space:]].*\$MODPATH/(bin|patch|webroot).*\/data\/adb\/modules' "$CUSTOMIZE" \ @@ -37,7 +35,7 @@ make_fake_module() { printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_dir/patch/$_pn_script" chmod 0644 "$_pn_dir/patch/$_pn_script" done - for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh; do + for _pn_tool in device_validation.sh arm_auto_recovery.sh verify_auto_recovery.sh export_recovery_boot.sh; do printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_dir/$_pn_tool" chmod 0644 "$_pn_dir/$_pn_tool" done @@ -54,12 +52,8 @@ run_installer() { ( ui_print() { printf 'UI:%s\n' "$*" >> "$_pn_log"; } abort() { printf 'ABORT:%s\n' "$*" >> "$_pn_log"; exit 99; } - set_perm() { - # target owner group mode [context] - chmod "$4" "$1" - } + set_perm() { chmod "$4" "$1"; } set_perm_recursive() { - # dir owner group dirmode filemode [context] _d=$1; _dm=$4; _fm=$5 find "$_d" -type d -exec chmod "$_dm" {} + find "$_d" -type f -exec chmod "$_fm" {} + @@ -90,11 +84,11 @@ run_installer() { [ "$(stat -c '%a' "$_pn_mod/bin/kpatch")" = "755" ] || return 93 [ "$(stat -c '%a' "$_pn_mod/patch/boot_patch.sh")" = "755" ] || return 94 [ "$(stat -c '%a' "$_pn_mod/device_validation.sh")" = "755" ] || return 95 - [ ! -e "$_pn_mod/module.prop.bak" ] || return 96 + [ "$(stat -c '%a' "$_pn_mod/export_recovery_boot.sh")" = "755" ] || return 96 + [ ! -e "$_pn_mod/module.prop.bak" ] || return 97 return 0 } -# Review marker must abort before the state directory is created. BLOCKED="$TMP/blocked-module" BLOCKED_STATE="$TMP/blocked-state" make_fake_module "$BLOCKED" @@ -107,8 +101,6 @@ set -e [ ! -e "$BLOCKED_STATE" ] || fail "review blocker allowed persistent state creation" grep -Fq 'ABORT:! FLASH_REVIEW_BLOCKED' "$TMP/blocked.log" || fail "review blocker abort reason missing" -# Candidate behavior: the same already-extracted MODPATH must work under each -# manager convention without copying to a hard-coded final module directory. for spec in 'magisk:magisk' 'ksu:ksu' 'apatch:apatch'; do manager=${spec%%:*} expected=${spec#*:} From 9154127d3c6c2cea8828b236a7a791bfcf382b41 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 15:09:10 +0800 Subject: [PATCH 94/94] test(recovery): create Android temp root in CI harness --- tests/recovery_export_contract.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/recovery_export_contract.sh b/tests/recovery_export_contract.sh index 39df139..f0b435e 100644 --- a/tests/recovery_export_contract.sh +++ b/tests/recovery_export_contract.sh @@ -9,6 +9,11 @@ fail() { exit 1 } +# The production script intentionally uses Android's /data/local/tmp. This +# contract runs under sudo on Ubuntu, so create the Android temp root explicitly +# rather than weakening the production path. +mkdir -p /data/local/tmp + TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT HUP INT TERM MOD="$TMP/module"