From c9014c7e032ffd7ef2f8f2ee813aa3a072ae97c1 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sat, 8 Aug 2026 00:21:36 +0800 Subject: [PATCH 001/152] 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 002/152] 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 003/152] 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 004/152] 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 005/152] 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 006/152] 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 007/152] 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 008/152] 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 009/152] 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 010/152] 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 011/152] 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 012/152] 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 013/152] 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 014/152] 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 015/152] 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 016/152] 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 017/152] 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 018/152] 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 019/152] 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 020/152] 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 021/152] 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 022/152] 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 023/152] 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 024/152] 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 025/152] 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 026/152] 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 027/152] 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 028/152] 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 029/152] 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 030/152] 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 031/152] 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 032/152] 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 033/152] 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 034/152] 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 035/152] 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 036/152] 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 037/152] 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 038/152] 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 039/152] 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 040/152] 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 041/152] 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 042/152] 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 043/152] 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 044/152] 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 045/152] 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 046/152] 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 047/152] 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 048/152] 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 049/152] 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 050/152] 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 051/152] 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 052/152] 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 053/152] 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 054/152] 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 055/152] 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 056/152] 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 057/152] 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 058/152] 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 059/152] 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 060/152] 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 061/152] 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 062/152] 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 063/152] 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 064/152] 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 065/152] 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 066/152] 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 067/152] 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 068/152] 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 069/152] 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 070/152] 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 071/152] 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 072/152] 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 073/152] 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 074/152] 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 075/152] 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 076/152] 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 077/152] 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 078/152] 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 079/152] 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 080/152] 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 081/152] 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 082/152] 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 083/152] 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 084/152] 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 085/152] 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 086/152] 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 087/152] 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 088/152] 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 089/152] 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 090/152] 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 091/152] 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 092/152] 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 093/152] 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 094/152] 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" From 33872ef6694f0e3e3208c982f4d753c1fd6bf8dc Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:01:57 +0800 Subject: [PATCH 095/152] fix(safety): make boot resolution failures non-destructive --- module/patch/flash_safety.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/module/patch/flash_safety.sh b/module/patch/flash_safety.sh index 428ac68..09d2c37 100644 --- a/module/patch/flash_safety.sh +++ b/module/patch/flash_safety.sh @@ -12,6 +12,8 @@ if [ -n "${MODPATH:-}" ] && [ -f "$MODPATH/transaction_safety.sh" ]; then fi # No eval: supported Magisk/APatch config keys are assigned explicitly. +# Do not call the upstream util_functions.sh abort() here: that helper removes +# $MODPATH, which is the installed patch-helper directory in these entry points. getvar() { _pn_key=$1 _pn_proppath='/data/.magisk /cache/.magisk' @@ -22,23 +24,29 @@ getvar() { KEEPVERITY) KEEPVERITY=$_pn_value ;; KEEPFORCEENCRYPT) KEEPFORCEENCRYPT=$_pn_value ;; RECOVERYMODE) RECOVERYMODE=$_pn_value ;; - *) abort "! getvar: unsupported key '$_pn_key'" ;; + *) >&2 echo "! getvar: unsupported key '$_pn_key'"; return 1 ;; 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. +# implementation and validation matrix. Resolution failures must return +# normally instead of invoking util_functions.sh abort(), because that upstream +# abort removes $MODPATH and would destroy the recovery helpers precisely when +# target discovery fails. find_boot_image() { BOOTIMAGE='' if [ -n "${SLOT:-}" ]; then case "$SLOT" in _a|_b) ;; - *) abort "! Invalid active slot suffix: '$SLOT'" ;; + *) >&2 echo "! Invalid active slot suffix: '$SLOT'"; return 1 ;; esac BOOTIMAGE=$(find_block "boot$SLOT" "kern$SLOT" "kern-$SLOT" 2>/dev/null) - [ -n "$BOOTIMAGE" ] || abort "! Cannot resolve boot partition for active slot $SLOT" + [ -n "$BOOTIMAGE" ] || { + >&2 echo "! Cannot resolve boot partition for active slot $SLOT" + return 1 + } echo "BOOTIMAGE=$BOOTIMAGE" return 0 fi @@ -48,7 +56,8 @@ find_boot_image() { _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" + >&2 echo "! A/B boot partitions detected but active slot is unresolved" + return 1 fi BOOTIMAGE=$(find_block boot android_boot kernel bootimg lnx 2>/dev/null || true) @@ -59,7 +68,10 @@ find_boot_image() { | head -n 1) fi - [ -n "$BOOTIMAGE" ] || abort "! Cannot resolve a supported boot partition" + [ -n "$BOOTIMAGE" ] || { + >&2 echo "! Cannot resolve a supported boot partition" + return 1 + } echo "BOOTIMAGE=$BOOTIMAGE" } From 01276a19ebc098663ff114aa074110bc7397005e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:03:04 +0800 Subject: [PATCH 096/152] fix(safety): propagate boot resolution failure without cleanup side effects --- module/patch/boot_extract.sh | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/module/patch/boot_extract.sh b/module/patch/boot_extract.sh index fbacbd9..5cb8955 100644 --- a/module/patch/boot_extract.sh +++ b/module/patch/boot_extract.sh @@ -7,7 +7,7 @@ MODPATH=${0%/*} ARCH=$(getprop ro.product.cpu.abi) -IS_INSTALL_NEXT_SLOT=$1 +IS_INSTALL_NEXT_SLOT=${1:-false} # shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" @@ -21,8 +21,17 @@ else get_current_slot fi -find_boot_image +# PatchNest find_boot_image returns non-zero on every ambiguous/unsupported +# target condition. Never route those failures through util_functions.sh +# abort(), because that upstream installer helper removes $MODPATH. +find_boot_image || { + >&2 echo "! Safe boot target resolution failed" + exit 1 +} -[ -e "$BOOTIMAGE" ] || { >&2 echo "- can't find boot.img!"; exit 1; } +[ -n "${BOOTIMAGE:-}" ] && [ -e "$BOOTIMAGE" ] || { + >&2 echo "! Resolved boot image is missing" + exit 1 +} -true +exit 0 From 855dc94e2a895db129414e3291fc7077f9e8195e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:04:58 +0800 Subject: [PATCH 097/152] fix(safety): override upstream destructive abort in patch runtime --- module/patch/flash_safety.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/module/patch/flash_safety.sh b/module/patch/flash_safety.sh index 09d2c37..8dea0aa 100644 --- a/module/patch/flash_safety.sh +++ b/module/patch/flash_safety.sh @@ -11,9 +11,17 @@ if [ -n "${MODPATH:-}" ] && [ -f "$MODPATH/transaction_safety.sh" ]; then . "$MODPATH/transaction_safety.sh" fi +# util_functions.sh was imported from an installer context where abort() may +# delete $MODPATH. Inside the installed PatchNest patch tree $MODPATH is +# persistent recovery code, so destructive cleanup is never valid. Override it +# for every reviewed runtime entry point before any target-resolution helper is +# called. +abort() { + >&2 echo "$1" + return 1 +} + # No eval: supported Magisk/APatch config keys are assigned explicitly. -# Do not call the upstream util_functions.sh abort() here: that helper removes -# $MODPATH, which is the installed patch-helper directory in these entry points. getvar() { _pn_key=$1 _pn_proppath='/data/.magisk /cache/.magisk' From f2af75b202ecb34444ead82739347982cf9f07e2 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:06:30 +0800 Subject: [PATCH 098/152] fix(validation): require clean FR-014 physical baseline --- module/device_validation.sh | 46 ++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/module/device_validation.sh b/module/device_validation.sh index 75d6f6b..0afc9c4 100644 --- a/module/device_validation.sh +++ b/module/device_validation.sh @@ -135,10 +135,53 @@ validate_target_unpack() { 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 + if ! PATH="$MODDIR/bin:$PATH" "$MODDIR/bin/kptools" -i "$_pn_tmp/kernel" -l > "$EVIDENCE/kernel-info.txt" 2>&1; then + rm -rf "$_pn_tmp"; fail "kptools cannot inspect resolved target kernel" + fi rm -rf "$_pn_tmp" } +require_clean_fr014_candidate() { + # Review packages remain intentionally blocked and are allowed to collect + # read-only diagnostics. A flashable FR-014 candidate must be explicitly + # marked and start from a clean pre-test state so old PatchNest artifacts + # cannot contaminate the lifecycle evidence. + [ ! -f "$MODDIR/FLASH_REVIEW_BLOCKED" ] || return 0 + [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ] || fail "unblocked package is not an FR-014 device candidate" + [ ! -f "$MODDIR/unresolved" ] || fail "module is already marked unresolved" + + for _pn_stale in \ + rollback_binding.json \ + transaction.pending.json \ + flash_recovery_required \ + superkey \ + superkey.pending \ + last_flash.json \ + last_restore.json \ + auto_unpatch_requested \ + autorecovery_active \ + auto_recovery_restored \ + credential_recovered_pending; do + [ ! -e "$PNDIR/$_pn_stale" ] || fail "stale PatchNest state blocks clean FR-014 preflight: $_pn_stale" + done + + if [ -f "$PNDIR/boot_count" ]; then + _pn_boot_count=$(tr -cd '0-9' < "$PNDIR/boot_count" 2>/dev/null | head -c 6) + [ -z "$_pn_boot_count" ] || [ "$_pn_boot_count" -eq 0 ] 2>/dev/null \ + || fail "non-zero historical boot_count blocks clean FR-014 preflight" + fi + + _pn_old_kpm='' + if [ -d "$PNDIR/kpm" ]; then + _pn_old_kpm=$(find "$PNDIR/kpm" -maxdepth 1 -type f \( -name '*.kpm' -o -name '*.ko' -o -name '*.o' \) -print -quit 2>/dev/null) + fi + [ -z "$_pn_old_kpm" ] || fail "pre-existing runtime KPM blocks clean FR-014 preflight: $(basename "$_pn_old_kpm")" + + if grep -Eq '(^|[[:space:]])patched[[:space:]]*=[[:space:]]*true([[:space:]]|$)' "$EVIDENCE/kernel-info.txt"; then + fail "resolved boot kernel is already KernelPatch-patched; clean FR-014 baseline required" + fi +} + run_production_rollback_check() { _pn_binding=${1:-$PNDIR/rollback_binding.json} PATCHNEST_ROLLBACK_BINDING_FILE="$_pn_binding" \ @@ -168,6 +211,7 @@ case "$MODE" in 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" + require_clean_fr014_candidate if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then log "result=REVIEW_PACKAGE_INTENTIONALLY_BLOCKED" else From 5ad70e11e9d4404bba935d6237f3ee6f441fa1ef Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:07:13 +0800 Subject: [PATCH 099/152] test(safety): prove target resolution failure preserves recovery helpers --- tests/boot_resolution_failure_contract.sh | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/boot_resolution_failure_contract.sh diff --git a/tests/boot_resolution_failure_contract.sh b/tests/boot_resolution_failure_contract.sh new file mode 100644 index 0000000..243c578 --- /dev/null +++ b/tests/boot_resolution_failure_contract.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +PATCH="$TMP/patch" +mkdir -p "$PATCH" +cp "$ROOT/module/patch/util_functions.sh" "$PATCH/util_functions.sh" +cp "$ROOT/module/patch/flash_safety.sh" "$PATCH/flash_safety.sh" +printf '%s\n' KEEP > "$PATCH/recovery-helper.sentinel" + +( + MODPATH="$PATCH" + BOOTMODE=true + OUTFD=1 + TMPDIR="$TMP/disposable" + mkdir -p "$TMPDIR" + export MODPATH BOOTMODE OUTFD TMPDIR + # shellcheck disable=SC1090 + . "$PATCH/util_functions.sh" + # shellcheck disable=SC1090 + . "$PATCH/flash_safety.sh" + + # Force the reviewed resolver into a hard failure without touching any real + # /dev node. A failure must be a normal non-zero result and must never call + # the upstream installer cleanup that deletes MODPATH. + find_block() { return 1; } + SLOT='_invalid' + if find_boot_image >/dev/null 2>&1; then + echo "boot resolution failure contract: FAIL: invalid slot accepted" >&2 + exit 1 + fi + [ -d "$PATCH" ] || { echo "boot resolution failure contract: FAIL: patch directory deleted" >&2; exit 1; } + [ -f "$PATCH/recovery-helper.sentinel" ] || { echo "boot resolution failure contract: FAIL: recovery sentinel deleted" >&2; exit 1; } +) + +[ -d "$PATCH" ] || { echo "boot resolution failure contract: FAIL: helper tree missing after failure" >&2; exit 1; } +[ -f "$PATCH/recovery-helper.sentinel" ] || { echo "boot resolution failure contract: FAIL: sentinel missing after failure" >&2; exit 1; } + +echo "boot resolution failure contract: PASS" From af0c94266c38aa58fbf1f45d2d83ef0b6db6aadc Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:07:36 +0800 Subject: [PATCH 100/152] test(validation): execute clean FR-014 preflight gates --- tests/fr014_preflight_contract.sh | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/fr014_preflight_contract.sh diff --git a/tests/fr014_preflight_contract.sh b/tests/fr014_preflight_contract.sh new file mode 100644 index 0000000..e60472b --- /dev/null +++ b/tests/fr014_preflight_contract.sh @@ -0,0 +1,121 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT HUP INT TERM +mkdir -p /data/local/tmp + +fail() { + echo "FR-014 preflight contract: FAIL: $*" >&2 + exit 1 +} + +make_fixture() { + _pn_case=$1 + _pn_mod="$TMP/$_pn_case/module" + _pn_state="$TMP/$_pn_case/state" + _pn_evidence="$TMP/$_pn_case/evidence" + _pn_target="$TMP/$_pn_case/boot.img" + mkdir -p "$_pn_mod/bin" "$_pn_mod/patch" "$_pn_state" "$_pn_evidence" + cp "$ROOT/module/device_validation.sh" "$_pn_mod/device_validation.sh" + chmod 0755 "$_pn_mod/device_validation.sh" + printf '%s\n' 'FR-014 synthetic candidate' > "$_pn_mod/FR014_DEVICE_CANDIDATE" + printf '%s\n' 'synthetic-stock-boot' > "$_pn_target" + + cat > "$_pn_mod/patch/boot_extract.sh" < "$_pn_mod/patch/boot_unpatch.sh" + chmod 0755 "$_pn_mod/patch/boot_unpatch.sh" + : > "$_pn_mod/patch/transaction_safety.sh" + : > "$_pn_mod/patch/transactional_flash.sh" + + cat > "$_pn_mod/bin/magiskboot" <<'EOF' +#!/bin/sh +[ "${1:-}" = "unpack" ] || exit 1 +printf '%s\n' 'synthetic-kernel' > kernel +exit 0 +EOF + chmod 0755 "$_pn_mod/bin/magiskboot" + + cat > "$_pn_mod/bin/kptools" <<'EOF' +#!/bin/sh +if [ "${PATCHNEST_TEST_PATCHED:-0}" = "1" ]; then + printf '%s\n' '[kernel]' 'patched=true' +else + printf '%s\n' '[kernel]' 'patched=false' +fi +exit 0 +EOF + chmod 0755 "$_pn_mod/bin/kptools" + printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_mod/bin/kpatch" + chmod 0755 "$_pn_mod/bin/kpatch" + printf '%s\n' 'kpimg' > "$_pn_mod/bin/kpimg" + + cat > "$_pn_mod/bin/getprop" <<'EOF' +#!/bin/sh +case "${1:-}" in + ro.boot.slot_suffix) printf '%s\n' '_a' ;; + ro.product.device) printf '%s\n' 'synthetic-device' ;; + ro.boot.vbmeta.device_state) printf '%s\n' 'unlocked' ;; + sys.boot_completed) printf '%s\n' '1' ;; + *) printf '%s\n' '' ;; +esac +EOF + chmod 0755 "$_pn_mod/bin/getprop" + + FIX_MOD=$_pn_mod + FIX_STATE=$_pn_state + FIX_EVIDENCE=$_pn_evidence + FIX_TARGET=$_pn_target +} + +run_preflight() { + PATCHNEST_MODDIR="$FIX_MOD" \ + PATCHNEST_STATE_DIR="$FIX_STATE" \ + PATCHNEST_EVIDENCE_DIR="$FIX_EVIDENCE" \ + PATH="$FIX_MOD/bin:$PATH" \ + sh "$FIX_MOD/device_validation.sh" preflight > "$FIX_EVIDENCE/stdout.log" 2>&1 +} + +# 1. Explicit candidate + stock kernel + empty historical state passes. +make_fixture clean +run_preflight || fail "clean candidate was rejected" +grep -Fq 'result=PREFLIGHT_PASS' "$FIX_EVIDENCE/validation.log" || fail "clean candidate did not emit PREFLIGHT_PASS" + +# 2. Old runtime KPM would be auto-loaded by service and must block a clean test. +make_fixture old-kpm +mkdir -p "$FIX_STATE/kpm" +printf '%s\n' old > "$FIX_STATE/kpm/old.kpm" +if run_preflight; then fail "pre-existing KPM was accepted"; fi +grep -Fq 'pre-existing runtime KPM blocks clean FR-014 preflight' "$FIX_EVIDENCE/stdout.log" || fail "old KPM rejection reason missing" + +# 3. Existing credential/transaction-era state invalidates new-key lifecycle proof. +make_fixture old-key +printf '%s\n' '0123456789abcdef0123456789abcdef0123456789abcdef' > "$FIX_STATE/superkey" +chmod 0600 "$FIX_STATE/superkey" +if run_preflight; then fail "historical superkey was accepted"; fi +grep -Fq 'stale PatchNest state blocks clean FR-014 preflight: superkey' "$FIX_EVIDENCE/stdout.log" || fail "old superkey rejection reason missing" + +make_fixture old-binding +printf '%s\n' '{}' > "$FIX_STATE/rollback_binding.json" +chmod 0600 "$FIX_STATE/rollback_binding.json" +if run_preflight; then fail "historical rollback binding was accepted"; fi +grep -Fq 'stale PatchNest state blocks clean FR-014 preflight: rollback_binding.json' "$FIX_EVIDENCE/stdout.log" || fail "old binding rejection reason missing" + +# 4. The pre-test boot kernel must not already be KernelPatch-patched. +make_fixture patched-kernel +if PATCHNEST_TEST_PATCHED=1 run_preflight; then fail "already-patched kernel was accepted"; fi +grep -Fq 'already KernelPatch-patched' "$FIX_EVIDENCE/stdout.log" || fail "patched-kernel rejection reason missing" + +# 5. An unblocked module without the explicit physical-candidate marker fails. +make_fixture unmarked +rm -f "$FIX_MOD/FR014_DEVICE_CANDIDATE" +if run_preflight; then fail "unmarked unblocked package was accepted"; fi +grep -Fq 'unblocked package is not an FR-014 device candidate' "$FIX_EVIDENCE/stdout.log" || fail "candidate-marker rejection reason missing" + +echo "FR-014 preflight contract: PASS" From c296d189cdca1035f7c20f6ed5fe3149d9009c46 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:08:04 +0800 Subject: [PATCH 101/152] ci(safety): execute resolution and FR-014 clean-baseline regressions --- .github/workflows/flash-safety.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index e14c09c..85814cc 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -21,6 +21,8 @@ on: - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' - 'tests/bootloop_recovery_contract.sh' + - 'tests/boot_resolution_failure_contract.sh' + - 'tests/fr014_preflight_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -44,6 +46,8 @@ on: - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' - 'tests/bootloop_recovery_contract.sh' + - 'tests/boot_resolution_failure_contract.sh' + - 'tests/fr014_preflight_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -66,7 +70,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 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 + 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/boot_resolution_failure_contract.sh tests/fr014_preflight_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -93,6 +97,8 @@ jobs: tests/flash_safety_contract.sh \ tests/destructive_transaction_contract.sh \ tests/bootloop_recovery_contract.sh \ + tests/boot_resolution_failure_contract.sh \ + tests/fr014_preflight_contract.sh \ tests/installer_contract.sh \ tests/recovery_export_contract.sh \ tests/runtime_abi_contract.sh @@ -107,9 +113,15 @@ jobs: PATCHNEST_TRANSACTION_TEST: '1' run: sh tests/destructive_transaction_contract.sh + - name: Prove boot-resolution failures preserve recovery helpers + run: sh tests/boot_resolution_failure_contract.sh + - name: Run bootloop automatic recovery contract run: sh tests/bootloop_recovery_contract.sh + - name: Run clean FR-014 preflight contract + run: sudo sh tests/fr014_preflight_contract.sh + - name: Run cross-manager installer contract run: sh tests/installer_contract.sh From 3846e5006c6ef51466405bec7287d03ec33da8a3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:09:38 +0800 Subject: [PATCH 102/152] fix(transaction): bind pending record to exact backup filename --- module/patch/transaction_safety.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh index 0731a3c..4d9956a 100644 --- a/module/patch/transaction_safety.sh +++ b/module/patch/transaction_safety.sh @@ -254,6 +254,7 @@ patchnest_commit_rollback_binding() { [ "$(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 "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "$_pn_backup_name" ] || 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 From d4eaccca371d5b9f0c61fbdd49535271939c2db1 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:09:57 +0800 Subject: [PATCH 103/152] test(transaction): reject pending backup filename mismatch --- tests/transaction_backup_identity_contract.sh | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/transaction_backup_identity_contract.sh diff --git a/tests/transaction_backup_identity_contract.sh b/tests/transaction_backup_identity_contract.sh new file mode 100644 index 0000000..06ee33c --- /dev/null +++ b/tests/transaction_backup_identity_contract.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +PATCHNEST_TRANSACTION_TEST=1 +PATCHNEST_DEVICE_IDENTITY='backup-name-contract-device' +PATCHNEST_ROLLBACK_BINDING_FILE="$TMP/state/rollback_binding.json" +PATCHNEST_PENDING_TRANSACTION_FILE="$TMP/state/transaction.pending.json" +PATCHNEST_RECOVERY_REQUIRED_FILE="$TMP/state/flash_recovery_required" +PATCHNEST_BACKUP_DIR="$TMP/backups" +PATCHNEST_SUPERKEY_FILE="$TMP/state/superkey" +PATCHNEST_SUPERKEY_PENDING_FILE="$TMP/state/superkey.pending" +PATCHNEST_SUPERKEY='0123456789abcdef0123456789abcdef0123456789abcdef' +FLASH_TO_DEVICE=true +export PATCHNEST_TRANSACTION_TEST PATCHNEST_DEVICE_IDENTITY +export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_PENDING_TRANSACTION_FILE PATCHNEST_RECOVERY_REQUIRED_FILE PATCHNEST_BACKUP_DIR +export PATCHNEST_SUPERKEY_FILE PATCHNEST_SUPERKEY_PENDING_FILE PATCHNEST_SUPERKEY FLASH_TO_DEVICE + +mkdir -p "$TMP/state" "$PATCHNEST_BACKUP_DIR" "$TMP/work" +BOOT_TARGET="$TMP/boot.img" +BACKUP_CANDIDATE="$PATCHNEST_BACKUP_DIR/boot_backup_20260808T020000Z_REAL.img" +WORKDIR="$TMP/work" +export BOOT_TARGET BACKUP_CANDIDATE WORKDIR +printf '%s\n' original > "$BACKUP_CANDIDATE" +printf '%s\n' patched > "$WORKDIR/new-boot.img" +cp "$WORKDIR/new-boot.img" "$BOOT_TARGET" + +# shellcheck disable=SC1090 +. "$ROOT/module/patch/superkey_safety.sh" +# The helper resets PATCHNEST_SUPERKEY when sourced; restore the explicit test key. +PATCHNEST_SUPERKEY='0123456789abcdef0123456789abcdef0123456789abcdef' +export PATCHNEST_SUPERKEY +# shellcheck disable=SC1090 +. "$ROOT/module/patch/transaction_safety.sh" + +patchnest_stage_pending_transaction "$WORKDIR/new-boot.img" "$BOOT_TARGET" "$BACKUP_CANDIDATE" \ + || { echo 'transaction backup identity contract: FAIL: staging failed' >&2; exit 1; } +patchnest_mark_pending_transaction_written \ + || { echo 'transaction backup identity contract: FAIL: written transition failed' >&2; exit 1; } + +# Tamper only the filename while preserving all content digests. A hash-only +# validator would accept this; the transaction must bind the exact backup name. +sed 's/boot_backup_20260808T020000Z_REAL.img/boot_backup_20260808T020000Z_OTHER.img/' \ + "$PATCHNEST_PENDING_TRANSACTION_FILE" > "$PATCHNEST_PENDING_TRANSACTION_FILE.tmp" +mv "$PATCHNEST_PENDING_TRANSACTION_FILE.tmp" "$PATCHNEST_PENDING_TRANSACTION_FILE" +chmod 0600 "$PATCHNEST_PENDING_TRANSACTION_FILE" + +if patchnest_commit_rollback_binding; then + echo 'transaction backup identity contract: FAIL: mismatched pending backup filename accepted' >&2 + exit 1 +fi +[ ! -e "$PATCHNEST_ROLLBACK_BINDING_FILE" ] || { + echo 'transaction backup identity contract: FAIL: binding written after mismatch' >&2 + exit 1 +} + +echo 'transaction backup identity contract: PASS' From d91f6ca7936d5cb718b5ae672174750fa45fa7aa Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:10:26 +0800 Subject: [PATCH 104/152] ci(transaction): execute exact backup-name binding regression --- .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 85814cc..46fea5b 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -20,6 +20,7 @@ on: - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' + - 'tests/transaction_backup_identity_contract.sh' - 'tests/bootloop_recovery_contract.sh' - 'tests/boot_resolution_failure_contract.sh' - 'tests/fr014_preflight_contract.sh' @@ -45,6 +46,7 @@ on: - 'version.properties' - 'tests/flash_safety_contract.sh' - 'tests/destructive_transaction_contract.sh' + - 'tests/transaction_backup_identity_contract.sh' - 'tests/bootloop_recovery_contract.sh' - 'tests/boot_resolution_failure_contract.sh' - 'tests/fr014_preflight_contract.sh' @@ -70,7 +72,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 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/boot_resolution_failure_contract.sh tests/fr014_preflight_contract.sh tests/installer_contract.sh tests/recovery_export_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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/fr014_preflight_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -96,6 +98,7 @@ jobs: scripts/package_module.sh \ tests/flash_safety_contract.sh \ tests/destructive_transaction_contract.sh \ + tests/transaction_backup_identity_contract.sh \ tests/bootloop_recovery_contract.sh \ tests/boot_resolution_failure_contract.sh \ tests/fr014_preflight_contract.sh \ @@ -113,6 +116,9 @@ jobs: PATCHNEST_TRANSACTION_TEST: '1' run: sh tests/destructive_transaction_contract.sh + - name: Reject mismatched transaction backup identity + run: sh tests/transaction_backup_identity_contract.sh + - name: Prove boot-resolution failures preserve recovery helpers run: sh tests/boot_resolution_failure_contract.sh From 45b67ce0b5406484007d1bc88ccb651c35fd0fed Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:11:26 +0800 Subject: [PATCH 105/152] fix(safety): preserve abort termination without destructive cleanup --- module/patch/flash_safety.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/module/patch/flash_safety.sh b/module/patch/flash_safety.sh index 8dea0aa..e924d51 100644 --- a/module/patch/flash_safety.sh +++ b/module/patch/flash_safety.sh @@ -13,12 +13,11 @@ fi # util_functions.sh was imported from an installer context where abort() may # delete $MODPATH. Inside the installed PatchNest patch tree $MODPATH is -# persistent recovery code, so destructive cleanup is never valid. Override it -# for every reviewed runtime entry point before any target-resolution helper is -# called. +# persistent recovery code, so destructive cleanup is never valid. Preserve +# abort's terminating semantics without deleting any persistent helper tree. abort() { >&2 echo "$1" - return 1 + exit 1 } # No eval: supported Magisk/APatch config keys are assigned explicitly. From 5970d3842ce26d7b9a46651ae5dc4d44bf88adfa Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:11:47 +0800 Subject: [PATCH 106/152] test(safety): execute non-destructive abort override --- tests/boot_resolution_failure_contract.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/boot_resolution_failure_contract.sh b/tests/boot_resolution_failure_contract.sh index 243c578..8f1f730 100644 --- a/tests/boot_resolution_failure_contract.sh +++ b/tests/boot_resolution_failure_contract.sh @@ -35,6 +35,27 @@ printf '%s\n' KEEP > "$PATCH/recovery-helper.sentinel" [ -f "$PATCH/recovery-helper.sentinel" ] || { echo "boot resolution failure contract: FAIL: recovery sentinel deleted" >&2; exit 1; } ) +# The runtime abort override must still terminate its caller but must not run +# the imported installer cleanup that recursively removes MODPATH. +set +e +( + MODPATH="$PATCH" + BOOTMODE=true + OUTFD=1 + TMPDIR="$TMP/disposable-abort" + mkdir -p "$TMPDIR" + export MODPATH BOOTMODE OUTFD TMPDIR + # shellcheck disable=SC1090 + . "$PATCH/util_functions.sh" + # shellcheck disable=SC1090 + . "$PATCH/flash_safety.sh" + abort '! synthetic runtime abort' + exit 0 +) >/dev/null 2>&1 +abort_rc=$? +set -e +[ "$abort_rc" -ne 0 ] || { echo "boot resolution failure contract: FAIL: abort did not terminate" >&2; exit 1; } + [ -d "$PATCH" ] || { echo "boot resolution failure contract: FAIL: helper tree missing after failure" >&2; exit 1; } [ -f "$PATCH/recovery-helper.sentinel" ] || { echo "boot resolution failure contract: FAIL: sentinel missing after failure" >&2; exit 1; } From d03a36e577a7855cb444201e9814bb62b533d08f Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:13:05 +0800 Subject: [PATCH 107/152] fix(safety): remove destructive abort and eval fallback from runtime helper --- module/patch/util_functions.sh | 47 ++++++++++++---------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/module/patch/util_functions.sh b/module/patch/util_functions.sh index 41b16b9..4410081 100644 --- a/module/patch/util_functions.sh +++ b/module/patch/util_functions.sh @@ -50,27 +50,18 @@ getvar() { local VARNAME=$1 local VALUE local PROPPATH='/data/.magisk /cache/.magisk' - # P1 fix: $MAGISKTMP was unquoted in `[ ! -z $MAGISKTMP ]`; an - # unset var made the test `[ ! -z ]` always true. - [ -n "$MAGISKTMP" ] && PROPPATH="$MAGISKTMP/.magisk/config $PROPPATH" - VALUE=$(grep_prop $VARNAME $PROPPATH) - # P0-1 security fix: replace eval with printf -v and add a key allow-list - # to prevent shell-injection via attacker-controlled VALUE. - # P2 fix: printf -v is bash-only; gate the whole block behind a - # BASH detection so the file still works under mksh/ash. When - # bash is present (which is the case on every device we support) - # the printf -v path is taken; otherwise we fall back to eval, - # which is safe because the allow-list above restricts VARNAME to - # three known-safe keys. + [ -n "${MAGISKTMP:-}" ] && PROPPATH="$MAGISKTMP/.magisk/config $PROPPATH" + VALUE=$(grep_prop "$VARNAME" $PROPPATH) + + # PatchNest is executed by Android /system/bin/sh, not bash. Keep this helper + # strictly POSIX and assign only the three reviewed keys explicitly; never + # retain an eval fallback in a root shell. case "$VARNAME" in - KEEPVERITY|KEEPFORCEENCRYPT|RECOVERYMODE) ;; - *) abort "! getvar: unknown key '$VARNAME'";; + KEEPVERITY) KEEPVERITY=$VALUE ;; + KEEPFORCEENCRYPT) KEEPFORCEENCRYPT=$VALUE ;; + RECOVERYMODE) RECOVERYMODE=$VALUE ;; + *) ui_print "! getvar: unknown key '$VARNAME'"; return 1 ;; esac - if [ -n "$BASH" ] && [ -n "$VALUE" ]; then - printf -v "$VARNAME" '%s' "$VALUE" - elif [ -n "$VALUE" ]; then - eval "$VARNAME=\$VALUE" - fi } is_mounted() { @@ -81,16 +72,11 @@ is_mounted() { abort() { ui_print "$1" - $BOOTMODE || recovery_cleanup - # P1 security fix: quote both variables in rm -rf. Unquoted $MODPATH - # with a glob character would expand and rm -rf; unquoted $TMPDIR - # on an unset value would degrade to `rm -rf` (no arg, but loud). - if [ -n "$MODPATH" ]; then - rm -rf "$MODPATH" - fi - if [ -n "$TMPDIR" ]; then - rm -rf "$TMPDIR" - fi + # This copy of util_functions.sh lives inside the installed PatchNest + # patch-helper tree. $MODPATH therefore points at persistent recovery code, + # not a disposable installer staging directory. Never delete it on failure. + # Operation-private workspaces are owned by their callers and cleaned by + # traps there. exit 1 } set_nvbase() { @@ -492,7 +478,6 @@ get_flags() { if [ -z $KEEPFORCEENCRYPT ]; then if $ISENCRYPTED; then KEEPFORCEENCRYPT=true - ui_print "- Encrypted data, keep forceencrypt" else KEEPFORCEENCRYPT=false fi @@ -641,4 +626,4 @@ run_migrations() { rm -f $BACKUP gzip -9f $TARGET done -} \ No newline at end of file +} From 53a2051055ef988021e31b71f9cddc03f6780310 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:14:56 +0800 Subject: [PATCH 108/152] feat(validation): add one-time FR-014 destructive-write receipt gate --- module/patch/fr014_gate.sh | 106 +++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 module/patch/fr014_gate.sh diff --git a/module/patch/fr014_gate.sh b/module/patch/fr014_gate.sh new file mode 100644 index 0000000..c19926a --- /dev/null +++ b/module/patch/fr014_gate.sh @@ -0,0 +1,106 @@ +#!/system/bin/sh +# FR-014 physical-candidate one-time preflight receipt. +# Requires transaction_safety.sh helpers. It is a no-op for normal review/release +# trees that do not contain FR014_DEVICE_CANDIDATE. + +PATCHNEST_FR014_PREFLIGHT_FILE="${PATCHNEST_FR014_PREFLIGHT_FILE:-/data/adb/patchnest/fr014_preflight.json}" + +patchnest_fr014_module_dir() { + if [ -n "${PATCHNEST_MODULE_DIR:-}" ]; then + printf '%s\n' "$PATCHNEST_MODULE_DIR" + return 0 + fi + if [ -n "${MODPATH:-}" ]; then + case "$MODPATH" in + */patch) printf '%s\n' "${MODPATH%/patch}" ;; + *) printf '%s\n' "$MODPATH" ;; + esac + return 0 + fi + return 1 +} + +patchnest_fr014_marker_path() { + _pn_mod=$(patchnest_fr014_module_dir) || return 1 + printf '%s\n' "$_pn_mod/FR014_DEVICE_CANDIDATE" +} + +patchnest_fr014_candidate_active() { + _pn_marker=$(patchnest_fr014_marker_path 2>/dev/null) || return 1 + [ -f "$_pn_marker" ] +} + +patchnest_clear_fr014_preflight_receipt() { + rm -f "$PATCHNEST_FR014_PREFLIGHT_FILE" + [ ! -e "$PATCHNEST_FR014_PREFLIGHT_FILE" ] +} + +patchnest_write_fr014_preflight_receipt() { + _pn_target=$1 + patchnest_fr014_candidate_active || return 0 + [ -e "$_pn_target" ] || return 1 + _pn_target=$(readlink -f "$_pn_target" 2>/dev/null || printf '%s' "$_pn_target") + _pn_marker=$(patchnest_fr014_marker_path) || return 1 + _pn_marker_sha=$(patchnest_hash_file "$_pn_marker") || return 1 + _pn_target_sha=$(patchnest_hash_file "$_pn_target") || return 1 + _pn_device_sha=$(patchnest_device_binding_sha256 "$_pn_target") || return 1 + _pn_dir=${PATCHNEST_FR014_PREFLIGHT_FILE%/*} + mkdir -p "$_pn_dir" || return 1 + umask 077 + _pn_tmp="${PATCHNEST_FR014_PREFLIGHT_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_FR014_PREFLIGHT_FILE" || { rm -f "$_pn_tmp"; return 1; } + patchnest_state_file_is_secure "$PATCHNEST_FR014_PREFLIGHT_FILE" +} + +patchnest_consume_fr014_preflight_if_required() { + _pn_target=$1 + patchnest_fr014_candidate_active || return 0 + _pn_target=$(readlink -f "$_pn_target" 2>/dev/null || printf '%s' "$_pn_target") + _pn_marker=$(patchnest_fr014_marker_path) || return 1 + + patchnest_state_file_is_secure "$PATCHNEST_FR014_PREFLIGHT_FILE" || { + >&2 echo "! FR-014 candidate requires a fresh device_validation.sh preflight" + return 1 + } + [ "$(patchnest_json_bool preflight_pass "$PATCHNEST_FR014_PREFLIGHT_FILE")" = "true" ] || return 1 + [ "$(patchnest_json_string boot_target "$PATCHNEST_FR014_PREFLIGHT_FILE")" = "$_pn_target" ] || { + >&2 echo "! FR-014 preflight receipt boot target mismatch" + return 1 + } + + _pn_expected_target=$(patchnest_json_string boot_target_sha256 "$PATCHNEST_FR014_PREFLIGHT_FILE") + _pn_expected_device=$(patchnest_json_string device_binding_sha256 "$PATCHNEST_FR014_PREFLIGHT_FILE") + _pn_expected_marker=$(patchnest_json_string candidate_marker_sha256 "$PATCHNEST_FR014_PREFLIGHT_FILE") + printf '%s' "$_pn_expected_target$_pn_expected_device$_pn_expected_marker" | grep -Eq '^[0-9a-f]{192}$' || return 1 + + [ "$(patchnest_hash_file "$_pn_target")" = "$_pn_expected_target" ] || { + >&2 echo "! Boot target changed after FR-014 preflight" + return 1 + } + [ "$(patchnest_device_binding_sha256 "$_pn_target")" = "$_pn_expected_device" ] || { + >&2 echo "! Device/slot context changed after FR-014 preflight" + return 1 + } + [ "$(patchnest_hash_file "$_pn_marker")" = "$_pn_expected_marker" ] || { + >&2 echo "! FR-014 candidate identity changed after preflight" + return 1 + } + + # One successful validation authorizes one destructive attempt only. Consume + # before the transaction is staged; any later pre-write failure requires a + # fresh read-only preflight rather than silently reusing stale approval. + patchnest_clear_fr014_preflight_receipt || return 1 + return 0 +} From 9edf99f208b87d6fdbcdde1ae3d5ea8292537406 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:15:13 +0800 Subject: [PATCH 109/152] fix(validation): require one-time FR-014 preflight before candidate write --- module/patch/transactional_flash.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/module/patch/transactional_flash.sh b/module/patch/transactional_flash.sh index 240e95a..b32d40c 100644 --- a/module/patch/transactional_flash.sh +++ b/module/patch/transactional_flash.sh @@ -2,6 +2,13 @@ # High-level destructive boot write transaction. # Requires flash_safety.sh, transaction_safety.sh and superkey_safety.sh. +# Candidate-only physical validation gate. Normal review/release trees without +# FR014_DEVICE_CANDIDATE treat this helper as a no-op. +if [ -n "${MODPATH:-}" ] && [ -f "$MODPATH/fr014_gate.sh" ]; then + # shellcheck disable=SC1091 + . "$MODPATH/fr014_gate.sh" +fi + patchnest_discard_pending_key_if_new() { if command -v patchnest_discard_pending_key >/dev/null 2>&1; then patchnest_discard_pending_key || true @@ -34,6 +41,7 @@ patchnest_attempt_verified_rollback() { # Returns: # 0 write verified and pending transaction advanced to state=written +# 9 FR-014 candidate preflight gate rejected; target untouched # 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 @@ -45,6 +53,13 @@ patchnest_transactional_flash() { _pn_target=$2 _pn_backup=$3 + if command -v patchnest_consume_fr014_preflight_if_required >/dev/null 2>&1; then + if ! patchnest_consume_fr014_preflight_if_required "$_pn_target"; then + patchnest_discard_pending_key_if_new + return 9 + fi + fi + patchnest_stage_pending_transaction "$_pn_source" "$_pn_target" "$_pn_backup" || { patchnest_discard_pending_key_if_new return 10 From 430d86e4542c6f33abddd4f3cda861d2fbd52fdf Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:16:26 +0800 Subject: [PATCH 110/152] fix(validation): bind candidate write to one-time preflight receipt --- module/device_validation.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/module/device_validation.sh b/module/device_validation.sh index 0afc9c4..0c2acb8 100644 --- a/module/device_validation.sh +++ b/module/device_validation.sh @@ -75,6 +75,7 @@ require_module_tree() { [ -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" + [ -f "$MODDIR/patch/fr014_gate.sh" ] || fail "FR-014 preflight gate helper missing" [ -x "$MODDIR/patch/boot_unpatch.sh" ] || fail "bound restore helper missing or not executable" } @@ -200,11 +201,28 @@ finalize() { require_root require_module_tree +PATCHNEST_MODULE_DIR=$MODDIR +PATCHNEST_FR014_PREFLIGHT_FILE="$PNDIR/fr014_preflight.json" +PATCHNEST_ROLLBACK_BINDING_FILE="$PNDIR/rollback_binding.json" +PATCHNEST_PENDING_TRANSACTION_FILE="$PNDIR/transaction.pending.json" +PATCHNEST_RECOVERY_REQUIRED_FILE="$PNDIR/flash_recovery_required" +PATCHNEST_BACKUP_DIR="$PNDIR/backup" +export PATCHNEST_MODULE_DIR PATCHNEST_FR014_PREFLIGHT_FILE +export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_PENDING_TRANSACTION_FILE PATCHNEST_RECOVERY_REQUIRED_FILE PATCHNEST_BACKUP_DIR +# shellcheck disable=SC1090 +. "$MODDIR/patch/transaction_safety.sh" +# shellcheck disable=SC1090 +. "$MODDIR/patch/fr014_gate.sh" resolve_target collect_common case "$MODE" in preflight) + # A candidate receipt is one-time. Re-running preflight intentionally + # revokes any older receipt before re-evaluating the live target/state. + if [ ! -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then + patchnest_clear_fr014_preflight_receipt || fail "cannot clear stale FR-014 preflight receipt" + fi validate_target_unpack record_cmd "kpatch file digest" sha256sum "$MODDIR/bin/kpatch" || true record_cmd "kptools file digest" sha256sum "$MODDIR/bin/kptools" || true @@ -215,6 +233,9 @@ case "$MODE" in if [ -f "$MODDIR/FLASH_REVIEW_BLOCKED" ]; then log "result=REVIEW_PACKAGE_INTENTIONALLY_BLOCKED" else + patchnest_write_fr014_preflight_receipt "$TARGET" || fail "cannot commit FR-014 preflight receipt" + copy_if_present "$PATCHNEST_FR014_PREFLIGHT_FILE" "fr014_preflight.json" + log "fr014_preflight_receipt=$PATCHNEST_FR014_PREFLIGHT_FILE" log "result=PREFLIGHT_PASS" fi ;; From d5b850aa8ab0b1872f7df43a9b61058f0bb60e02 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:17:12 +0800 Subject: [PATCH 111/152] fix(package): require FR-014 gate helper at install time --- module/customize.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/module/customize.sh b/module/customize.sh index 80b0217..96aa347 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -67,6 +67,7 @@ for _pn_script in \ flash_safety.sh \ transaction_safety.sh \ transactional_flash.sh \ + fr014_gate.sh \ superkey_safety.sh; do if [ ! -x "$MODPATH/patch/$_pn_script" ]; then abort "! Required patch helper missing or not executable: patch/$_pn_script" From c6a719a74d9ae2a667aeb90a1edff5759a68115d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:17:33 +0800 Subject: [PATCH 112/152] fix(package): require FR-014 gate in deterministic archive --- scripts/package_module.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index a91359c..cd8ade9 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -56,6 +56,7 @@ for required in \ patch/flash_safety.sh \ patch/transaction_safety.sh \ patch/transactional_flash.sh \ + patch/fr014_gate.sh \ patch/superkey_safety.sh; do grep -Fxq "$required" "$STAGE/zip-list" || { echo "required package entry missing: $required" >&2 From 258946a7c50f816d79c24cf17f229dac61663803 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:18:04 +0800 Subject: [PATCH 113/152] fix(validation): fail closed if candidate preflight helper is missing --- module/patch/transactional_flash.sh | 42 ++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/module/patch/transactional_flash.sh b/module/patch/transactional_flash.sh index b32d40c..de87df4 100644 --- a/module/patch/transactional_flash.sh +++ b/module/patch/transactional_flash.sh @@ -3,10 +3,23 @@ # Requires flash_safety.sh, transaction_safety.sh and superkey_safety.sh. # Candidate-only physical validation gate. Normal review/release trees without -# FR014_DEVICE_CANDIDATE treat this helper as a no-op. -if [ -n "${MODPATH:-}" ] && [ -f "$MODPATH/fr014_gate.sh" ]; then - # shellcheck disable=SC1091 - . "$MODPATH/fr014_gate.sh" +# FR014_DEVICE_CANDIDATE treat this helper as a no-op. A candidate marker with a +# missing helper is a hard failure, never an implicit bypass. +PATCHNEST_FR014_GATE_MISSING=0 +if [ -n "${MODPATH:-}" ]; then + _pn_candidate_marker="$MODPATH/../FR014_DEVICE_CANDIDATE" + if [ -f "$_pn_candidate_marker" ]; then + if [ -f "$MODPATH/fr014_gate.sh" ]; then + # shellcheck disable=SC1091 + . "$MODPATH/fr014_gate.sh" + else + PATCHNEST_FR014_GATE_MISSING=1 + fi + elif [ -f "$MODPATH/fr014_gate.sh" ]; then + # Load the no-op-capable helper in normal review/release trees as well. + # shellcheck disable=SC1091 + . "$MODPATH/fr014_gate.sh" + fi fi patchnest_discard_pending_key_if_new() { @@ -41,7 +54,7 @@ patchnest_attempt_verified_rollback() { # Returns: # 0 write verified and pending transaction advanced to state=written -# 9 FR-014 candidate preflight gate rejected; target untouched +# 9 FR-014 candidate preflight gate rejected/missing; target untouched # 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 @@ -53,11 +66,28 @@ patchnest_transactional_flash() { _pn_target=$2 _pn_backup=$3 - if command -v patchnest_consume_fr014_preflight_if_required >/dev/null 2>&1; then + if [ "${PATCHNEST_FR014_GATE_MISSING:-0}" = "1" ]; then + >&2 echo "! FR-014 candidate gate helper is missing" + patchnest_discard_pending_key_if_new + return 9 + fi + if [ -f "${_pn_candidate_marker:-/nonexistent}" ]; then + if ! command -v patchnest_consume_fr014_preflight_if_required >/dev/null 2>&1; then + >&2 echo "! FR-014 candidate gate function is unavailable" + patchnest_discard_pending_key_if_new + return 9 + fi if ! patchnest_consume_fr014_preflight_if_required "$_pn_target"; then patchnest_discard_pending_key_if_new return 9 fi + elif command -v patchnest_consume_fr014_preflight_if_required >/dev/null 2>&1; then + # Normal branch helper explicitly returns success when candidate mode is + # inactive. Keeping the call here exercises one common code path. + patchnest_consume_fr014_preflight_if_required "$_pn_target" || { + patchnest_discard_pending_key_if_new + return 9 + } fi patchnest_stage_pending_transaction "$_pn_source" "$_pn_target" "$_pn_backup" || { From b4caa26eb8a59bc7d63893fcdc6077b2d6e5ef0a Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:18:29 +0800 Subject: [PATCH 114/152] test(validation): verify FR-014 preflight receipt and strict baseline --- tests/fr014_preflight_contract.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/fr014_preflight_contract.sh b/tests/fr014_preflight_contract.sh index e60472b..936ce3d 100644 --- a/tests/fr014_preflight_contract.sh +++ b/tests/fr014_preflight_contract.sh @@ -31,7 +31,8 @@ EOF chmod 0755 "$_pn_mod/patch/boot_extract.sh" printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_mod/patch/boot_unpatch.sh" chmod 0755 "$_pn_mod/patch/boot_unpatch.sh" - : > "$_pn_mod/patch/transaction_safety.sh" + cp "$ROOT/module/patch/transaction_safety.sh" "$_pn_mod/patch/transaction_safety.sh" + cp "$ROOT/module/patch/fr014_gate.sh" "$_pn_mod/patch/fr014_gate.sh" : > "$_pn_mod/patch/transactional_flash.sh" cat > "$_pn_mod/bin/magiskboot" <<'EOF' @@ -59,8 +60,10 @@ EOF cat > "$_pn_mod/bin/getprop" <<'EOF' #!/bin/sh case "${1:-}" in + ro.boot.serialno|ro.serialno) printf '%s\n' 'SYNTHETIC-SERIAL' ;; ro.boot.slot_suffix) printf '%s\n' '_a' ;; ro.product.device) printf '%s\n' 'synthetic-device' ;; + ro.boot.vbmeta.digest) printf '%s\n' 'synthetic-vbmeta' ;; ro.boot.vbmeta.device_state) printf '%s\n' 'unlocked' ;; sys.boot_completed) printf '%s\n' '1' ;; *) printf '%s\n' '' ;; @@ -75,6 +78,7 @@ EOF } run_preflight() { + PATCHNEST_TEST_PATCHED="${PATCHNEST_TEST_PATCHED:-0}" \ PATCHNEST_MODDIR="$FIX_MOD" \ PATCHNEST_STATE_DIR="$FIX_STATE" \ PATCHNEST_EVIDENCE_DIR="$FIX_EVIDENCE" \ @@ -82,10 +86,15 @@ run_preflight() { sh "$FIX_MOD/device_validation.sh" preflight > "$FIX_EVIDENCE/stdout.log" 2>&1 } -# 1. Explicit candidate + stock kernel + empty historical state passes. +# 1. Explicit candidate + stock kernel + empty historical state passes and +# creates a secure receipt bound to the exact target/candidate identity. make_fixture clean run_preflight || fail "clean candidate was rejected" grep -Fq 'result=PREFLIGHT_PASS' "$FIX_EVIDENCE/validation.log" || fail "clean candidate did not emit PREFLIGHT_PASS" +[ -f "$FIX_STATE/fr014_preflight.json" ] || fail "clean preflight receipt missing" +[ "$(stat -c '%a' "$FIX_STATE/fr014_preflight.json")" = "600" ] || fail "preflight receipt is not mode 0600" +grep -Fq '"preflight_pass": true' "$FIX_STATE/fr014_preflight.json" || fail "preflight receipt missing pass marker" +grep -Fq "\"boot_target\": \"$FIX_TARGET\"" "$FIX_STATE/fr014_preflight.json" || fail "preflight receipt not bound to exact target" # 2. Old runtime KPM would be auto-loaded by service and must block a clean test. make_fixture old-kpm From b008893542de6c5d2aeca9c7eeefc2060503b262 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:19:01 +0800 Subject: [PATCH 115/152] test(validation): execute one-time FR-014 write authorization gate --- tests/fr014_write_gate_contract.sh | 118 +++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/fr014_write_gate_contract.sh diff --git a/tests/fr014_write_gate_contract.sh b/tests/fr014_write_gate_contract.sh new file mode 100644 index 0000000..79942ce --- /dev/null +++ b/tests/fr014_write_gate_contract.sh @@ -0,0 +1,118 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +MOD="$TMP/module" +PATCH="$MOD/patch" +STATE="$TMP/state" +mkdir -p "$PATCH" "$STATE" "$TMP/work" "$TMP/backups" +printf '%s\n' 'candidate-gate-contract' > "$MOD/FR014_DEVICE_CANDIDATE" +cp "$ROOT/module/patch/transaction_safety.sh" "$PATCH/transaction_safety.sh" +cp "$ROOT/module/patch/fr014_gate.sh" "$PATCH/fr014_gate.sh" +cp "$ROOT/module/patch/transactional_flash.sh" "$PATCH/transactional_flash.sh" + +SOURCE="$TMP/work/new-boot.img" +TARGET="$TMP/boot.img" +BACKUP="$TMP/backups/boot_backup_20260808T030000Z_GATE.img" +printf '%s\n' patched > "$SOURCE" +printf '%s\n' original > "$TARGET" +cp "$TARGET" "$BACKUP" + +PATCHNEST_TRANSACTION_TEST=1 +PATCHNEST_DEVICE_IDENTITY='fr014-gate-device' +PATCHNEST_MODULE_DIR="$MOD" +MODPATH="$PATCH" +PATCHNEST_ROLLBACK_BINDING_FILE="$STATE/rollback_binding.json" +PATCHNEST_PENDING_TRANSACTION_FILE="$STATE/transaction.pending.json" +PATCHNEST_RECOVERY_REQUIRED_FILE="$STATE/flash_recovery_required" +PATCHNEST_BACKUP_DIR="$TMP/backups" +PATCHNEST_FR014_PREFLIGHT_FILE="$STATE/fr014_preflight.json" +PATCHNEST_SUPERKEY_PENDING_FILE="$STATE/superkey.pending" +export PATCHNEST_TRANSACTION_TEST PATCHNEST_DEVICE_IDENTITY PATCHNEST_MODULE_DIR MODPATH +export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_PENDING_TRANSACTION_FILE PATCHNEST_RECOVERY_REQUIRED_FILE PATCHNEST_BACKUP_DIR +export PATCHNEST_FR014_PREFLIGHT_FILE PATCHNEST_SUPERKEY_PENDING_FILE + +# shellcheck disable=SC1090 +. "$PATCH/transaction_safety.sh" +patchnest_superkey_sha256() { + printf '%s' '0123456789abcdef0123456789abcdef0123456789abcdef' | sha256sum | awk '{print $1}' +} +patchnest_discard_pending_key() { rm -f "$PATCHNEST_SUPERKEY_PENDING_FILE"; } +FLASH_CALLS="$TMP/flash.calls" +printf '%s\n' 0 > "$FLASH_CALLS" +flash_image() { + _pn_n=$(cat "$FLASH_CALLS") + _pn_n=$((_pn_n + 1)) + printf '%s\n' "$_pn_n" > "$FLASH_CALLS" + cp "$1" "$2" +} +# shellcheck disable=SC1090 +. "$PATCH/transactional_flash.sh" + +# 1. Candidate destructive write without a fresh preflight receipt must fail +# before flash_image is called. +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 9 ] || { echo "FR-014 write gate contract: FAIL: no-receipt rc=$rc" >&2; exit 1; } +[ "$(cat "$FLASH_CALLS")" -eq 0 ] || { echo "FR-014 write gate contract: FAIL: no-receipt path touched writer" >&2; exit 1; } +cmp -s "$TARGET" "$BACKUP" || { echo "FR-014 write gate contract: FAIL: no-receipt path changed target" >&2; exit 1; } + +# 2. A receipt is bound to the exact live target bytes. External target change +# after preflight must invalidate it before the writer is reached. +patchnest_write_fr014_preflight_receipt "$TARGET" || { echo "FR-014 write gate contract: FAIL: receipt creation failed" >&2; exit 1; } +printf '%s\n' externally-changed > "$TARGET" +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 9 ] || { echo "FR-014 write gate contract: FAIL: changed-target rc=$rc" >&2; exit 1; } +[ "$(cat "$FLASH_CALLS")" -eq 0 ] || { echo "FR-014 write gate contract: FAIL: changed-target path touched writer" >&2; exit 1; } + +# 3. Fresh receipt + unchanged target authorizes exactly one destructive attempt. +cp "$BACKUP" "$TARGET" +patchnest_clear_fr014_preflight_receipt +patchnest_write_fr014_preflight_receipt "$TARGET" || { echo "FR-014 write gate contract: FAIL: fresh receipt creation failed" >&2; exit 1; } +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" || { echo "FR-014 write gate contract: FAIL: valid receipt rejected" >&2; exit 1; } +[ "$(cat "$FLASH_CALLS")" -eq 1 ] || { echo "FR-014 write gate contract: FAIL: valid path writer count incorrect" >&2; exit 1; } +cmp -s "$TARGET" "$SOURCE" || { echo "FR-014 write gate contract: FAIL: valid path did not write source" >&2; exit 1; } +[ ! -e "$PATCHNEST_FR014_PREFLIGHT_FILE" ] || { echo "FR-014 write gate contract: FAIL: receipt was not consumed" >&2; exit 1; } +[ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] || { echo "FR-014 write gate contract: FAIL: transaction did not reach written" >&2; exit 1; } + +# 4. A candidate marker with missing gate helper must fail closed. Use a fresh +# subshell so transactional_flash.sh evaluates helper presence from scratch. +( + MISS="$TMP/missing-helper" + mkdir -p "$MISS/module/patch" "$MISS/state" "$MISS/backups" "$MISS/work" + printf '%s\n' candidate > "$MISS/module/FR014_DEVICE_CANDIDATE" + cp "$PATCH/transaction_safety.sh" "$MISS/module/patch/transaction_safety.sh" + cp "$PATCH/transactional_flash.sh" "$MISS/module/patch/transactional_flash.sh" + printf '%s\n' original > "$MISS/boot.img" + cp "$MISS/boot.img" "$MISS/backups/boot_backup_20260808T040000Z_MISSING.img" + printf '%s\n' patched > "$MISS/work/new-boot.img" + MODPATH="$MISS/module/patch" + PATCHNEST_TRANSACTION_TEST=1 + PATCHNEST_DEVICE_IDENTITY='missing-gate-device' + PATCHNEST_PENDING_TRANSACTION_FILE="$MISS/state/transaction.pending.json" + PATCHNEST_ROLLBACK_BINDING_FILE="$MISS/state/rollback_binding.json" + PATCHNEST_RECOVERY_REQUIRED_FILE="$MISS/state/flash_recovery_required" + PATCHNEST_BACKUP_DIR="$MISS/backups" + export MODPATH PATCHNEST_TRANSACTION_TEST PATCHNEST_DEVICE_IDENTITY + export PATCHNEST_PENDING_TRANSACTION_FILE PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_RECOVERY_REQUIRED_FILE PATCHNEST_BACKUP_DIR + # shellcheck disable=SC1090 + . "$MISS/module/patch/transaction_safety.sh" + patchnest_superkey_sha256() { printf '%s' key | sha256sum | awk '{print $1}'; } + flash_image() { echo 'writer must not run' >&2; return 99; } + # shellcheck disable=SC1090 + . "$MISS/module/patch/transactional_flash.sh" + set +e + patchnest_transactional_flash "$MISS/work/new-boot.img" "$MISS/boot.img" "$MISS/backups/boot_backup_20260808T040000Z_MISSING.img" + _pn_rc=$? + set -e + [ "$_pn_rc" -eq 9 ] || exit 1 +) || { echo "FR-014 write gate contract: FAIL: missing helper did not fail closed" >&2; exit 1; } + +echo "FR-014 write gate contract: PASS" From 233211342cb6554660aeb5b54347e632e20c250f Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:19:28 +0800 Subject: [PATCH 116/152] test(installer): require FR-014 gate helper across managers --- tests/installer_contract.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/installer_contract.sh b/tests/installer_contract.sh index 8dd03f9..6bdadfb 100644 --- a/tests/installer_contract.sh +++ b/tests/installer_contract.sh @@ -31,7 +31,7 @@ make_fake_module() { 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 + transaction_safety.sh transactional_flash.sh fr014_gate.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 @@ -83,9 +83,10 @@ run_installer() { [ "$(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 - [ "$(stat -c '%a' "$_pn_mod/export_recovery_boot.sh")" = "755" ] || return 96 - [ ! -e "$_pn_mod/module.prop.bak" ] || return 97 + [ "$(stat -c '%a' "$_pn_mod/patch/fr014_gate.sh")" = "755" ] || return 95 + [ "$(stat -c '%a' "$_pn_mod/device_validation.sh")" = "755" ] || return 96 + [ "$(stat -c '%a' "$_pn_mod/export_recovery_boot.sh")" = "755" ] || return 97 + [ ! -e "$_pn_mod/module.prop.bak" ] || return 98 return 0 } From 94b30cb34faa17f775219a6754c9ed5cf833fa25 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:19:57 +0800 Subject: [PATCH 117/152] ci(validation): execute one-time FR-014 write gate contract --- .github/workflows/flash-safety.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 46fea5b..b260aed 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -24,6 +24,7 @@ on: - 'tests/bootloop_recovery_contract.sh' - 'tests/boot_resolution_failure_contract.sh' - 'tests/fr014_preflight_contract.sh' + - 'tests/fr014_write_gate_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -50,6 +51,7 @@ on: - 'tests/bootloop_recovery_contract.sh' - 'tests/boot_resolution_failure_contract.sh' - 'tests/fr014_preflight_contract.sh' + - 'tests/fr014_write_gate_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -72,7 +74,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 module/export_recovery_boot.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/fr014_preflight_contract.sh tests/installer_contract.sh tests/recovery_export_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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done @@ -86,6 +88,7 @@ jobs: module/patch/flash_safety.sh \ module/patch/transaction_safety.sh \ module/patch/transactional_flash.sh \ + module/patch/fr014_gate.sh \ module/patch/superkey_safety.sh \ module/service.sh \ module/post-fs-data.sh \ @@ -102,6 +105,7 @@ jobs: tests/bootloop_recovery_contract.sh \ tests/boot_resolution_failure_contract.sh \ tests/fr014_preflight_contract.sh \ + tests/fr014_write_gate_contract.sh \ tests/installer_contract.sh \ tests/recovery_export_contract.sh \ tests/runtime_abi_contract.sh @@ -128,6 +132,9 @@ jobs: - name: Run clean FR-014 preflight contract run: sudo sh tests/fr014_preflight_contract.sh + - name: Enforce one-time FR-014 destructive-write authorization + run: sh tests/fr014_write_gate_contract.sh + - name: Run cross-manager installer contract run: sh tests/installer_contract.sh From 8d6023f52b1239936bd68513d56c29b3d5a40759 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:21:49 +0800 Subject: [PATCH 118/152] fix(uninstall): preserve boot-critical recovery state --- module/uninstall.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/module/uninstall.sh b/module/uninstall.sh index ee4bfad..d193637 100644 --- a/module/uninstall.sh +++ b/module/uninstall.sh @@ -1,2 +1,19 @@ -#!/bin/sh -rm -rf /data/adb/patchnest /data/adb/service.d/patchnest.sh +#!/system/bin/sh +# PatchNest uninstall safety policy. +# +# Removing a root-manager module does not restore the boot partition. Therefore +# deleting /data/adb/patchnest here could destroy the exact rollback backup, +# Public1158 superkey, transaction binding and recovery evidence while the +# device is still booting a PatchNest-patched kernel. Preserve that state so a +# reinstall can still authenticate and perform the reviewed bound restore. +# State may be explicitly removed only after the original boot has been +# transaction-bound restored and independently verified. + +rm -f /data/adb/service.d/patchnest.sh 2>/dev/null || true + +if [ -d /data/adb/patchnest ]; then + printf '%s\n' 'module_removed_state_preserved=1' > /data/adb/patchnest/module_removed_state_preserved 2>/dev/null || true + chmod 0600 /data/adb/patchnest/module_removed_state_preserved 2>/dev/null || true +fi + +exit 0 From e96b922c27a333f75137192b27412d6fca43c8e5 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:22:50 +0800 Subject: [PATCH 119/152] test(package): add release-safety package validator --- tests/validate_flash_package.js | 124 ++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/validate_flash_package.js diff --git a/tests/validate_flash_package.js b/tests/validate_flash_package.js new file mode 100644 index 0000000..1046ea9 --- /dev/null +++ b/tests/validate_flash_package.js @@ -0,0 +1,124 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const MOD = path.join(ROOT, 'module'); +let failed = 0; + +function fail(msg) { + failed += 1; + console.error(`FAIL: ${msg}`); +} +function pass(msg) { + console.log(`PASS: ${msg}`); +} +function read(rel) { + return fs.readFileSync(path.join(MOD, rel), 'utf8'); +} +function requireFile(rel, minSize = 1) { + const p = path.join(MOD, rel); + if (!fs.existsSync(p)) { + fail(`missing required package entry: ${rel}`); + return false; + } + const st = fs.statSync(p); + if (!st.isFile() || st.size < minSize) { + fail(`invalid/empty required package entry: ${rel}`); + return false; + } + pass(`${rel} present (${st.size} bytes)`); + return true; +} + +console.log('PatchNest release-safety package validation'); + +for (const [rel, minSize] of [ + ['bin/kpatch', 1024], + ['bin/kptools', 1024], + ['bin/kpimg', 1024], + ['bin/magiskboot', 1024], + ['customize.sh', 1], + ['service.sh', 1], + ['post-fs-data.sh', 1], + ['uninstall.sh', 1], + ['device_validation.sh', 1], + ['arm_auto_recovery.sh', 1], + ['verify_auto_recovery.sh', 1], + ['export_recovery_boot.sh', 1], + ['patch/boot_patch.sh', 1], + ['patch/boot_extract.sh', 1], + ['patch/boot_unpatch.sh', 1], + ['patch/util_functions.sh', 1], + ['patch/flash_safety.sh', 1], + ['patch/transaction_safety.sh', 1], + ['patch/transactional_flash.sh', 1], + ['patch/fr014_gate.sh', 1], + ['patch/superkey_safety.sh', 1], +]) requireFile(rel, minSize); + +// The review branch must be non-installable. A dedicated candidate is allowed +// to replace this with FR014_DEVICE_CANDIDATE, but never to omit both markers. +const blocker = path.join(MOD, 'FLASH_REVIEW_BLOCKED'); +const candidate = path.join(MOD, 'FR014_DEVICE_CANDIDATE'); +if (!fs.existsSync(blocker) && !fs.existsSync(candidate)) { + fail('package has neither FLASH_REVIEW_BLOCKED nor FR014_DEVICE_CANDIDATE'); +} else if (fs.existsSync(blocker) && fs.existsSync(candidate)) { + fail('package contains both review blocker and physical-candidate marker'); +} else { + pass(fs.existsSync(blocker) ? 'review blocker present' : 'FR-014 candidate marker present'); +} + +const util = read('patch/util_functions.sh'); +if (/\beval\b/.test(util)) fail('runtime util_functions.sh still contains eval'); +else pass('runtime util_functions.sh contains no eval'); +if (/rm\s+-rf\s+["']?\$MODPATH/.test(util)) fail('runtime util_functions.sh can recursively delete MODPATH'); +else pass('runtime util_functions.sh cannot recursively delete MODPATH'); + +const safety = read('patch/flash_safety.sh'); +if (!/abort\(\)[\s\S]*?exit\s+1/.test(safety)) fail('flash_safety abort override does not terminate safely'); +else pass('flash_safety abort terminates without upstream cleanup'); +if (/vendor_boot|init_boot/.test(safety.match(/find_boot_image\(\)[\s\S]*?\n\}/)?.[0] || '')) { + fail('reviewed find_boot_image contains vendor_boot/init_boot fallback'); +} else pass('reviewed boot resolver has no vendor_boot/init_boot fallback'); + +const patcher = read('patch/boot_patch.sh'); +if (!patcher.includes('patchnest_transactional_flash')) fail('boot patcher does not use transactional writer'); +else pass('boot patcher uses transactional writer'); +if (/flash_image\s+"\$WORKDIR\/new-boot\.img"/.test(patcher)) fail('boot patcher directly calls low-level writer for patched boot'); +else pass('boot patcher does not bypass transactional writer'); + +const tx = read('patch/transactional_flash.sh'); +if (!tx.includes('patchnest_consume_fr014_preflight_if_required')) fail('transaction writer lacks FR-014 receipt gate'); +else pass('transaction writer contains FR-014 receipt gate'); +if (!tx.includes('PATCHNEST_FR014_GATE_MISSING')) fail('transaction writer does not fail closed on missing candidate gate helper'); +else pass('candidate gate-helper absence fails closed'); + +const gate = read('patch/fr014_gate.sh'); +for (const required of [ + 'boot_target_sha256', + 'device_binding_sha256', + 'candidate_marker_sha256', + 'patchnest_clear_fr014_preflight_receipt', +]) { + if (!gate.includes(required)) fail(`FR-014 gate missing binding/control: ${required}`); +} +if (failed === 0) pass('FR-014 receipt binds target/device/candidate and is one-time'); + +const transaction = read('patch/transaction_safety.sh'); +if (!transaction.includes('patchnest_json_string rollback_backup "$PATCHNEST_PENDING_TRANSACTION_FILE"')) { + fail('rollback commit does not verify exact pending backup filename'); +} else pass('rollback commit binds exact pending backup filename'); + +const uninstall = read('uninstall.sh'); +if (/rm\s+-rf\s+\/data\/adb\/patchnest/.test(uninstall)) { + fail('uninstall destroys boot-critical PatchNest recovery state'); +} else pass('uninstall preserves boot-critical recovery state'); + +if (failed) { + console.error(`release-safety package validation failed: ${failed} issue(s)`); + process.exit(1); +} +console.log('release-safety package validation: PASS'); From 7876bb017d05a3979d1f11d6763891e06b79f3ab Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:24:06 +0800 Subject: [PATCH 120/152] ci(package): run release-safety validator on assembled module --- scripts/package_module.sh | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index cd8ade9..12266af 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -18,10 +18,34 @@ case "$OUTPUT" in *) OUTPUT_ABS="$(pwd)/$OUTPUT" ;; esac +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd) +SAFETY_VALIDATOR="$REPO_ROOT/tests/validate_flash_package.js" + 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; } +# Build assembles the Android binaries/WebUI into module/ immediately before +# packaging. When that complete tree is present, release-safety validation is a +# mandatory pre-ZIP gate. The source-only Flash safety workflow intentionally +# lacks these generated binaries and continues with its shell/transaction tests. +if [ -s "$SOURCE_DIR/bin/kpatch" ] && \ + [ -s "$SOURCE_DIR/bin/kptools" ] && \ + [ -s "$SOURCE_DIR/bin/kpimg" ] && \ + [ -s "$SOURCE_DIR/bin/magiskboot" ] && \ + [ -s "$SOURCE_DIR/webroot/index.html" ]; then + [ -f "$SAFETY_VALIDATOR" ] || { + echo "assembled module safety validator missing: $SAFETY_VALIDATOR" >&2 + exit 1 + } + command -v node >/dev/null 2>&1 || { + echo "node is required to validate an assembled release package" >&2 + exit 1 + } + node "$SAFETY_VALIDATOR" +fi + STAGE=$(mktemp -d) cleanup() { rm -rf "$STAGE"; } trap cleanup EXIT HUP INT TERM From 67166036399ab1ad1405d2be8e0cd54443ea0885 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:26:36 +0800 Subject: [PATCH 121/152] fix(validation): keep unpatched FR-014 candidate in idle boot state --- module/post-fs-data.sh | 50 ++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/module/post-fs-data.sh b/module/post-fs-data.sh index 8c2b727..7956ffc 100644 --- a/module/post-fs-data.sh +++ b/module/post-fs-data.sh @@ -1,9 +1,5 @@ #!/system/bin/sh -# P1-fix (ultracode-audit-2026-06-06): enable strict error handling -# so an unexpected error in early boot doesn't silently disable -# bootloop recovery. Combined with the explicit quoting below, -# this means a corrupted $BOOT_COUNT_FILE can never reach an `eval` -# or cause a missing var to silently become '0' in the wrong way. +# Early boot counter / automatic recovery arming. set -eu MODDIR=${0%/*} @@ -13,39 +9,55 @@ PNDIR="/data/adb/patchnest" BOOT_COUNT_FILE="$PNDIR/boot_count" AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" -# Ensure the directories exist BEFORE trying to write into them. -# mkdir -p on a missing parent path can otherwise create weird -# intermediate state if the script is interrupted mid-boot. mkdir -p "$SERVICE_D" "$PNDIR" cp "$MODDIR/status.sh" "$STATUS_SH" chmod 755 "$STATUS_SH" +# A dedicated FR-014 candidate is intentionally installed before the first +# PatchNest boot mutation so preflight/recovery export can run against the stock +# target. In that state there is no patched kernel to recover and counting the +# normal stock boot as a PatchNest boot failure would create a false bootloop. +# Once any durable patch/credential/transaction evidence exists, normal recovery +# counting becomes mandatory again. +fr014_prepatch_idle() { + [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ] || return 1 + for _pn_evidence in \ + rollback_binding.json \ + transaction.pending.json \ + flash_recovery_required \ + superkey \ + superkey.pending \ + last_flash.json; do + [ ! -e "$PNDIR/$_pn_evidence" ] || return 1 + done + return 0 +} + +if fr014_prepatch_idle; then + printf '%s\n' 0 > "$BOOT_COUNT_FILE" + rm -f "$AUTORECOVERY_MARKER" "$PNDIR/auto_unpatch_requested" + exit 0 +fi + # ============================================================ # Bootloop Auto-Recovery counter -# - Increments on every post-fs-data.sh run. -# - If counter reaches >= 3 consecutive failed boots, do NOT -# increment further; instead signal auto-unpatch by leaving -# the marker file. service.sh will reset it on a healthy boot. +# - Increments on every post-fs-data.sh run after PatchNest has durable +# evidence that a destructive candidate transaction has begun/committed. +# - If counter reaches >= 3 consecutive failed boots, service.sh performs the +# exact transaction-bound restore before normal runtime mutations. # ============================================================ current_count=0 if [ -f "$BOOT_COUNT_FILE" ]; then - # P1-fix: read with a fd and strip everything that isn't a digit. - # The `printf '%s'` (vs `echo`) prevents backslash interpretation - # in shells that treat echo as a builtin with -e semantics. current_count=$(printf '%s' "$(cat "$BOOT_COUNT_FILE" 2>/dev/null || true)" | tr -cd '0-9' | head -c 6) [ -n "$current_count" ] || current_count=0 fi if [ "$current_count" -ge 3 ] 2>/dev/null; then - # Bootloop detected — signal auto-unpatch. Counter stays at 3 - # so we never miss the signal until service.sh clears it. touch "$AUTORECOVERY_MARKER" - # Mark auto-unpatch request for boot_unpatch.sh consumers (e.g. WebUI action). touch "$PNDIR/auto_unpatch_requested" else current_count=$((current_count + 1)) echo "$current_count" > "$BOOT_COUNT_FILE" - # If we just transitioned into the danger zone this boot, surface it. if [ "$current_count" -ge 3 ]; then touch "$AUTORECOVERY_MARKER" touch "$PNDIR/auto_unpatch_requested" From 820eac8d9b98663f10e471f1f189478de6de820a Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:28:30 +0800 Subject: [PATCH 122/152] fix(runtime): separate FR-014 prepatch idle state and gate KPM autoload --- module/service.sh | 109 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 27 deletions(-) diff --git a/module/service.sh b/module/service.sh index 6a24ed3..f27595e 100644 --- a/module/service.sh +++ b/module/service.sh @@ -35,19 +35,29 @@ case "$KPM_SIGNATURE_POLICY" in *) REQUIRE_KPM_SIGNATURES=1 ;; esac -# shellcheck disable=SC1091 -. "$MODDIR/kpm_verify.sh" 2>/dev/null || true -# 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" echo "[$(date)] MODDIR=$MODDIR" >> "$LOG" echo "[$(date)] PATH=$PATH" >> "$LOG" echo "[$(date)] KPM_SIGNATURE_POLICY=$KPM_SIGNATURE_POLICY" >> "$LOG" +# Signature verification is optional only when policy permits unsigned modules. +# Transaction/key helpers are boot-safety critical and must load successfully. +# shellcheck disable=SC1091 +. "$MODDIR/kpm_verify.sh" 2>/dev/null || true +if [ ! -r "$MODDIR/patch/superkey_safety.sh" ] || \ + ! . "$MODDIR/patch/superkey_safety.sh" 2>>"$LOG"; then + echo "[$(date)] ERROR: superkey safety helper unavailable" >> "$LOG" + touch "$MODDIR/unresolved" + exit 0 +fi +if [ ! -r "$MODDIR/patch/transaction_safety.sh" ] || \ + ! . "$MODDIR/patch/transaction_safety.sh" 2>>"$LOG"; then + echo "[$(date)] ERROR: transaction safety helper unavailable" >> "$LOG" + touch "$MODDIR/unresolved" + exit 0 +fi + ROOT_MGR="unknown" if [ -f "$PNDIR/root_manager" ]; then _rm_raw="$(cat "$PNDIR/root_manager" 2>/dev/null || true)" @@ -62,6 +72,31 @@ if [ ! -x "$MODDIR/bin/kpatch" ]; then exit 0 fi +# A dedicated FR-014 candidate is installed while the device still boots its +# pre-test stock target. Until durable PatchNest transaction/credential evidence +# exists, hello failure is expected and must not be misclassified as a bootloop +# or unresolved patched-kernel failure. +fr014_prepatch_idle() { + [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ] || return 1 + for _pn_evidence in \ + rollback_binding.json \ + transaction.pending.json \ + flash_recovery_required \ + superkey \ + superkey.pending \ + last_flash.json; do + [ ! -e "$PNDIR/$_pn_evidence" ] || return 1 + done + return 0 +} + +if fr014_prepatch_idle; then + echo "[$(date)] FR-014 candidate pre-patch idle: no kernel ABI probe or runtime mutations" >> "$LOG" + printf '%s\n' 0 > "$BOOT_COUNT_FILE" 2>/dev/null || true + rm -f "$AUTORECOVERY_MARKER" "$AUTO_UNPATCH_REQUEST" "$MODDIR/unresolved" + exit 0 +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 @@ -101,8 +136,6 @@ try_pending_public1158_key() { fi if ! patchnest_commit_binding_from_pending_written "$_pn_pending_key"; then - # 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" @@ -148,8 +181,6 @@ handle_requested_auto_recovery() { 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" @@ -159,9 +190,6 @@ handle_requested_auto_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" \ @@ -180,8 +208,6 @@ handle_requested_auto_recovery() { 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=$? @@ -227,13 +253,35 @@ esac echo "[$(date)] kpatch hello OK: $hello_out profile=$ABI_PROFILE" >> "$LOG" printf '%s\n' "$ABI_PROFILE" > "$PNDIR/abi_profile" -# IMPORTANT: hello does not prove a healthy Android boot. boot_count and -# autorecovery markers are cleared only after sys.boot_completed=1 below. + +validate_runtime_kpm() { + _pn_file=$1 + command -v xxd >/dev/null 2>&1 || return 1 + [ -f "$_pn_file" ] && [ ! -L "$_pn_file" ] && [ -s "$_pn_file" ] || return 1 + _pn_hdr=$(xxd -p -l 20 "$_pn_file" 2>/dev/null | tr -d '\r\n') + [ "$(printf '%s' "$_pn_hdr" | cut -c1-12)" = "7f454c460201" ] || return 1 + [ "$(printf '%s' "$_pn_hdr" | cut -c37-40)" = "b700" ] || return 1 + PATH="$MODDIR/bin:$PATH" kptools -l -M "$_pn_file" >/dev/null 2>&1 || return 1 + return 0 +} 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\)$//') + + if [ ! -f "$KPM_EVENT_DIR/${mod_basename}.autoload" ]; then + echo "[$(date)] KPM autoload disabled or unregistered: $(basename "$kpm")" >> "$LOG" + continue + fi + + if ! validate_runtime_kpm "$kpm"; then + echo "[$(date)] REJECTED (invalid/non-AArch64 KPM): $(basename "$kpm"), moving to failed/" >> "$LOG" + mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" 2>/dev/null || true + rm -f "$KPM_EVENT_DIR/${mod_basename}.autoload" + continue + fi + args="" if [ -f "$KPM_EVENT_DIR/${mod_basename}.args" ]; then raw_args="$(cat "$KPM_EVENT_DIR/${mod_basename}.args" 2>/dev/null || true)" @@ -246,14 +294,16 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do if [ "$KPM_SIGNATURE_POLICY" = "strict" ]; then echo "[$(date)] REJECTED (strict, unsigned): $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" + rm -f "$KPM_EVENT_DIR/${mod_basename}.autoload" continue 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" + elif ! command -v verify_kpm_sig >/dev/null 2>&1 || ! verify_kpm_sig "$kpm" "$_kpm_sig"; then + echo "[$(date)] REJECTED (sig invalid/unverifiable): $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" mv "$_kpm_sig" "$KPM_DIR/failed/$(basename "$_kpm_sig")" 2>/dev/null || true + rm -f "$KPM_EVENT_DIR/${mod_basename}.autoload" continue fi fi @@ -266,6 +316,7 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do if [ $? -ne 0 ]; then echo "[$(date)] Failed to load: $(basename "$kpm"), moving to failed/" >> "$LOG" mv "$kpm" "$KPM_DIR/failed/$(basename "$kpm")" + rm -f "$KPM_EVENT_DIR/${mod_basename}.autoload" else echo "[$(date)] Loaded: $(basename "$kpm") args=[$args]" >> "$LOG" fi @@ -327,16 +378,20 @@ fi if [ -f "$CONFIG" ]; then excluded_count=0 excluded_failed=0 - _cfg_tmp=$(mktemp /data/local/tmp/patchnest_cfg.XXXXXX) + _cfg_tmp=$(mktemp /data/local/tmp/patchnest_cfg.XXXXXX) || { + echo "[$(date)] ERROR: cannot create exclusion workspace" >> "$LOG" + touch "$MODDIR/unresolved" + exit 0 + } tail -n +2 "$CONFIG" > "$_cfg_tmp" while IFS= read -r line; do [ -z "$line" ] && continue - 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 - 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}') + pkg=$(printf '%s' "$line" | awk -F, '{print $1}') + exclude=$(printf '%s' "$line" | awk -F, '{print $2}') + uid=$(printf '%s' "$line" | awk -F, '{print $4}') + if [ "$exclude" = "1" ] && [ -n "$pkg" ] && printf '%s' "$uid" | grep -Eq '^[0-9]+$'; then + UID_VAL=$(awk -v p="$pkg" -v u="$uid" '$1 == p && $2 == u { print $2; exit }' \ + /data/system/packages.list 2>/dev/null) if [ -n "$UID_VAL" ]; then if kpatch exclude_set "$UID_VAL" 1 >>"$LOG" 2>&1; then excluded_count=$((excluded_count + 1)) From 05486cebc5a6052bc98885024f1e0fd6f524af83 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:29:38 +0800 Subject: [PATCH 123/152] fix(kpm): validate archive and ARM64 module before root install --- module/install_kpm.sh | 313 +++++++++++++++++++++++++----------------- 1 file changed, 184 insertions(+), 129 deletions(-) diff --git a/module/install_kpm.sh b/module/install_kpm.sh index 919b02f..d25db57 100644 --- a/module/install_kpm.sh +++ b/module/install_kpm.sh @@ -1,14 +1,8 @@ #!/system/bin/sh -# -# KPM ZIP Installer +# PatchNest KPM ZIP installer # Usage: install_kpm.sh -# -# KPM ZIP format: -# module.prop # metadata (required) -# xxx.kpm # compiled binary (for binary modules) -# xxx.c # OR source code (for source modules) -# config.json # optional: event/args defaults -# + +set -u MODDIR=${0%/*} PNDIR="/data/adb/patchnest" @@ -17,166 +11,227 @@ KPM_ZIP_DIR="$PNDIR/kpm_zips" KPM_EVENT_DIR="$PNDIR/kpm_events" LOG="$PNDIR/service.log" PATH="$MODDIR/bin:$PATH" +ZIP_FILE=${1:-} log() { - echo "[$(date)] install_kpm: $1" >> "$LOG" - echo "- $1" + printf '[%s] install_kpm: %s\n' "$(date)" "$1" >> "$LOG" 2>/dev/null || true + printf '%s\n' "- $1" +} + +fail() { + printf '%s\n' "! $1" >&2 + log "ERROR: $1" + exit "${2:-1}" } get_prop() { - local file="$1" key="$2" - grep "^${key}=" "$file" 2>/dev/null | head -1 | cut -d'=' -f2- + _pn_file=$1 + _pn_key=$2 + grep "^${_pn_key}=" "$_pn_file" 2>/dev/null | head -1 | cut -d'=' -f2- } -ZIP_FILE="$1" -case "$ZIP_FILE" in - "" | /*) - echo "! install_kpm.sh: refusing to install with empty or absolute path: '$ZIP_FILE'" >&2 - exit 2 - ;; - *..* | */./*) - echo "! install_kpm.sh: refusing to install with path-traversal: '$ZIP_FILE'" >&2 - exit 2 - ;; - *[!A-Za-z0-9._/+@%=-]*) - echo "! install_kpm.sh: refusing to install with unsafe characters in zip filename: '$ZIP_FILE'" >&2 - exit 2 - ;; -esac -if [ ! -f "$ZIP_FILE" ]; then - echo "! Usage: install_kpm.sh " - exit 1 -fi +ensure_real_dir() { + _pn_dir=$1 + [ ! -L "$_pn_dir" ] || return 1 + mkdir -p "$_pn_dir" || return 1 + [ -d "$_pn_dir" ] && [ ! -L "$_pn_dir" ] +} -TMPDIR=$(mktemp -d /data/local/tmp/kpm_install.XXXXXX) -trap 'rm -rf "$TMPDIR"' EXIT +validate_archive_entries() { + _pn_zip=$1 + _pn_list=$2 + unzip -Z1 "$_pn_zip" > "$_pn_list" 2>/dev/null || return 1 + [ -s "$_pn_list" ] || return 1 + while IFS= read -r _pn_entry || [ -n "$_pn_entry" ]; do + [ -n "$_pn_entry" ] || return 1 + case "$_pn_entry" in + /*|\\*|[A-Za-z]:*|../*|*/../*|*/..|..|./*|*\\*) + printf '%s\n' "! Unsafe ZIP entry: $_pn_entry" >&2 + return 1 + ;; + esac + done < "$_pn_list" + return 0 +} -echo "- Extracting $ZIP_FILE..." -unzip -o "$ZIP_FILE" -d "$TMPDIR" > /dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "! Failed to extract ZIP" - exit 1 -fi +validate_kpm_binary() { + _pn_file=$1 + [ -f "$_pn_file" ] && [ ! -L "$_pn_file" ] && [ -s "$_pn_file" ] || return 1 + command -v xxd >/dev/null 2>&1 || return 1 + [ -x "$MODDIR/bin/kptools" ] || return 1 + + _pn_hdr=$(xxd -p -l 20 "$_pn_file" 2>/dev/null | tr -d '\r\n') + # ELF64, little-endian, AArch64 (e_machine=0x00b7 at offset 18). + [ "$(printf '%s' "$_pn_hdr" | cut -c1-12)" = "7f454c460201" ] || return 1 + [ "$(printf '%s' "$_pn_hdr" | cut -c37-40)" = "b700" ] || return 1 + + _pn_meta=$(PATH="$MODDIR/bin:$PATH" kptools -l -M "$_pn_file" 2>/dev/null) || return 1 + _pn_name=$(printf '%s\n' "$_pn_meta" | sed -n 's/^name=//p' | head -n 1) + [ -n "$_pn_name" ] || return 1 + return 0 +} + +[ -n "$ZIP_FILE" ] || fail "Usage: install_kpm.sh " 2 +[ -f "$ZIP_FILE" ] && [ ! -L "$ZIP_FILE" ] || fail "KPM ZIP is missing, not regular, or is a symlink: $ZIP_FILE" 2 -if [ ! -f "$TMPDIR/module.prop" ]; then - echo "! No module.prop found in ZIP" - exit 1 +# FR-014 must remain a clean boot lifecycle test. Diagnostic KPM testing uses +# device_validation.sh kpm-cycle with its own explicit unlock instead of the +# normal persistent installer/autoload path. +if [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ]; then + fail "Persistent KPM installation is disabled on the FR-014 device candidate" 3 fi -MOD_ID=$(get_prop "$TMPDIR/module.prop" "id") -MOD_NAME=$(get_prop "$TMPDIR/module.prop" "name") -MOD_VERSION=$(get_prop "$TMPDIR/module.prop" "version") -MOD_AUTHOR=$(get_prop "$TMPDIR/module.prop" "author") -MOD_DESC=$(get_prop "$TMPDIR/module.prop" "description") -MOD_EVENT=$(get_prop "$TMPDIR/module.prop" "event") -MOD_ARGS=$(get_prop "$TMPDIR/module.prop" "args") -MOD_AUTOLOAD=$(get_prop "$TMPDIR/module.prop" "autoLoad") +ensure_real_dir "$PNDIR" || fail "Unsafe PatchNest state directory: $PNDIR" +chmod 0700 "$PNDIR" 2>/dev/null || true +for _pn_dir in "$KPM_DIR" "$KPM_ZIP_DIR" "$KPM_EVENT_DIR"; do + ensure_real_dir "$_pn_dir" || fail "Unsafe KPM state directory: $_pn_dir" +done + +TMPDIR=$(mktemp -d /data/local/tmp/kpm_install.XXXXXX) || fail "Cannot create private KPM workspace" +cleanup() { [ -n "${TMPDIR:-}" ] && [ -d "$TMPDIR" ] && rm -rf "$TMPDIR"; } +trap cleanup EXIT HUP INT TERM +chmod 0700 "$TMPDIR" 2>/dev/null || true +ENTRY_LIST="$TMPDIR/archive.entries" + +validate_archive_entries "$ZIP_FILE" "$ENTRY_LIST" || fail "KPM ZIP contains unsafe/invalid archive entries" + +printf '%s\n' "- Extracting $ZIP_FILE..." +unzip -qq -o "$ZIP_FILE" -d "$TMPDIR/extracted" || fail "Failed to extract KPM ZIP" +EXTRACTED="$TMPDIR/extracted" +[ -d "$EXTRACTED" ] || fail "KPM extraction produced no directory" +if find "$EXTRACTED" -type l -print -quit 2>/dev/null | grep -q .; then + fail "KPM ZIP contains symlink entries" +fi -MOD_ARGS="$(printf '%s' "$MOD_ARGS" | tr -cd 'A-Za-z0-9_=,.+:/@% -')" +PROP="$EXTRACTED/module.prop" +[ -f "$PROP" ] && [ ! -L "$PROP" ] || fail "KPM ZIP has no safe root module.prop" -MOD_ID="${MOD_ID:-unknown}" -MOD_NAME="${MOD_NAME:-$MOD_ID}" -MOD_VERSION="${MOD_VERSION:-0.0.0}" -MOD_AUTOLOAD="${MOD_AUTOLOAD:-true}" +MOD_ID=$(get_prop "$PROP" id) +MOD_NAME=$(get_prop "$PROP" name) +MOD_VERSION=$(get_prop "$PROP" version) +MOD_AUTHOR=$(get_prop "$PROP" author) +MOD_EVENT=$(get_prop "$PROP" event) +MOD_ARGS=$(get_prop "$PROP" args) +MOD_AUTOLOAD=$(get_prop "$PROP" autoLoad) -if [ -z "$MOD_ID" ] || [ "$MOD_ID" = "unknown" ]; then +MOD_ID=${MOD_ID:-unknown} +if [ "$MOD_ID" = unknown ]; then MOD_ID=$(basename "$ZIP_FILE" .zip | tr ' ' '_') fi +MOD_ID=$(printf '%s' "$MOD_ID" | tr -cd 'A-Za-z0-9_.-') +MOD_NAME=$(printf '%s' "${MOD_NAME:-$MOD_ID}" | tr -cd 'A-Za-z0-9 _.-') +MOD_VERSION=$(printf '%s' "${MOD_VERSION:-0.0.0}" | 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_,-') +MOD_ARGS=$(printf '%s' "${MOD_ARGS:-}" | tr -cd 'A-Za-z0-9_=,.+:/@% -') +case "${MOD_AUTOLOAD:-true}" in + true) MOD_AUTOLOAD=true ;; + false) MOD_AUTOLOAD=false ;; + *) fail "Invalid autoLoad value; expected true or false" 2 ;; +esac -MOD_ID="$(printf '%s' "$MOD_ID" | tr -cd 'A-Za-z0-9_.-')" -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_,')" - -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 +[ -n "$MOD_ID" ] && [ "${#MOD_ID}" -le 64 ] && [ "$MOD_ID" != . ] && [ "$MOD_ID" != .. ] \ + || fail "Unsafe or empty KPM id: '$MOD_ID'" 2 + +# Exactly one binary module OR one-or-more C sources. Mixed packages and +# multi-binary ambiguity are rejected rather than choosing an arbitrary file. +BINARY_LIST="$TMPDIR/binaries.list" +SOURCE_LIST="$TMPDIR/sources.list" +find "$EXTRACTED" -type f \( -name '*.kpm' -o -name '*.ko' -o -name '*.o' \) \ + ! -name '._*' ! -name '.DS_Store' -print > "$BINARY_LIST" +find "$EXTRACTED" -type f -name '*.c' ! -name '._*' ! -name '.DS_Store' -print > "$SOURCE_LIST" +BINARY_COUNT=$(awk 'END{print NR+0}' "$BINARY_LIST") +SOURCE_COUNT=$(awk 'END{print NR+0}' "$SOURCE_LIST") + +if [ "$BINARY_COUNT" -gt 0 ] && [ "$SOURCE_COUNT" -gt 0 ]; then + fail "KPM ZIP mixes binary and source modules; package is ambiguous" +fi +if [ "$BINARY_COUNT" -gt 1 ]; then + fail "KPM ZIP contains multiple binary module candidates" +fi +if [ "$BINARY_COUNT" -eq 0 ] && [ "$SOURCE_COUNT" -eq 0 ]; then + fail "No .kpm/.ko/.o or .c module files found" fi -log "Installing KPM: $MOD_NAME ($MOD_ID) v$MOD_VERSION" -mkdir -p "$KPM_DIR" "$KPM_ZIP_DIR" "$KPM_EVENT_DIR" - -SRC_FILES=$(find "$TMPDIR" -type f -name "*.c" \ - ! -name '._*' ! -name '.DS_Store' 2>/dev/null) -KPM_FILES=$(find "$TMPDIR" -type f \ - \( -name "*.kpm" -o -name "*.ko" -o -name "*.o" \) \ - ! -name '._*' ! -name '.DS_Store' 2>/dev/null) - -if [ -n "$KPM_FILES" ]; then - 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" - +STAGED_KPM="$TMPDIR/${MOD_ID}.kpm" +if [ "$BINARY_COUNT" -eq 1 ]; then + KPM_FILE=$(sed -n '1p' "$BINARY_LIST") + validate_kpm_binary "$KPM_FILE" || fail "Binary module is not a valid AArch64 KPM" + cp "$KPM_FILE" "$STAGED_KPM" || fail "Cannot stage validated KPM" +else + COMPILE_SCRIPT="$MODDIR/compile_kpm.sh" + [ -x "$COMPILE_SCRIPT" ] || fail "Source KPM requires an available compiler helper" + "$COMPILE_SCRIPT" "$EXTRACTED" "$STAGED_KPM" "$MODDIR" || fail "KPM source compilation failed" + validate_kpm_binary "$STAGED_KPM" || fail "Compiled module failed AArch64/KPM validation" +fi +chmod 0600 "$STAGED_KPM" 2>/dev/null || true + +log "Installing validated KPM: $MOD_NAME ($MOD_ID) v$MOD_VERSION" +DEST_KPM="$KPM_DIR/${MOD_ID}.kpm" +DEST_TMP="$KPM_DIR/.${MOD_ID}.kpm.tmp.$$" +cp "$STAGED_KPM" "$DEST_TMP" || fail "Cannot stage KPM in persistent directory" +chmod 0600 "$DEST_TMP" 2>/dev/null || true +mv -f "$DEST_TMP" "$DEST_KPM" || fail "Cannot atomically commit KPM" + +# Never retain a signature from an older binary revision. +rm -f "$KPM_DIR/${MOD_ID}.kpm.sig" +KPM_BASENAME=$(basename "$(sed -n '1p' "$BINARY_LIST" 2>/dev/null || true)") +if [ -n "$KPM_BASENAME" ]; then _kpm_stem=$(printf '%s' "$KPM_BASENAME" | sed -E 's/\.(kpm|ko|o)$//') - for _sig in "$TMPDIR/${_kpm_stem}.kpm.sig" \ - "$TMPDIR/${_kpm_stem}.sig" \ - "$TMPDIR/$(basename "$KPM_BASENAME" .kpm).kpm.sig" \ - "$TMPDIR/$(basename "$KPM_BASENAME" .kpm).sig"; do - if [ -f "$_sig" ]; then - cp "$_sig" "$KPM_DIR/${MOD_ID}.kpm.sig" - log "Signature copied: $KPM_DIR/${MOD_ID}.kpm.sig" + for _sig in "$EXTRACTED/${_kpm_stem}.kpm.sig" \ + "$EXTRACTED/${_kpm_stem}.sig" \ + "$EXTRACTED/$(basename "$KPM_BASENAME" .kpm).kpm.sig" \ + "$EXTRACTED/$(basename "$KPM_BASENAME" .kpm).sig"; do + if [ -f "$_sig" ] && [ ! -L "$_sig" ]; then + cp "$_sig" "$KPM_DIR/${MOD_ID}.kpm.sig" || fail "Cannot install KPM signature" + chmod 0600 "$KPM_DIR/${MOD_ID}.kpm.sig" 2>/dev/null || true break fi done -elif [ -n "$SRC_FILES" ]; then - COMPILE_SCRIPT="$MODDIR/compile_kpm.sh" - if [ -x "$COMPILE_SCRIPT" ]; then - echo "- Compiling source module..." - "$COMPILE_SCRIPT" "$TMPDIR" "$KPM_DIR/${MOD_ID}.kpm" "$MODDIR" - if [ $? -ne 0 ]; then - log "Compilation failed for $MOD_ID" - echo "! Compilation failed" - exit 1 - fi - log "Source module compiled and installed" - else - 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 -else - echo "! No .kpm/.ko/.o or .c files found in ZIP" - exit 1 fi -cp "$ZIP_FILE" "$KPM_ZIP_DIR/${MOD_ID}.zip" +ZIP_TMP="$KPM_ZIP_DIR/.${MOD_ID}.zip.tmp.$$" +cp "$ZIP_FILE" "$ZIP_TMP" || fail "Cannot stage original KPM ZIP" +chmod 0600 "$ZIP_TMP" 2>/dev/null || true +mv -f "$ZIP_TMP" "$KPM_ZIP_DIR/${MOD_ID}.zip" || fail "Cannot commit original KPM ZIP" +cp "$PROP" "$KPM_ZIP_DIR/${MOD_ID}.prop" || fail "Cannot install KPM metadata" +chmod 0600 "$KPM_ZIP_DIR/${MOD_ID}.prop" 2>/dev/null || true if [ -n "$MOD_EVENT" ]; then - echo "$MOD_EVENT" > "$KPM_EVENT_DIR/${MOD_ID}.events" - log "Events registered: $MOD_EVENT" + printf '%s\n' "$MOD_EVENT" > "$KPM_EVENT_DIR/${MOD_ID}.events" || fail "Cannot write KPM event metadata" +else + rm -f "$KPM_EVENT_DIR/${MOD_ID}.events" fi - if [ -n "$MOD_ARGS" ]; then - echo "$MOD_ARGS" > "$KPM_EVENT_DIR/${MOD_ID}.args" + printf '%s\n' "$MOD_ARGS" > "$KPM_EVENT_DIR/${MOD_ID}.args" || fail "Cannot write KPM args" +else + rm -f "$KPM_EVENT_DIR/${MOD_ID}.args" fi -if [ "$MOD_AUTOLOAD" = "true" ]; then - touch "$KPM_EVENT_DIR/${MOD_ID}.autoload" +if [ "$MOD_AUTOLOAD" = true ]; then + touch "$KPM_EVENT_DIR/${MOD_ID}.autoload" || fail "Cannot enable KPM autoload" +else + rm -f "$KPM_EVENT_DIR/${MOD_ID}.autoload" fi +chmod 0600 "$KPM_EVENT_DIR/${MOD_ID}."* 2>/dev/null || true -cp "$TMPDIR/module.prop" "$KPM_ZIP_DIR/${MOD_ID}.prop" - -if [ "$MOD_AUTOLOAD" = "true" ]; then - echo "- Loading module..." - # 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 [ "$MOD_AUTOLOAD" = true ]; then + printf '%s\n' "- Loading module..." if [ -n "$MOD_ARGS" ]; then - kpatch kpm load "$KPM_DIR/${MOD_ID}.kpm" "$MOD_ARGS" 2>&1 + kpatch kpm load "$DEST_KPM" "$MOD_ARGS" 2>&1 else - kpatch kpm load "$KPM_DIR/${MOD_ID}.kpm" 2>&1 + kpatch kpm load "$DEST_KPM" 2>&1 fi if [ $? -eq 0 ]; then log "Module $MOD_ID loaded successfully" - echo "- Successfully installed and loaded: $MOD_NAME v$MOD_VERSION" + printf '%s\n' "- Successfully installed and loaded: $MOD_NAME v$MOD_VERSION" else - log "Module $MOD_ID load failed (will retry on boot)" - echo "- Installed but load failed (will retry on boot): $MOD_NAME v$MOD_VERSION" + log "Module $MOD_ID load failed; persistent autoload remains enabled for next healthy boot" + printf '%s\n' "- Installed but load failed; will retry on next healthy boot: $MOD_NAME v$MOD_VERSION" fi else - echo "- Installed (auto-load disabled): $MOD_NAME v$MOD_VERSION" + log "Module $MOD_ID installed with autoload disabled" + printf '%s\n' "- Installed with auto-load disabled: $MOD_NAME v$MOD_VERSION" fi + +exit 0 From 6aa145bb5db913d6ed8070ac5d9510cf154cd64b Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:30:44 +0800 Subject: [PATCH 124/152] fix(validation): require matching recovery export before candidate write --- module/patch/fr014_gate.sh | 73 +++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/module/patch/fr014_gate.sh b/module/patch/fr014_gate.sh index c19926a..14d3492 100644 --- a/module/patch/fr014_gate.sh +++ b/module/patch/fr014_gate.sh @@ -1,9 +1,10 @@ #!/system/bin/sh -# FR-014 physical-candidate one-time preflight receipt. +# FR-014 physical-candidate one-time preflight/recovery-export gate. # Requires transaction_safety.sh helpers. It is a no-op for normal review/release # trees that do not contain FR014_DEVICE_CANDIDATE. PATCHNEST_FR014_PREFLIGHT_FILE="${PATCHNEST_FR014_PREFLIGHT_FILE:-/data/adb/patchnest/fr014_preflight.json}" +PATCHNEST_RECOVERY_EXPORT_FILE="${PATCHNEST_RECOVERY_EXPORT_FILE:-${PATCHNEST_FR014_PREFLIGHT_FILE%/*}/recovery_export.json}" patchnest_fr014_module_dir() { if [ -n "${PATCHNEST_MODULE_DIR:-}" ]; then @@ -64,6 +65,68 @@ EOF patchnest_state_file_is_secure "$PATCHNEST_FR014_PREFLIGHT_FILE" } +patchnest_fr014_clean_state_still_holds() { + _pn_mod=$(patchnest_fr014_module_dir) || return 1 + _pn_state=${PATCHNEST_FR014_PREFLIGHT_FILE%/*} + [ ! -f "$_pn_mod/unresolved" ] || { + >&2 echo "! Module became unresolved after FR-014 preflight" + return 1 + } + for _pn_stale in \ + rollback_binding.json \ + transaction.pending.json \ + flash_recovery_required \ + superkey \ + superkey.pending \ + last_flash.json \ + last_restore.json \ + auto_unpatch_requested \ + autorecovery_active \ + auto_recovery_restored \ + credential_recovered_pending; do + [ ! -e "$_pn_state/$_pn_stale" ] || { + >&2 echo "! PatchNest state changed after FR-014 preflight: $_pn_stale" + return 1 + } + done + if [ -d "$_pn_state/kpm" ]; then + _pn_kpm=$(find "$_pn_state/kpm" -maxdepth 1 -type f \( -name '*.kpm' -o -name '*.ko' -o -name '*.o' \) -print -quit 2>/dev/null) + [ -z "$_pn_kpm" ] || { + >&2 echo "! Runtime KPM appeared after FR-014 preflight" + return 1 + } + fi + return 0 +} + +patchnest_fr014_recovery_export_matches() { + _pn_target=$1 + _pn_expected_target_sha=$2 + patchnest_state_file_is_secure "$PATCHNEST_RECOVERY_EXPORT_FILE" || { + >&2 echo "! FR-014 candidate requires a verified recovery boot export after preflight" + return 1 + } + [ "$(patchnest_json_bool verified "$PATCHNEST_RECOVERY_EXPORT_FILE")" = "true" ] || return 1 + _pn_export_target=$(patchnest_json_string boot_target "$PATCHNEST_RECOVERY_EXPORT_FILE") + _pn_export_sha=$(patchnest_json_string image_sha256 "$PATCHNEST_RECOVERY_EXPORT_FILE") + _pn_export_size=$(patchnest_json_number image_size "$PATCHNEST_RECOVERY_EXPORT_FILE") + [ "$_pn_export_target" = "$_pn_target" ] || { + >&2 echo "! Recovery export belongs to a different boot target" + return 1 + } + printf '%s' "$_pn_export_sha" | grep -Eq '^[0-9a-f]{64}$' || return 1 + printf '%s' "$_pn_export_size" | grep -Eq '^[1-9][0-9]*$' || return 1 + [ "$_pn_export_sha" = "$_pn_expected_target_sha" ] || { + >&2 echo "! Recovery export SHA does not match preflight boot SHA" + return 1 + } + [ "$(patchnest_hash_file "$_pn_target")" = "$_pn_export_sha" ] || { + >&2 echo "! Live boot target no longer matches exported recovery image" + return 1 + } + return 0 +} + patchnest_consume_fr014_preflight_if_required() { _pn_target=$1 patchnest_fr014_candidate_active || return 0 @@ -97,10 +160,12 @@ patchnest_consume_fr014_preflight_if_required() { >&2 echo "! FR-014 candidate identity changed after preflight" return 1 } + patchnest_fr014_clean_state_still_holds || return 1 + patchnest_fr014_recovery_export_matches "$_pn_target" "$_pn_expected_target" || return 1 - # One successful validation authorizes one destructive attempt only. Consume - # before the transaction is staged; any later pre-write failure requires a - # fresh read-only preflight rather than silently reusing stale approval. + # One successful validation/export pair authorizes one destructive attempt. + # Consume preflight approval before transaction staging; the recovery export + # receipt is retained because it is boot-critical evidence for later rescue. patchnest_clear_fr014_preflight_receipt || return 1 return 0 } From b039ab2956a35eb564b1599fa6f2b0350c77bdbc Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:31:35 +0800 Subject: [PATCH 125/152] test(validation): enforce recovery export and clean state at candidate write --- tests/fr014_write_gate_contract.sh | 67 ++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/tests/fr014_write_gate_contract.sh b/tests/fr014_write_gate_contract.sh index 79942ce..4b4dbc0 100644 --- a/tests/fr014_write_gate_contract.sh +++ b/tests/fr014_write_gate_contract.sh @@ -29,10 +29,11 @@ PATCHNEST_PENDING_TRANSACTION_FILE="$STATE/transaction.pending.json" PATCHNEST_RECOVERY_REQUIRED_FILE="$STATE/flash_recovery_required" PATCHNEST_BACKUP_DIR="$TMP/backups" PATCHNEST_FR014_PREFLIGHT_FILE="$STATE/fr014_preflight.json" +PATCHNEST_RECOVERY_EXPORT_FILE="$STATE/recovery_export.json" PATCHNEST_SUPERKEY_PENDING_FILE="$STATE/superkey.pending" export PATCHNEST_TRANSACTION_TEST PATCHNEST_DEVICE_IDENTITY PATCHNEST_MODULE_DIR MODPATH export PATCHNEST_ROLLBACK_BINDING_FILE PATCHNEST_PENDING_TRANSACTION_FILE PATCHNEST_RECOVERY_REQUIRED_FILE PATCHNEST_BACKUP_DIR -export PATCHNEST_FR014_PREFLIGHT_FILE PATCHNEST_SUPERKEY_PENDING_FILE +export PATCHNEST_FR014_PREFLIGHT_FILE PATCHNEST_RECOVERY_EXPORT_FILE PATCHNEST_SUPERKEY_PENDING_FILE # shellcheck disable=SC1090 . "$PATCH/transaction_safety.sh" @@ -51,6 +52,31 @@ flash_image() { # shellcheck disable=SC1090 . "$PATCH/transactional_flash.sh" +write_recovery_receipt() { + _pn_sha=$(sha256sum "$TARGET" | awk '{print $1}') + _pn_size=$(stat -c '%s' "$TARGET") + cat > "$PATCHNEST_RECOVERY_EXPORT_FILE" < "$FLASH_CALLS" +} + # 1. Candidate destructive write without a fresh preflight receipt must fail # before flash_image is called. set +e @@ -64,6 +90,7 @@ cmp -s "$TARGET" "$BACKUP" || { echo "FR-014 write gate contract: FAIL: no-recei # 2. A receipt is bound to the exact live target bytes. External target change # after preflight must invalidate it before the writer is reached. patchnest_write_fr014_preflight_receipt "$TARGET" || { echo "FR-014 write gate contract: FAIL: receipt creation failed" >&2; exit 1; } +write_recovery_receipt printf '%s\n' externally-changed > "$TARGET" set +e patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" @@ -72,18 +99,42 @@ set -e [ "$rc" -eq 9 ] || { echo "FR-014 write gate contract: FAIL: changed-target rc=$rc" >&2; exit 1; } [ "$(cat "$FLASH_CALLS")" -eq 0 ] || { echo "FR-014 write gate contract: FAIL: changed-target path touched writer" >&2; exit 1; } -# 3. Fresh receipt + unchanged target authorizes exactly one destructive attempt. -cp "$BACKUP" "$TARGET" -patchnest_clear_fr014_preflight_receipt +# 3. Preflight without a matching verified recovery export is insufficient. +reset_clean_target +patchnest_write_fr014_preflight_receipt "$TARGET" || { echo "FR-014 write gate contract: FAIL: receipt creation for no-export case failed" >&2; exit 1; } +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 9 ] || { echo "FR-014 write gate contract: FAIL: missing recovery export rc=$rc" >&2; exit 1; } +[ "$(cat "$FLASH_CALLS")" -eq 0 ] || { echo "FR-014 write gate contract: FAIL: missing-export path touched writer" >&2; exit 1; } + +# 4. KPM state appearing after preflight invalidates the clean lifecycle. +reset_clean_target +patchnest_write_fr014_preflight_receipt "$TARGET" || exit 1 +write_recovery_receipt +mkdir -p "$STATE/kpm" +printf '%s\n' synthetic > "$STATE/kpm/injected.kpm" +set +e +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" +rc=$? +set -e +[ "$rc" -eq 9 ] || { echo "FR-014 write gate contract: FAIL: post-preflight KPM rc=$rc" >&2; exit 1; } +[ "$(cat "$FLASH_CALLS")" -eq 0 ] || { echo "FR-014 write gate contract: FAIL: KPM-contaminated path touched writer" >&2; exit 1; } + +# 5. Fresh preflight + matching recovery export + unchanged clean state authorizes +# exactly one destructive attempt. +reset_clean_target patchnest_write_fr014_preflight_receipt "$TARGET" || { echo "FR-014 write gate contract: FAIL: fresh receipt creation failed" >&2; exit 1; } -patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" || { echo "FR-014 write gate contract: FAIL: valid receipt rejected" >&2; exit 1; } +write_recovery_receipt +patchnest_transactional_flash "$SOURCE" "$TARGET" "$BACKUP" || { echo "FR-014 write gate contract: FAIL: valid receipt/export pair rejected" >&2; exit 1; } [ "$(cat "$FLASH_CALLS")" -eq 1 ] || { echo "FR-014 write gate contract: FAIL: valid path writer count incorrect" >&2; exit 1; } cmp -s "$TARGET" "$SOURCE" || { echo "FR-014 write gate contract: FAIL: valid path did not write source" >&2; exit 1; } -[ ! -e "$PATCHNEST_FR014_PREFLIGHT_FILE" ] || { echo "FR-014 write gate contract: FAIL: receipt was not consumed" >&2; exit 1; } +[ ! -e "$PATCHNEST_FR014_PREFLIGHT_FILE" ] || { echo "FR-014 write gate contract: FAIL: preflight receipt was not consumed" >&2; exit 1; } +[ -e "$PATCHNEST_RECOVERY_EXPORT_FILE" ] || { echo "FR-014 write gate contract: FAIL: recovery export evidence was consumed" >&2; exit 1; } [ "$(patchnest_json_string state "$PATCHNEST_PENDING_TRANSACTION_FILE")" = "written" ] || { echo "FR-014 write gate contract: FAIL: transaction did not reach written" >&2; exit 1; } -# 4. A candidate marker with missing gate helper must fail closed. Use a fresh -# subshell so transactional_flash.sh evaluates helper presence from scratch. +# 6. A candidate marker with missing gate helper must fail closed. ( MISS="$TMP/missing-helper" mkdir -p "$MISS/module/patch" "$MISS/state" "$MISS/backups" "$MISS/work" From f71629c085d66ba04d5197e0d2affb947d3c1b4e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:33:10 +0800 Subject: [PATCH 126/152] fix(kpm): materialize validated ZIP entries as regular files only --- module/install_kpm.sh | 74 +++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/module/install_kpm.sh b/module/install_kpm.sh index d25db57..a434aef 100644 --- a/module/install_kpm.sh +++ b/module/install_kpm.sh @@ -42,14 +42,59 @@ validate_archive_entries() { _pn_list=$2 unzip -Z1 "$_pn_zip" > "$_pn_list" 2>/dev/null || return 1 [ -s "$_pn_list" ] || return 1 + + # Duplicate names create parser/extractor ambiguity; reject them before any + # materialization. Entry names are deliberately conservative because unzip + # treats wildcard characters as patterns even when the shell quoted them. + if LC_ALL=C sort "$_pn_list" | uniq -d | grep -q .; then + printf '%s\n' "! Duplicate ZIP entries are not allowed" >&2 + return 1 + fi + while IFS= read -r _pn_entry || [ -n "$_pn_entry" ]; do [ -n "$_pn_entry" ] || return 1 case "$_pn_entry" in - /*|\\*|[A-Za-z]:*|../*|*/../*|*/..|..|./*|*\\*) + /*|\\*|[A-Za-z]:*|../*|*/../*|*/..|..|./*|*\\*|*\**|*\?*|*\[*|*\]*) printf '%s\n' "! Unsafe ZIP entry: $_pn_entry" >&2 return 1 ;; esac + # Keep archive paths predictable and reject control/unusual characters + # that could make line-based ZIP listing ambiguous. + if ! printf '%s\n' "$_pn_entry" | LC_ALL=C grep -Eq '^[A-Za-z0-9._/@%+=, -]+/?$'; then + printf '%s\n' "! Unsupported ZIP entry name: $_pn_entry" >&2 + return 1 + fi + done < "$_pn_list" + return 0 +} + +materialize_archive_regular_files() { + _pn_zip=$1 + _pn_list=$2 + _pn_root=$3 + ensure_real_dir "$_pn_root" || return 1 + chmod 0700 "$_pn_root" 2>/dev/null || true + umask 077 + + while IFS= read -r _pn_entry || [ -n "$_pn_entry" ]; do + case "$_pn_entry" in + */) + _pn_dir="$_pn_root/${_pn_entry%/}" + ensure_real_dir "$_pn_dir" || return 1 + ;; + *) + _pn_dest="$_pn_root/$_pn_entry" + _pn_parent=${_pn_dest%/*} + [ "$_pn_parent" = "$_pn_dest" ] && _pn_parent=$_pn_root + ensure_real_dir "$_pn_parent" || return 1 + [ ! -e "$_pn_dest" ] || return 1 + # -p emits entry bytes to stdout; archive mode/type metadata is + # never allowed to create symlinks, devices, or hard links. + unzip -p "$_pn_zip" "$_pn_entry" > "$_pn_dest" 2>/dev/null || return 1 + [ -f "$_pn_dest" ] && [ ! -L "$_pn_dest" ] || return 1 + ;; + esac done < "$_pn_list" return 0 } @@ -61,7 +106,6 @@ validate_kpm_binary() { [ -x "$MODDIR/bin/kptools" ] || return 1 _pn_hdr=$(xxd -p -l 20 "$_pn_file" 2>/dev/null | tr -d '\r\n') - # ELF64, little-endian, AArch64 (e_machine=0x00b7 at offset 18). [ "$(printf '%s' "$_pn_hdr" | cut -c1-12)" = "7f454c460201" ] || return 1 [ "$(printf '%s' "$_pn_hdr" | cut -c37-40)" = "b700" ] || return 1 @@ -74,9 +118,6 @@ validate_kpm_binary() { [ -n "$ZIP_FILE" ] || fail "Usage: install_kpm.sh " 2 [ -f "$ZIP_FILE" ] && [ ! -L "$ZIP_FILE" ] || fail "KPM ZIP is missing, not regular, or is a symlink: $ZIP_FILE" 2 -# FR-014 must remain a clean boot lifecycle test. Diagnostic KPM testing uses -# device_validation.sh kpm-cycle with its own explicit unlock instead of the -# normal persistent installer/autoload path. if [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ]; then fail "Persistent KPM installation is disabled on the FR-014 device candidate" 3 fi @@ -94,14 +135,9 @@ chmod 0700 "$TMPDIR" 2>/dev/null || true ENTRY_LIST="$TMPDIR/archive.entries" validate_archive_entries "$ZIP_FILE" "$ENTRY_LIST" || fail "KPM ZIP contains unsafe/invalid archive entries" - -printf '%s\n' "- Extracting $ZIP_FILE..." -unzip -qq -o "$ZIP_FILE" -d "$TMPDIR/extracted" || fail "Failed to extract KPM ZIP" EXTRACTED="$TMPDIR/extracted" -[ -d "$EXTRACTED" ] || fail "KPM extraction produced no directory" -if find "$EXTRACTED" -type l -print -quit 2>/dev/null | grep -q .; then - fail "KPM ZIP contains symlink entries" -fi +printf '%s\n' "- Materializing validated archive entries..." +materialize_archive_regular_files "$ZIP_FILE" "$ENTRY_LIST" "$EXTRACTED" || fail "Safe KPM ZIP materialization failed" PROP="$EXTRACTED/module.prop" [ -f "$PROP" ] && [ ! -L "$PROP" ] || fail "KPM ZIP has no safe root module.prop" @@ -133,8 +169,6 @@ esac [ -n "$MOD_ID" ] && [ "${#MOD_ID}" -le 64 ] && [ "$MOD_ID" != . ] && [ "$MOD_ID" != .. ] \ || fail "Unsafe or empty KPM id: '$MOD_ID'" 2 -# Exactly one binary module OR one-or-more C sources. Mixed packages and -# multi-binary ambiguity are rejected rather than choosing an arbitrary file. BINARY_LIST="$TMPDIR/binaries.list" SOURCE_LIST="$TMPDIR/sources.list" find "$EXTRACTED" -type f \( -name '*.kpm' -o -name '*.ko' -o -name '*.o' \) \ @@ -173,15 +207,15 @@ cp "$STAGED_KPM" "$DEST_TMP" || fail "Cannot stage KPM in persistent directory" chmod 0600 "$DEST_TMP" 2>/dev/null || true mv -f "$DEST_TMP" "$DEST_KPM" || fail "Cannot atomically commit KPM" -# Never retain a signature from an older binary revision. rm -f "$KPM_DIR/${MOD_ID}.kpm.sig" -KPM_BASENAME=$(basename "$(sed -n '1p' "$BINARY_LIST" 2>/dev/null || true)") +KPM_ORIGINAL=$(sed -n '1p' "$BINARY_LIST" 2>/dev/null || true) +KPM_BASENAME='' +[ -z "$KPM_ORIGINAL" ] || KPM_BASENAME=$(basename "$KPM_ORIGINAL") if [ -n "$KPM_BASENAME" ]; then _kpm_stem=$(printf '%s' "$KPM_BASENAME" | sed -E 's/\.(kpm|ko|o)$//') - for _sig in "$EXTRACTED/${_kpm_stem}.kpm.sig" \ - "$EXTRACTED/${_kpm_stem}.sig" \ - "$EXTRACTED/$(basename "$KPM_BASENAME" .kpm).kpm.sig" \ - "$EXTRACTED/$(basename "$KPM_BASENAME" .kpm).sig"; do + _kpm_parent=$(dirname "$KPM_ORIGINAL") + for _sig in "$_kpm_parent/${_kpm_stem}.kpm.sig" \ + "$_kpm_parent/${_kpm_stem}.sig"; do if [ -f "$_sig" ] && [ ! -L "$_sig" ]; then cp "$_sig" "$KPM_DIR/${MOD_ID}.kpm.sig" || fail "Cannot install KPM signature" chmod 0600 "$KPM_DIR/${MOD_ID}.kpm.sig" 2>/dev/null || true From 78193651c114f728d0d00024a8ebe3ae6361af6c Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:33:28 +0800 Subject: [PATCH 127/152] test(validation): execute FR-014 prepatch idle boot state --- tests/candidate_prepatch_idle_contract.sh | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/candidate_prepatch_idle_contract.sh diff --git a/tests/candidate_prepatch_idle_contract.sh b/tests/candidate_prepatch_idle_contract.sh new file mode 100644 index 0000000..adeec85 --- /dev/null +++ b/tests/candidate_prepatch_idle_contract.sh @@ -0,0 +1,74 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +fail() { + echo "candidate prepatch idle contract: FAIL: $*" >&2 + exit 1 +} + +# ---- post-fs-data: stock prepatch candidate boots never count as failures ---- +POSTMOD="$TMP/postmod" +POSTSTATE="$TMP/poststate" +SERVICED="$TMP/service.d" +mkdir -p "$POSTMOD" "$POSTSTATE" "$SERVICED" +printf '%s\n' '#!/bin/sh' 'exit 0' > "$POSTMOD/status.sh" +chmod 0755 "$POSTMOD/status.sh" +printf '%s\n' candidate > "$POSTMOD/FR014_DEVICE_CANDIDATE" +sed \ + -e "s#SERVICE_D=\"/data/adb/service.d\"#SERVICE_D=\"$SERVICED\"#" \ + -e "s#PNDIR=\"/data/adb/patchnest\"#PNDIR=\"$POSTSTATE\"#" \ + "$ROOT/module/post-fs-data.sh" > "$POSTMOD/post-fs-data.sh" +chmod 0755 "$POSTMOD/post-fs-data.sh" + +for _pn_i in 1 2 3 4; do + sh "$POSTMOD/post-fs-data.sh" + [ "$(cat "$POSTSTATE/boot_count")" = "0" ] || fail "prepatch candidate boot incremented failure counter" + [ ! -e "$POSTSTATE/auto_unpatch_requested" ] || fail "prepatch candidate armed auto rollback" + [ ! -e "$POSTSTATE/autorecovery_active" ] || fail "prepatch candidate surfaced autorecovery marker" +done + +# Durable patch evidence immediately re-enables the bootloop counter. +printf '%s\n' '{}' > "$POSTSTATE/last_flash.json" +sh "$POSTMOD/post-fs-data.sh" +[ "$(cat "$POSTSTATE/boot_count")" = "1" ] || fail "patched-evidence boot did not increment counter" + +# ---- service: prepatch idle must not even probe kernel ABI ------------------ +MOD="$TMP/service-module" +STATE="$TMP/service-state" +mkdir -p "$MOD/bin" "$MOD/patch" "$STATE" +printf '%s\n' candidate > "$MOD/FR014_DEVICE_CANDIDATE" +cat > "$MOD/bin/kpatch" <> "$TMP/kpatch.calls" +exit 1 +EOF +chmod 0755 "$MOD/bin/kpatch" +cat > "$MOD/bin/sleep" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod 0755 "$MOD/bin/sleep" +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\"#" "$ROOT/module/service.sh" > "$MOD/service.sh" +chmod 0755 "$MOD/service.sh" + +PATH="$MOD/bin:$PATH" sh "$MOD/service.sh" +[ ! -e "$TMP/kpatch.calls" ] || fail "prepatch idle service probed kpatch ABI" +[ ! -e "$MOD/unresolved" ] || fail "prepatch idle service marked module unresolved" +grep -Fq 'FR-014 candidate pre-patch idle' "$STATE/service.log" || fail "prepatch idle state was not logged" +[ "$(cat "$STATE/boot_count")" = "0" ] || fail "service did not keep prepatch counter at zero" + +# Once durable patch evidence exists, hello failure is a real unresolved state. +printf '%s\n' '{}' > "$STATE/last_flash.json" +rm -f "$TMP/kpatch.calls" "$MOD/unresolved" +PATH="$MOD/bin:$PATH" sh "$MOD/service.sh" +[ -s "$TMP/kpatch.calls" ] || fail "patched-evidence service skipped ABI probe" +[ -e "$MOD/unresolved" ] || fail "patched-evidence hello failure was not marked unresolved" + +echo "candidate prepatch idle contract: PASS" From d088817d34201b1b59cfa1692725dfe800c7dbb8 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:34:09 +0800 Subject: [PATCH 128/152] test(kpm): execute safe archive and ARM64 admission contract --- tests/kpm_install_security_contract.sh | 159 +++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 tests/kpm_install_security_contract.sh diff --git a/tests/kpm_install_security_contract.sh b/tests/kpm_install_security_contract.sh new file mode 100644 index 0000000..e40dac9 --- /dev/null +++ b/tests/kpm_install_security_contract.sh @@ -0,0 +1,159 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +mkdir -p /data/local/tmp + +fail() { + echo "KPM install security contract: FAIL: $*" >&2 + exit 1 +} +command -v zip >/dev/null 2>&1 || fail "zip missing" +command -v unzip >/dev/null 2>&1 || fail "unzip missing" +command -v xxd >/dev/null 2>&1 || fail "xxd missing" +command -v python3 >/dev/null 2>&1 || fail "python3 missing" + +MOD="$TMP/module" +STATE="$TMP/state" +mkdir -p "$MOD/bin" "$STATE" +sed "s#PNDIR=\"/data/adb/patchnest\"#PNDIR=\"$STATE\"#" \ + "$ROOT/module/install_kpm.sh" > "$MOD/install_kpm.sh" +chmod 0755 "$MOD/install_kpm.sh" + +cat > "$MOD/bin/kptools" <<'EOF' +#!/bin/sh +printf '%s\n' '[kpm]' 'name=contract_module' 'version=1.0.0' +exit 0 +EOF +chmod 0755 "$MOD/bin/kptools" +cat > "$MOD/bin/kpatch" <> "$TMP/kpatch.calls" +exit 0 +EOF +chmod 0755 "$MOD/bin/kpatch" + +make_elf() { + _pn_path=$1 + _pn_machine=$2 + python3 - "$_pn_path" "$_pn_machine" <<'PY' +import sys +p = sys.argv[1] +machine = int(sys.argv[2], 0) +b = bytearray(64) +b[0:4] = b'\x7fELF' +b[4] = 2 +b[5] = 1 +b[6] = 1 +b[16:18] = (1).to_bytes(2, 'little') +b[18:20] = machine.to_bytes(2, 'little') +with open(p, 'wb') as f: + f.write(b) +PY +} + +make_zip() { + _pn_dir=$1 + _pn_zip=$2 + (cd "$_pn_dir" && zip -q "$_pn_zip" module.prop module.kpm) +} + +# 1. Valid ARM64 package installs atomically and autoLoad=true creates marker. +SAFE="$TMP/safe" +mkdir -p "$SAFE" +cat > "$SAFE/module.prop" <<'EOF' +id=safe_mod +name=Safe Module +version=1.0.0 +autoLoad=true +args=mode=test +EOF +make_elf "$SAFE/module.kpm" 0xb7 +make_zip "$SAFE" "$TMP/safe.zip" +sh "$MOD/install_kpm.sh" "$TMP/safe.zip" >/dev/null +[ -f "$STATE/kpm/safe_mod.kpm" ] || fail "valid ARM64 KPM not installed" +[ -f "$STATE/kpm_events/safe_mod.autoload" ] || fail "autoLoad=true marker missing" +grep -Fq 'kpm load' "$TMP/kpatch.calls" || fail "autoLoad=true did not attempt immediate load" + +# 2. autoLoad=false must not create marker or invoke immediate kernel load. +DISABLED="$TMP/disabled" +mkdir -p "$DISABLED" +cat > "$DISABLED/module.prop" <<'EOF' +id=disabled_mod +name=Disabled Module +version=1.0.0 +autoLoad=false +EOF +make_elf "$DISABLED/module.kpm" 0xb7 +make_zip "$DISABLED" "$TMP/disabled.zip" +: > "$TMP/kpatch.calls" +sh "$MOD/install_kpm.sh" "$TMP/disabled.zip" >/dev/null +[ -f "$STATE/kpm/disabled_mod.kpm" ] || fail "autoload-disabled KPM not installed" +[ ! -e "$STATE/kpm_events/disabled_mod.autoload" ] || fail "autoLoad=false still created marker" +[ ! -s "$TMP/kpatch.calls" ] || fail "autoLoad=false still invoked kernel load" + +# 3. Non-AArch64 ELF is rejected before persistent module installation. +BADARCH="$TMP/badarch" +mkdir -p "$BADARCH" +cat > "$BADARCH/module.prop" <<'EOF' +id=badarch +name=Bad Arch +version=1.0.0 +autoLoad=true +EOF +make_elf "$BADARCH/module.kpm" 0x3e +make_zip "$BADARCH" "$TMP/badarch.zip" +set +e +sh "$MOD/install_kpm.sh" "$TMP/badarch.zip" >/dev/null 2>&1 +badarch_rc=$? +set -e +[ "$badarch_rc" -ne 0 ] || fail "non-AArch64 KPM was accepted" +[ ! -e "$STATE/kpm/badarch.kpm" ] || fail "non-AArch64 KPM reached persistent state" + +# 4. Traversal entry is rejected before archive bytes are materialized. +python3 - "$TMP/traversal.zip" <<'PY' +import zipfile, sys +p = sys.argv[1] +with zipfile.ZipFile(p, 'w') as z: + z.writestr('module.prop', 'id=traversal\nname=Traversal\nversion=1\nautoLoad=true\n') + z.writestr('module.kpm', b'not-important') + z.writestr('../../escape.txt', b'escape') +PY +set +e +sh "$MOD/install_kpm.sh" "$TMP/traversal.zip" >/dev/null 2>&1 +traversal_rc=$? +set -e +[ "$traversal_rc" -ne 0 ] || fail "traversal archive was accepted" +[ ! -e "$TMP/escape.txt" ] || fail "traversal archive wrote outside workspace" +[ ! -e "$STATE/kpm/traversal.kpm" ] || fail "traversal package reached persistent state" + +# 5. Ambiguous multiple-binary package is rejected instead of arbitrary choice. +MULTI="$TMP/multi" +mkdir -p "$MULTI" +cat > "$MULTI/module.prop" <<'EOF' +id=multi +name=Multi +version=1 +autoLoad=true +EOF +make_elf "$MULTI/a.kpm" 0xb7 +make_elf "$MULTI/b.kpm" 0xb7 +(cd "$MULTI" && zip -q "$TMP/multi.zip" module.prop a.kpm b.kpm) +set +e +sh "$MOD/install_kpm.sh" "$TMP/multi.zip" >/dev/null 2>&1 +multi_rc=$? +set -e +[ "$multi_rc" -ne 0 ] || fail "multi-binary package was accepted" +[ ! -e "$STATE/kpm/multi.kpm" ] || fail "multi-binary package reached persistent state" + +# 6. Physical FR-014 candidate refuses persistent KPM install entirely. +printf '%s\n' candidate > "$MOD/FR014_DEVICE_CANDIDATE" +set +e +sh "$MOD/install_kpm.sh" "$TMP/safe.zip" >/dev/null 2>&1 +candidate_rc=$? +set -e +[ "$candidate_rc" -eq 3 ] || fail "FR-014 candidate did not reject persistent KPM install with rc=3" + +echo "KPM install security contract: PASS" From b0a76dec8228dac63e0a453fa05278d1dc0d914c Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:34:28 +0800 Subject: [PATCH 129/152] test(kpm): execute service autoload and runtime binary admission --- tests/kpm_runtime_admission_contract.sh | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/kpm_runtime_admission_contract.sh diff --git a/tests/kpm_runtime_admission_contract.sh b/tests/kpm_runtime_admission_contract.sh new file mode 100644 index 0000000..a61d18e --- /dev/null +++ b/tests/kpm_runtime_admission_contract.sh @@ -0,0 +1,95 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM + +fail() { + echo "KPM runtime admission contract: FAIL: $*" >&2 + exit 1 +} +command -v xxd >/dev/null 2>&1 || fail "xxd missing" +command -v python3 >/dev/null 2>&1 || fail "python3 missing" + +MOD="$TMP/module" +STATE="$TMP/state" +mkdir -p "$MOD/bin" "$MOD/patch" "$STATE/kpm/failed" "$STATE/kpm_events" + +# Signature policy off isolates this contract to autoload/binary admission. +printf '%s\n' 'KPM_SIGNATURE_POLICY=off' > "$STATE/config" + +cat > "$MOD/bin/kpatch" <> "$TMP/kpatch.calls"; exit 0 ;; +esac +EOF +chmod 0755 "$MOD/bin/kpatch" +cat > "$MOD/bin/kptools" <<'EOF' +#!/bin/sh +printf '%s\n' '[kpm]' 'name=runtime_contract' +exit 0 +EOF +chmod 0755 "$MOD/bin/kptools" +cat > "$MOD/bin/getprop" <<'EOF' +#!/bin/sh +case "${1:-}" in + sys.boot_completed) printf '%s\n' 1 ;; + *) printf '%s\n' '' ;; +esac +EOF +chmod 0755 "$MOD/bin/getprop" +cat > "$MOD/bin/sleep" <<'EOF' +#!/bin/sh +exit 0 +EOF +chmod 0755 "$MOD/bin/sleep" + +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" + +make_elf() { + _pn_path=$1 + _pn_machine=$2 + python3 - "$_pn_path" "$_pn_machine" <<'PY' +import sys +p=sys.argv[1]; machine=int(sys.argv[2],0) +b=bytearray(64) +b[0:4]=b'\x7fELF'; b[4]=2; b[5]=1; b[6]=1 +b[16:18]=(1).to_bytes(2,'little'); b[18:20]=machine.to_bytes(2,'little') +open(p,'wb').write(b) +PY +} + +make_elf "$STATE/kpm/enabled.kpm" 0xb7 +make_elf "$STATE/kpm/disabled.kpm" 0xb7 +make_elf "$STATE/kpm/badarch.kpm" 0x3e +touch "$STATE/kpm_events/enabled.autoload" +touch "$STATE/kpm_events/badarch.autoload" + +sed "s#PNDIR=\"/data/adb/patchnest\"#PNDIR=\"$STATE\"#" "$ROOT/module/service.sh" > "$MOD/service.sh" +chmod 0755 "$MOD/service.sh" + +PATH="$MOD/bin:$PATH" sh "$MOD/service.sh" + +# enabled is the only valid explicitly-autoloaded module. +grep -Fq "kpm load $STATE/kpm/enabled.kpm" "$TMP/kpatch.calls" \ + || fail "valid autoload-enabled KPM was not loaded" +if grep -Fq "kpm load $STATE/kpm/disabled.kpm" "$TMP/kpatch.calls"; then + fail "autoload-disabled KPM was loaded" +fi +if grep -Fq "kpm load $STATE/kpm/badarch.kpm" "$TMP/kpatch.calls"; then + fail "non-AArch64 KPM reached kpatch load" +fi +[ -f "$STATE/kpm/disabled.kpm" ] || fail "autoload-disabled KPM was incorrectly removed" +[ -f "$STATE/kpm/failed/badarch.kpm" ] || fail "invalid KPM was not quarantined" +[ ! -e "$STATE/kpm_events/badarch.autoload" ] || fail "invalid KPM autoload marker survived quarantine" +grep -Fq 'KPM autoload disabled or unregistered: disabled.kpm' "$STATE/service.log" \ + || fail "autoload-disabled decision not logged" +grep -Fq 'REJECTED (invalid/non-AArch64 KPM): badarch.kpm' "$STATE/service.log" \ + || fail "runtime binary rejection not logged" + +echo "KPM runtime admission contract: PASS" From 7224202c442dab47d0ada5023dcccdb93b53d460 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:35:06 +0800 Subject: [PATCH 130/152] ci(safety): cover candidate idle and KPM admission dynamically --- .github/workflows/flash-safety.yml | 49 ++++++++++++++++++------------ 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index b260aed..70380ea 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -7,14 +7,8 @@ on: pull_request: branches: [main] paths: + - 'module/*.sh' - 'module/patch/**' - - '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' - 'version.properties' @@ -23,25 +17,23 @@ on: - 'tests/transaction_backup_identity_contract.sh' - 'tests/bootloop_recovery_contract.sh' - 'tests/boot_resolution_failure_contract.sh' + - 'tests/candidate_prepatch_idle_contract.sh' - 'tests/fr014_preflight_contract.sh' - 'tests/fr014_write_gate_contract.sh' + - 'tests/kpm_install_security_contract.sh' + - 'tests/kpm_runtime_admission_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' + - 'tests/validate_flash_package.js' - '.github/workflows/flash-safety.yml' push: branches: - 'review/flash-readiness-hardening' - 'review/flash-readiness-final' paths: + - 'module/*.sh' - 'module/patch/**' - - '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' - 'version.properties' @@ -50,11 +42,15 @@ on: - 'tests/transaction_backup_identity_contract.sh' - 'tests/bootloop_recovery_contract.sh' - 'tests/boot_resolution_failure_contract.sh' + - 'tests/candidate_prepatch_idle_contract.sh' - 'tests/fr014_preflight_contract.sh' - 'tests/fr014_write_gate_contract.sh' + - 'tests/kpm_install_security_contract.sh' + - 'tests/kpm_runtime_admission_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' + - 'tests/validate_flash_package.js' - '.github/workflows/flash-safety.yml' workflow_dispatch: @@ -66,17 +62,18 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Install shell validation tools + - name: Install validation tools run: | sudo apt-get update - sudo apt-get install -y shellcheck zip unzip + sudo apt-get install -y shellcheck zip unzip xxd - - name: Shell syntax + - name: Shell and validator 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 module/export_recovery_boot.sh scripts/device_validation.sh scripts/package_module.sh tests/flash_safety_contract.sh tests/destructive_transaction_contract.sh tests/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/installer_contract.sh tests/recovery_export_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/install_kpm.sh module/uninstall.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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/candidate_prepatch_idle_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/kpm_install_security_contract.sh tests/kpm_runtime_admission_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done + node --check tests/validate_flash_package.js - name: ShellCheck reviewed flash/runtime path run: | @@ -93,6 +90,8 @@ jobs: module/service.sh \ module/post-fs-data.sh \ module/customize.sh \ + module/install_kpm.sh \ + module/uninstall.sh \ module/device_validation.sh \ module/arm_auto_recovery.sh \ module/verify_auto_recovery.sh \ @@ -104,8 +103,11 @@ jobs: tests/transaction_backup_identity_contract.sh \ tests/bootloop_recovery_contract.sh \ tests/boot_resolution_failure_contract.sh \ + tests/candidate_prepatch_idle_contract.sh \ tests/fr014_preflight_contract.sh \ tests/fr014_write_gate_contract.sh \ + tests/kpm_install_security_contract.sh \ + tests/kpm_runtime_admission_contract.sh \ tests/installer_contract.sh \ tests/recovery_export_contract.sh \ tests/runtime_abi_contract.sh @@ -129,12 +131,21 @@ jobs: - name: Run bootloop automatic recovery contract run: sh tests/bootloop_recovery_contract.sh + - name: Run FR-014 prepatch idle state contract + run: sh tests/candidate_prepatch_idle_contract.sh + - name: Run clean FR-014 preflight contract run: sudo sh tests/fr014_preflight_contract.sh - - name: Enforce one-time FR-014 destructive-write authorization + - name: Enforce FR-014 preflight + recovery-export write authorization run: sh tests/fr014_write_gate_contract.sh + - name: Run KPM ZIP admission contract + run: sudo sh tests/kpm_install_security_contract.sh + + - name: Run KPM runtime admission contract + run: sh tests/kpm_runtime_admission_contract.sh + - name: Run cross-manager installer contract run: sh tests/installer_contract.sh From dc467e6d740cc4ee3ee8d26a39394725152091d1 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:36:27 +0800 Subject: [PATCH 131/152] fix(kpm): verify Ed25519 signatures with SPKI DER and pkeyutl --- module/kpm_verify.sh | 325 +++++++++++++------------------------------ 1 file changed, 98 insertions(+), 227 deletions(-) diff --git a/module/kpm_verify.sh b/module/kpm_verify.sh index af6f121..da90e27 100644 --- a/module/kpm_verify.sh +++ b/module/kpm_verify.sh @@ -1,263 +1,134 @@ #!/bin/sh -# kpm_verify.sh — Ed25519 signature verification for KPM modules. -# -# This file is intended to be sourced from service.sh (and any other -# caller that wants to verify a .kpm before loading it). It exposes -# one public function: -# -# verify_kpm_sig -# -# Behavior: -# * Returns 0 (success) when the signature is valid. -# * Returns 1 (failure) when verification fails for any reason -# (missing sig file, bad sig format, signature mismatch, openssl -# not present, etc.). The caller SHOULD log and skip loading the -# module in that case. -# -# .kpm.sig file format (MVP): -# The first non-empty line of the file is the 64-byte Ed25519 -# signature encoded as 128 lowercase hex characters, with no -# whitespace. Any other lines are ignored. This keeps the file -# grep-friendly and trivially parseable on Android shell. -# -# Public key: -# KPM_SIGN_PUBKEY_HEX is a hardcoded 32-byte Ed25519 public key -# encoded as 64 lowercase hex characters. -# The current value is a DEV/TEST key. TODO(security): replace with -# a release key managed out-of-band, and consider loading the key -# from /data/adb/patchnest/kpm_sign_pubkey instead of hardcoding it. -# -# Tooling: -# We use `openssl dgst -ed25519 -verify` (OpenSSL >= 1.1.1). This is -# available in modern Android (toybox/busybox builds frequently ship -# it; on stock AOSP it is in /system/bin/openssl). No Android -# SDK/NDK dependency is introduced. -# -# We intentionally avoid the BSD `signify` and any prebuilt NDK -# crypto blob. If openssl is missing, verify_kpm_sig returns 1 and -# the caller treats that as a verification failure (which is the -# safe default when enforcement is on). -# -# Backward compatibility: -# * When REQUIRE_KPM_SIGNATURES=0 (or unset), service.sh will NOT -# call this function at all. -# * When enforcement IS on, the caller passes the .kpm path and the -# expected .sig path. If the sig file does not exist, this -# function returns 1 and the caller skips the load. +# Ed25519 signature verification for KPM modules. +# Public API: verify_kpm_sig -# ---------- bundled public key ---------- -# Generated locally for the v0.3.0 ultracode audit (2026-06-06). -# Replaces the previous RFC 8032 Test 1 key, which was the well-known -# "everyone has the private key" value — meaning anyone could forge -# a .kpm.sig that this verifier would accept. -# -# This key is the corresponding PUBLIC half of an Ed25519 pair -# generated by the maintainer on 2026-06-06. The matching private -# key is held out-of-band (NOT in this repo); use it via -# openssl pkeyutl -sign -inkey -rawin -in \ -# -out .sig -# to produce .kpm.sig files for distribution. -# -# The test vector in kpm_verify__require_openssl() below uses this -# same public key with a probe input ("probe") so that openssl -# compatibility is checked against the actual deployment key. KPM_SIGN_PUBKEY_HEX="a6cee3371d164daf9ad2ed38ecaf1d492e7867fc6df31f810e69eaa0dd45259b" +# Signature over the five bytes "probe" with the deployment key above. This is +# a public verification vector only; the private key is not present here. +KPM_VERIFY_PROBE_SIG_HEX="886199b494a8dcb9ddec3a48385f4ea7e3cbefcc90198c6c807fd2434125b20e32f1b8cdd817e782f7fcf80860c4a32f7c49006089a4efefb4b734bbcb30f703" -# ---------- internal helpers ---------- - -# kpm_verify__log -# Append a timestamped line to the service log when one is writable. -# Service.sh sets $PNDIR and $LOG; we tolerate their absence so that -# this script is safe to source from any context. kpm_verify__log() { - if [ -n "$LOG" ] && [ -n "$PNDIR" ] && [ -d "$PNDIR" ]; then - printf '[%s] kpm_verify: %s\n' "$(date)" "$1" >> "$LOG" 2>/dev/null + if [ -n "${LOG:-}" ] && [ -n "${PNDIR:-}" ] && [ -d "$PNDIR" ]; then + printf '[%s] kpm_verify: %s\n' "$(date)" "$1" >> "$LOG" 2>/dev/null || true fi } -# kpm_verify__hex_to_bin -# Write the binary representation of to . -# Returns 0 on success, 1 if the input is not valid hex of even length. -# This is intentionally written in pure POSIX shell (no awk ord(), -# no xxd, no perl) so it works on stock Android mksh/toybox/ash. -kpm_verify__hex_to_bin() { - local _hex=$1 - local _out=$2 - local _i _fmt _expected_sz _actual_sz - # Reject any non-hex char early. - case "$_hex" in - *[!0-9a-fA-F]*) return 1 ;; - esac - if [ $(( ${#_hex} % 2 )) -ne 0 ]; then - return 1 - fi - # printf "%b" on mksh interprets \xHH correctly. We build the - # argument by joining "\xHH" sequences; printf then emits the raw - # bytes in one shot. This avoids spawning a process per byte. - _i=1 - _fmt="" - while [ $_i -le ${#_hex} ]; do - _fmt="${_fmt}\\x$(printf '%s' "$_hex" | cut -c${_i}-$((_i+1)))" - _i=$((_i + 2)) - done - if ! printf '%b' "$_fmt" > "$_out" 2>/dev/null; then - rm -f "$_out" 2>/dev/null - return 1 - fi - # Verify output size matches expected binary length. - _expected_sz=$(( ${#_hex} / 2 )) - _actual_sz=$(wc -c < "$_out" 2>/dev/null) - if [ "$_actual_sz" != "$_expected_sz" ]; then - rm -f "$_out" 2>/dev/null - return 1 - fi +kpm_verify__hex_valid() { + _kv_hex=$1 + _kv_len=$2 + [ "${#_kv_hex}" -eq "$_kv_len" ] || return 1 + case "$_kv_hex" in *[!0-9a-fA-F]*) return 1 ;; esac return 0 } -# kpm_verify__require_openssl -# Returns 0 if `openssl dgst -ed25519 -verify` is usable, 1 otherwise. -# -# We probe by running a self-signed Ed25519 test vector with the -# *deployment* public key. If openssl on the device supports Ed25519, -# the verify call returns 0; if not, the call fails with -# "digital envelope routine:unsupported" or similar. Using the -# deployment key (not a well-known test vector) ensures the probe -# really exercises the same key path that production verification -# will use, and not some quirk that just happens to accept the -# RFC 8032 Test 1 vector. -# -# Test vector: (priv=, -# pub=a6cee3371d164daf9ad2ed38ecaf1d492e7867fc6df31f810e69eaa0dd45259b, -# msg="probe" (5 bytes), -# sig=886199b494a8dcb9ddec3a48385f4ea7e3cbefcc90198c6c807fd2434125b20e32f1b8cdd817e782f7fcf80860c4a32f7c49006089a4efefb4b734bbcb30f703). -# The probe writes a 5-byte file ("probe") into /data/local/tmp; we -# avoid mktemp to keep the script portable to minimal Android shells -# without /system/bin/mktemp. -kpm_verify__require_openssl() { - local _probekey _probesig _probeinput _probekeyfile _probesigfile - if ! command -v openssl >/dev/null 2>&1; then +kpm_verify__make_tmpdir() { + umask 077 + command -v mktemp >/dev/null 2>&1 || return 1 + _kv_tmp=$(mktemp -d /data/local/tmp/kpm_verify.XXXXXX 2>/dev/null) || \ + _kv_tmp=$(mktemp -d /tmp/kpm_verify.XXXXXX 2>/dev/null) || return 1 + [ -d "$_kv_tmp" ] && [ ! -L "$_kv_tmp" ] || { + rm -rf "$_kv_tmp" 2>/dev/null || true return 1 - fi - _probekey="a6cee3371d164daf9ad2ed38ecaf1d492e7867fc6df31f810e69eaa0dd45259b" - _probesig="886199b494a8dcb9ddec3a48385f4ea7e3cbefcc90198c6c807fd2434125b20e32f1b8cdd817e782f7fcf80860c4a32f7c49006089a4efefb4b734bbcb30f703" - _probeinput=/data/local/tmp/.kpm_probe_input_$$.bin - _probekeyfile=/data/local/tmp/.kpm_probekey_$$.bin - _probesigfile=/data/local/tmp/.kpm_probesig_$$.bin - if ! printf '%s' "probe" > "$_probeinput" 2>/dev/null; then + } + printf '%s\n' "$_kv_tmp" +} + +# OpenSSL expects a public-key object, not the raw 32-byte Ed25519 key. Encode +# the raw key as SubjectPublicKeyInfo DER: +# SEQUENCE { SEQUENCE { OID 1.3.101.112 }, BIT STRING <32-byte key> } +kpm_verify__write_pubkey_der() { + _kv_out=$1 + kpm_verify__hex_valid "$KPM_SIGN_PUBKEY_HEX" 64 || return 1 + command -v xxd >/dev/null 2>&1 || return 1 + printf '%s' "302a300506032b6570032100${KPM_SIGN_PUBKEY_HEX}" \ + | xxd -r -p > "$_kv_out" 2>/dev/null || return 1 + [ "$(wc -c < "$_kv_out" 2>/dev/null)" -eq 44 ] || return 1 +} + +kpm_verify__write_sig_bin() { + _kv_hex=$1 + _kv_out=$2 + kpm_verify__hex_valid "$_kv_hex" 128 || return 1 + command -v xxd >/dev/null 2>&1 || return 1 + printf '%s' "$_kv_hex" | xxd -r -p > "$_kv_out" 2>/dev/null || return 1 + [ "$(wc -c < "$_kv_out" 2>/dev/null)" -eq 64 ] || return 1 +} + +kpm_verify__openssl_verify() { + _kv_input=$1 + _kv_sig_hex=$2 + command -v openssl >/dev/null 2>&1 || return 1 + command -v xxd >/dev/null 2>&1 || return 1 + + _kv_tmp=$(kpm_verify__make_tmpdir) || return 1 + _kv_pub="$_kv_tmp/pub.der" + _kv_sig="$_kv_tmp/sig.bin" + if ! kpm_verify__write_pubkey_der "$_kv_pub" || \ + ! kpm_verify__write_sig_bin "$_kv_sig_hex" "$_kv_sig"; then + rm -rf "$_kv_tmp" 2>/dev/null || true return 1 fi - if ! kpm_verify__hex_to_bin "$_probekey" "$_probekeyfile"; then - rm -f "$_probeinput" 2>/dev/null - return 1 + + if openssl pkeyutl -verify \ + -pubin -keyform DER -inkey "$_kv_pub" \ + -sigfile "$_kv_sig" -rawin -in "$_kv_input" \ + >/dev/null 2>&1; then + rm -rf "$_kv_tmp" 2>/dev/null || true + return 0 fi - if ! kpm_verify__hex_to_bin "$_probesig" "$_probesigfile"; then - rm -f "$_probeinput" "$_probekeyfile" 2>/dev/null + rm -rf "$_kv_tmp" 2>/dev/null || true + return 1 +} + +# Capability check uses the actual deployment public key and its probe +# signature, so a CLI that merely exposes pkeyutl but cannot verify Ed25519 does +# not pass the gate. +kpm_verify__require_openssl() { + _kv_tmp=$(kpm_verify__make_tmpdir) || return 1 + _kv_probe="$_kv_tmp/probe" + printf '%s' probe > "$_kv_probe" || { + rm -rf "$_kv_tmp" 2>/dev/null || true return 1 - fi - if openssl dgst -ed25519 -verify "$_probekeyfile" \ - -signature "$_probesigfile" "$_probeinput" \ - >/dev/null 2>&1; then - rm -f "$_probeinput" "$_probekeyfile" "$_probesigfile" 2>/dev/null + } + if kpm_verify__openssl_verify "$_kv_probe" "$KPM_VERIFY_PROBE_SIG_HEX"; then + rm -rf "$_kv_tmp" 2>/dev/null || true return 0 fi - rm -f "$_probeinput" "$_probekeyfile" "$_probesigfile" 2>/dev/null + rm -rf "$_kv_tmp" 2>/dev/null || true return 1 } -# ---------- public API ---------- - -# verify_kpm_sig -# Returns 0 if the signature is valid, 1 otherwise. verify_kpm_sig() { - local _kpm=$1 - local _sig=$2 - local _sig_hex _tmpdir _pub_bin _sig_bin _ok - - if [ -z "$_kpm" ] || [ -z "$_sig" ]; then + _kv_kpm=${1:-} + _kv_sigfile=${2:-} + [ -n "$_kv_kpm" ] && [ -n "$_kv_sigfile" ] || { kpm_verify__log "missing arguments" return 1 - fi - if [ ! -f "$_kpm" ]; then - kpm_verify__log "kpm file not found: $_kpm" + } + [ -f "$_kv_kpm" ] && [ ! -L "$_kv_kpm" ] || { + kpm_verify__log "KPM file missing/not regular: $_kv_kpm" return 1 - fi - if [ ! -f "$_sig" ]; then - kpm_verify__log "sig file not found: $_sig" + } + [ -f "$_kv_sigfile" ] && [ ! -L "$_kv_sigfile" ] || { + kpm_verify__log "signature file missing/not regular: $_kv_sigfile" return 1 - fi - - if ! kpm_verify__require_openssl; then - kpm_verify__log "openssl with ed25519 not available; failing closed" - return 1 - fi - - # Read the hex signature (first non-empty line) and validate. - _sig_hex=$(awk 'NF{print; exit}' "$_sig" 2>/dev/null | tr -d ' \t\r\n') - case ${#_sig_hex} in - 128) ;; - *) - kpm_verify__log "sig file is not 64 bytes (got ${#_sig_hex} hex chars)" - return 1 ;; - esac - case "$_sig_hex" in - *[!0-9a-fA-F]*) - kpm_verify__log "sig file contains non-hex characters" - return 1 - ;; - esac + } - # Decode the public key and signature into temp files. We use - # /data/local/tmp (world-writable on debug builds, but the files - # are short-lived and contain no secrets — just a public key and - # a signature). If that fails, fall back to /tmp. - _tmpdir="" - if command -v mktemp >/dev/null 2>&1; then - _tmpdir=$(mktemp -d /data/local/tmp/kpm_verify.XXXXXX 2>/dev/null) || \ - _tmpdir=$(mktemp -d /tmp/kpm_verify.XXXXXX 2>/dev/null) - fi - if [ -z "$_tmpdir" ] || [ ! -d "$_tmpdir" ]; then - _tmpdir=/data/local/tmp/kpm_verify.$$ - mkdir -p "$_tmpdir" 2>/dev/null - fi - # Defend against symlink attack: reject if _tmpdir is a symlink. - if [ -L "$_tmpdir" ]; then - kpm_verify__log "temp dir is a symlink; possible attack" - return 1 - fi - if [ ! -d "$_tmpdir" ]; then - kpm_verify__log "could not create temp dir" + _kv_sig_hex=$(awk 'NF{print; exit}' "$_kv_sigfile" 2>/dev/null | tr -d ' \t\r\n') + kpm_verify__hex_valid "$_kv_sig_hex" 128 || { + kpm_verify__log "signature is not exactly 64 bytes of hex" return 1 - fi - trap 'rm -rf "$_tmpdir" 2>/dev/null' EXIT INT TERM HUP - _pub_bin="$_tmpdir/pub.bin" - _sig_bin="$_tmpdir/sig.bin" + } - if ! kpm_verify__hex_to_bin "$KPM_SIGN_PUBKEY_HEX" "$_pub_bin"; then - kpm_verify__log "bundled public key is not valid hex" - rm -rf "$_tmpdir" 2>/dev/null - return 1 - fi - if ! kpm_verify__hex_to_bin "$_sig_hex" "$_sig_bin"; then - kpm_verify__log "sig hex decode failed" - rm -rf "$_tmpdir" 2>/dev/null + if ! kpm_verify__require_openssl; then + kpm_verify__log "OpenSSL Ed25519 pkeyutl verification unavailable; failing closed" return 1 fi - # Run the actual verification. openssl returns 0 on success, 1 on - # signature mismatch, and non-zero (often 1) on malformed input. - _ok=1 - if openssl dgst -ed25519 -verify "$_pub_bin" \ - -signature "$_sig_bin" "$_kpm" >/dev/null 2>&1; then - _ok=0 - fi - - rm -rf "$_tmpdir" 2>/dev/null - trap - EXIT INT TERM HUP - - if [ "$_ok" -eq 0 ]; then - kpm_verify__log "signature OK: $(basename "$_kpm")" + if kpm_verify__openssl_verify "$_kv_kpm" "$_kv_sig_hex"; then + kpm_verify__log "signature OK: $(basename "$_kv_kpm")" return 0 fi - kpm_verify__log "signature INVALID: $(basename "$_kpm")" + kpm_verify__log "signature INVALID: $(basename "$_kv_kpm")" return 1 } From ea7da4df6a70124eced5dd85b8f13217be062acd Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:36:43 +0800 Subject: [PATCH 132/152] test(kpm): execute deployment Ed25519 verification vector --- tests/kpm_signature_contract.sh | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/kpm_signature_contract.sh diff --git a/tests/kpm_signature_contract.sh b/tests/kpm_signature_contract.sh new file mode 100644 index 0000000..77cadea --- /dev/null +++ b/tests/kpm_signature_contract.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +mkdir -p /data/local/tmp + +fail() { + echo "KPM signature contract: FAIL: $*" >&2 + exit 1 +} +command -v openssl >/dev/null 2>&1 || fail "openssl missing" +command -v xxd >/dev/null 2>&1 || fail "xxd missing" + +PNDIR="$TMP/state" +LOG="$TMP/verify.log" +mkdir -p "$PNDIR" +export PNDIR LOG +# shellcheck disable=SC1090 +. "$ROOT/module/kpm_verify.sh" + +printf '%s' probe > "$TMP/probe.kpm" +printf '%s\n' "$KPM_VERIFY_PROBE_SIG_HEX" > "$TMP/probe.sig" + +kpm_verify__require_openssl || fail "deployment Ed25519 probe vector did not verify" +verify_kpm_sig "$TMP/probe.kpm" "$TMP/probe.sig" || fail "valid deployment signature rejected" + +printf '%s' 'probe!' > "$TMP/tampered.kpm" +if verify_kpm_sig "$TMP/tampered.kpm" "$TMP/probe.sig"; then + fail "tampered payload accepted with valid signature" +fi + +printf '%s\n' '00' > "$TMP/malformed.sig" +if verify_kpm_sig "$TMP/probe.kpm" "$TMP/malformed.sig"; then + fail "malformed signature accepted" +fi + +ln -s "$TMP/probe.sig" "$TMP/link.sig" +if verify_kpm_sig "$TMP/probe.kpm" "$TMP/link.sig"; then + fail "symlink signature file accepted" +fi + +grep -Fq 'signature OK' "$LOG" || fail "success was not audited" +grep -Fq 'signature INVALID' "$LOG" || fail "tamper rejection was not audited" + +echo "KPM signature contract: PASS" From df99b1f81e4684716ee02bd433a6f81a7fd587a3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:37:18 +0800 Subject: [PATCH 133/152] test(package): require hardened KPM runtime components --- tests/validate_flash_package.js | 34 ++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/validate_flash_package.js b/tests/validate_flash_package.js index 1046ea9..478d9f8 100644 --- a/tests/validate_flash_package.js +++ b/tests/validate_flash_package.js @@ -44,6 +44,9 @@ for (const [rel, minSize] of [ ['service.sh', 1], ['post-fs-data.sh', 1], ['uninstall.sh', 1], + ['install_kpm.sh', 1], + ['compile_kpm.sh', 1], + ['kpm_verify.sh', 1], ['device_validation.sh', 1], ['arm_auto_recovery.sh', 1], ['verify_auto_recovery.sh', 1], @@ -59,8 +62,6 @@ for (const [rel, minSize] of [ ['patch/superkey_safety.sh', 1], ]) requireFile(rel, minSize); -// The review branch must be non-installable. A dedicated candidate is allowed -// to replace this with FR014_DEVICE_CANDIDATE, but never to omit both markers. const blocker = path.join(MOD, 'FLASH_REVIEW_BLOCKED'); const candidate = path.join(MOD, 'FR014_DEVICE_CANDIDATE'); if (!fs.existsSync(blocker) && !fs.existsSync(candidate)) { @@ -101,11 +102,13 @@ for (const required of [ 'boot_target_sha256', 'device_binding_sha256', 'candidate_marker_sha256', + 'PATCHNEST_RECOVERY_EXPORT_FILE', + 'patchnest_fr014_recovery_export_matches', 'patchnest_clear_fr014_preflight_receipt', ]) { if (!gate.includes(required)) fail(`FR-014 gate missing binding/control: ${required}`); } -if (failed === 0) pass('FR-014 receipt binds target/device/candidate and is one-time'); +if (failed === 0) pass('FR-014 receipt binds target/device/candidate/recovery export and is one-time'); const transaction = read('patch/transaction_safety.sh'); if (!transaction.includes('patchnest_json_string rollback_backup "$PATCHNEST_PENDING_TRANSACTION_FILE"')) { @@ -117,6 +120,31 @@ if (/rm\s+-rf\s+\/data\/adb\/patchnest/.test(uninstall)) { fail('uninstall destroys boot-critical PatchNest recovery state'); } else pass('uninstall preserves boot-critical recovery state'); +const installer = read('install_kpm.sh'); +if (/unzip\s+[^\n]*-d\s+/.test(installer)) { + fail('KPM installer lets unzip directly materialize archive paths'); +} else pass('KPM installer materializes validated entries itself'); +if (!installer.includes('unzip -p')) fail('KPM installer lacks regular-file-only archive extraction'); +else pass('KPM installer extracts entry bytes with unzip -p'); +if (!installer.includes('7f454c460201') || !installer.includes('b700')) { + fail('KPM installer lacks ELF64 little-endian AArch64 admission check'); +} else pass('KPM installer enforces AArch64 ELF admission'); +if (!installer.includes('FR014_DEVICE_CANDIDATE')) fail('FR-014 candidate does not block persistent KPM installation'); +else pass('FR-014 candidate blocks persistent KPM installation'); + +const service = read('service.sh'); +if (!service.includes('.autoload')) fail('service does not require explicit KPM autoload markers'); +else pass('service requires explicit KPM autoload markers'); +if (!service.includes('validate_runtime_kpm')) fail('service lacks second-stage KPM binary validation'); +else pass('service revalidates KPM binaries before kernel load'); +if (!service.includes('FR-014 candidate pre-patch idle')) fail('service lacks FR-014 prepatch idle state'); +else pass('service distinguishes candidate prepatch idle from patched failure'); + +const verifier = read('kpm_verify.sh'); +if (!verifier.includes('openssl pkeyutl -verify') || !verifier.includes('302a300506032b6570032100')) { + fail('KPM signature verifier does not use Ed25519 SPKI/pkeyutl flow'); +} else pass('KPM signature verifier uses Ed25519 SPKI/pkeyutl flow'); + if (failed) { console.error(`release-safety package validation failed: ${failed} issue(s)`); process.exit(1); From b9980449e6a08cc20f8c3a79cabc7261d9d6420d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:37:46 +0800 Subject: [PATCH 134/152] fix(package): require hardened KPM and uninstall components --- scripts/package_module.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index 12266af..597aa46 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -26,10 +26,6 @@ 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; } -# Build assembles the Android binaries/WebUI into module/ immediately before -# packaging. When that complete tree is present, release-safety validation is a -# mandatory pre-ZIP gate. The source-only Flash safety workflow intentionally -# lacks these generated binaries and continues with its shell/transaction tests. if [ -s "$SOURCE_DIR/bin/kpatch" ] && \ [ -s "$SOURCE_DIR/bin/kptools" ] && \ [ -s "$SOURCE_DIR/bin/kpimg" ] && \ @@ -71,12 +67,19 @@ for required in \ FLASH_REVIEW_BLOCKED \ customize.sh \ service.sh \ + post-fs-data.sh \ + uninstall.sh \ + install_kpm.sh \ + compile_kpm.sh \ + kpm_verify.sh \ device_validation.sh \ arm_auto_recovery.sh \ verify_auto_recovery.sh \ export_recovery_boot.sh \ patch/boot_patch.sh \ + patch/boot_extract.sh \ patch/boot_unpatch.sh \ + patch/util_functions.sh \ patch/flash_safety.sh \ patch/transaction_safety.sh \ patch/transactional_flash.sh \ From ce6c30f6d0f9e1d43c3a890ae52373c65ff3726d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:39:32 +0800 Subject: [PATCH 135/152] ci(kpm): execute Ed25519 signature verification contract --- .github/workflows/flash-safety.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 70380ea..66f39cc 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -22,6 +22,7 @@ on: - 'tests/fr014_write_gate_contract.sh' - 'tests/kpm_install_security_contract.sh' - 'tests/kpm_runtime_admission_contract.sh' + - 'tests/kpm_signature_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -47,6 +48,7 @@ on: - 'tests/fr014_write_gate_contract.sh' - 'tests/kpm_install_security_contract.sh' - 'tests/kpm_runtime_admission_contract.sh' + - 'tests/kpm_signature_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -65,12 +67,12 @@ jobs: - name: Install validation tools run: | sudo apt-get update - sudo apt-get install -y shellcheck zip unzip xxd + sudo apt-get install -y shellcheck zip unzip xxd openssl - name: Shell and validator syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/install_kpm.sh module/uninstall.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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/candidate_prepatch_idle_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/kpm_install_security_contract.sh tests/kpm_runtime_admission_contract.sh tests/installer_contract.sh tests/recovery_export_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/install_kpm.sh module/kpm_verify.sh module/uninstall.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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/candidate_prepatch_idle_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/kpm_install_security_contract.sh tests/kpm_runtime_admission_contract.sh tests/kpm_signature_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done node --check tests/validate_flash_package.js @@ -91,6 +93,7 @@ jobs: module/post-fs-data.sh \ module/customize.sh \ module/install_kpm.sh \ + module/kpm_verify.sh \ module/uninstall.sh \ module/device_validation.sh \ module/arm_auto_recovery.sh \ @@ -108,6 +111,7 @@ jobs: tests/fr014_write_gate_contract.sh \ tests/kpm_install_security_contract.sh \ tests/kpm_runtime_admission_contract.sh \ + tests/kpm_signature_contract.sh \ tests/installer_contract.sh \ tests/recovery_export_contract.sh \ tests/runtime_abi_contract.sh @@ -146,6 +150,9 @@ jobs: - name: Run KPM runtime admission contract run: sh tests/kpm_runtime_admission_contract.sh + - name: Run KPM Ed25519 signature contract + run: sh tests/kpm_signature_contract.sh + - name: Run cross-manager installer contract run: sh tests/installer_contract.sh From 6418e8c17205eba5ce2b08926a5f35e24997d80e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:41:05 +0800 Subject: [PATCH 136/152] test(package): ignore comments when detecting executable eval --- tests/validate_flash_package.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/validate_flash_package.js b/tests/validate_flash_package.js index 478d9f8..97e2e80 100644 --- a/tests/validate_flash_package.js +++ b/tests/validate_flash_package.js @@ -32,6 +32,14 @@ function requireFile(rel, minSize = 1) { pass(`${rel} present (${st.size} bytes)`); return true; } +function hasExecutableShellCommand(text, command) { + const re = new RegExp(`^\\s*${command}(?:\\s|$)`); + return text.split(/\r?\n/).some(line => { + const trimmed = line.trimStart(); + if (!trimmed || trimmed.startsWith('#')) return false; + return re.test(line); + }); +} console.log('PatchNest release-safety package validation'); @@ -73,8 +81,8 @@ if (!fs.existsSync(blocker) && !fs.existsSync(candidate)) { } const util = read('patch/util_functions.sh'); -if (/\beval\b/.test(util)) fail('runtime util_functions.sh still contains eval'); -else pass('runtime util_functions.sh contains no eval'); +if (hasExecutableShellCommand(util, 'eval')) fail('runtime util_functions.sh still executes eval'); +else pass('runtime util_functions.sh executes no eval command'); if (/rm\s+-rf\s+["']?\$MODPATH/.test(util)) fail('runtime util_functions.sh can recursively delete MODPATH'); else pass('runtime util_functions.sh cannot recursively delete MODPATH'); From 0fb656dcedc7b5755bca15672b1708f11bd6a574 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:43:09 +0800 Subject: [PATCH 137/152] fix(kpm): gate direct KPM loads through runtime admission helper --- module/validate_kpm_file.sh | 68 +++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 module/validate_kpm_file.sh diff --git a/module/validate_kpm_file.sh b/module/validate_kpm_file.sh new file mode 100644 index 0000000..94a8f45 --- /dev/null +++ b/module/validate_kpm_file.sh @@ -0,0 +1,68 @@ +#!/system/bin/sh +# Validate one direct KPM file before any userspace path may call kpatch kpm load. +# Usage: validate_kpm_file.sh + +set -u + +MODDIR=${0%/*} +PNDIR="/data/adb/patchnest" +PATH="$MODDIR/bin:$PATH" +KPM_FILE=${1:-} + +fail() { + printf '%s\n' "! $1" >&2 + exit "${2:-1}" +} + +[ -n "$KPM_FILE" ] || fail "Usage: validate_kpm_file.sh " 2 +[ -f "$KPM_FILE" ] && [ ! -L "$KPM_FILE" ] && [ -s "$KPM_FILE" ] \ + || fail "KPM file is missing, empty, non-regular, or a symlink" 2 + +# Physical FR-014 candidate keeps normal persistent/direct KPM paths disabled. +# The only permitted diagnostic load is device_validation.sh kpm-cycle, which +# has an explicit unlock and unloads the module in the same controlled phase. +if [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ]; then + fail "Direct KPM loading is disabled on the FR-014 device candidate" 3 +fi + +command -v xxd >/dev/null 2>&1 || fail "xxd is required for KPM ELF validation" +[ -x "$MODDIR/bin/kptools" ] || fail "kptools is unavailable" + +_hdr=$(xxd -p -l 20 "$KPM_FILE" 2>/dev/null | tr -d '\r\n') +[ "$(printf '%s' "$_hdr" | cut -c1-12)" = "7f454c460201" ] \ + || fail "KPM is not ELF64 little-endian" +[ "$(printf '%s' "$_hdr" | cut -c37-40)" = "b700" ] \ + || fail "KPM is not AArch64" + +_meta=$(kptools -l -M "$KPM_FILE" 2>/dev/null) || fail "kptools rejected KPM metadata" +_name=$(printf '%s\n' "$_meta" | sed -n 's/^name=//p' | head -n 1) +[ -n "$_name" ] || fail "KPM metadata has no module name" + +POLICY=warn +if [ -f "$PNDIR/config" ]; then + _raw=$(grep -E '^[[:space:]]*(export[[:space:]]+)?KPM_SIGNATURE_POLICY[[:space:]]*=' \ + "$PNDIR/config" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//' | tr -d '"\r\n' | tr 'A-Z' 'a-z') + case "$_raw" in + off) POLICY=off ;; + warn) POLICY=warn ;; + strict) POLICY=strict ;; + 0|false) POLICY=off ;; + 1|true|yes|on) POLICY=strict ;; + esac +fi + +SIG_FILE="${KPM_FILE}.sig" +if [ "$POLICY" != "off" ]; then + if [ -f "$SIG_FILE" ] && [ ! -L "$SIG_FILE" ]; then + # shellcheck disable=SC1091 + . "$MODDIR/kpm_verify.sh" || fail "KPM signature verifier unavailable" + verify_kpm_sig "$KPM_FILE" "$SIG_FILE" || fail "KPM signature verification failed" + elif [ "$POLICY" = "strict" ]; then + fail "Unsigned direct KPM rejected by strict signature policy" + else + printf '%s\n' "- WARNING: unsigned direct KPM accepted by warn policy: $(basename "$KPM_FILE")" >&2 + fi +fi + +printf '%s\n' "KPM_VALIDATED name=$_name policy=$POLICY" +exit 0 From 5f9320670228ab34e5ffdcd41814f9692679aa43 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:50:55 +0800 Subject: [PATCH 138/152] fix(kpm): allow only explicit FR-014 diagnostic-cycle context --- module/validate_kpm_file.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/module/validate_kpm_file.sh b/module/validate_kpm_file.sh index 94a8f45..1fc0b20 100644 --- a/module/validate_kpm_file.sh +++ b/module/validate_kpm_file.sh @@ -19,9 +19,11 @@ fail() { || fail "KPM file is missing, empty, non-regular, or a symlink" 2 # Physical FR-014 candidate keeps normal persistent/direct KPM paths disabled. -# The only permitted diagnostic load is device_validation.sh kpm-cycle, which -# has an explicit unlock and unloads the module in the same controlled phase. -if [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ]; then +# The only allowed direct load is the controlled device_validation.sh kpm-cycle +# phase. The context only exempts the candidate-mode ban; ELF/metadata/signature +# admission below still runs in full. +if [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ] && \ + [ "${PATCHNEST_KPM_CONTEXT:-}" != "KPM_CYCLE" ]; then fail "Direct KPM loading is disabled on the FR-014 device candidate" 3 fi From 71779a916e3fa0ce2b6e55c45cc4c322f44b9908 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:51:07 +0800 Subject: [PATCH 139/152] fix(kpm): centralize direct KPM load admission in runtime wrapper --- module/kpatch_runtime_wrapper.sh | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 module/kpatch_runtime_wrapper.sh diff --git a/module/kpatch_runtime_wrapper.sh b/module/kpatch_runtime_wrapper.sh new file mode 100644 index 0000000..032d30f --- /dev/null +++ b/module/kpatch_runtime_wrapper.sh @@ -0,0 +1,30 @@ +#!/system/bin/sh +# Runtime guard for the packaged Public1158 kpatch CLI. +# All normal commands are delegated unchanged. Only `kpm load` receives an +# additional fail-closed userspace admission check before entering the kernel. + +set -u + +BINDIR=${0%/*} +MODDIR=${BINDIR%/bin} +REAL="$BINDIR/kpatch.real" + +[ -x "$REAL" ] || { + printf '%s\n' "! kpatch.real is missing or not executable" >&2 + exit 127 +} + +if [ "${1:-}" = "kpm" ] && [ "${2:-}" = "load" ]; then + KPM_FILE=${3:-} + [ -n "$KPM_FILE" ] || { + printf '%s\n' "! kpatch kpm load requires a module path" >&2 + exit 2 + } + [ -x "$MODDIR/validate_kpm_file.sh" ] || { + printf '%s\n' "! KPM admission helper is missing" >&2 + exit 3 + } + PATH="$BINDIR:$PATH" "$MODDIR/validate_kpm_file.sh" "$KPM_FILE" || exit $? +fi + +exec "$REAL" "$@" From 3711182693eef83545689c7373fa8316f1dd4f73 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:51:40 +0800 Subject: [PATCH 140/152] fix(kpm): reuse explicit diagnostic unlock for candidate direct load --- module/validate_kpm_file.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/module/validate_kpm_file.sh b/module/validate_kpm_file.sh index 1fc0b20..eb1f524 100644 --- a/module/validate_kpm_file.sh +++ b/module/validate_kpm_file.sh @@ -20,10 +20,11 @@ fail() { # Physical FR-014 candidate keeps normal persistent/direct KPM paths disabled. # The only allowed direct load is the controlled device_validation.sh kpm-cycle -# phase. The context only exempts the candidate-mode ban; ELF/metadata/signature -# admission below still runs in full. +# phase. The context/unlock only exempts the candidate-mode ban; +# ELF/metadata/signature admission below still runs in full. if [ -f "$MODDIR/FR014_DEVICE_CANDIDATE" ] && \ - [ "${PATCHNEST_KPM_CONTEXT:-}" != "KPM_CYCLE" ]; then + [ "${PATCHNEST_KPM_CONTEXT:-}" != "KPM_CYCLE" ] && \ + [ "${PATCHNEST_DEVICE_TEST_UNLOCK:-}" != "KPM_CYCLE" ]; then fail "Direct KPM loading is disabled on the FR-014 device candidate" 3 fi From a9413bdc45a8a11b1e2a8a3dd9aed69952b0fd44 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:52:05 +0800 Subject: [PATCH 141/152] fix(kpm): install central kpatch runtime admission wrapper --- module/customize.sh | 68 +++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/module/customize.sh b/module/customize.sh index 96aa347..6f8fb7d 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -7,7 +7,7 @@ if [ -z "${MODPATH:-}" ] || [ ! -d "$MODPATH" ]; then fi # Review packages are deliberately non-installable. This check must remain -# before every persistent PatchNest write. +# before every persistent PatchNest write or installed-tree mutation. 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" @@ -19,9 +19,6 @@ if [ "${ARCH:-}" != "arm64" ]; then abort "! Only arm64 is supported" fi -# 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 @@ -42,23 +39,27 @@ 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 export_recovery_boot.sh; do +for _pn_tool in \ + device_validation.sh \ + arm_auto_recovery.sh \ + verify_auto_recovery.sh \ + export_recovery_boot.sh \ + validate_kpm_file.sh \ + kpatch_runtime_wrapper.sh; do [ ! -f "$MODPATH/$_pn_tool" ] || set_perm "$MODPATH/$_pn_tool" 0 0 0755 done -# Fail before creating persistent state if the extracted install tree is not a -# complete runnable package. +# Validate the extracted package before creating state or replacing the CLI +# entry point with its runtime policy wrapper. 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" + if [ ! -x "$MODPATH/bin/$_pn_bin" ] || [ -L "$MODPATH/bin/$_pn_bin" ]; then + abort "! Required binary missing/not executable/unsafe: bin/$_pn_bin" fi done -if [ ! -s "$MODPATH/bin/kpimg" ]; then - abort "! Required KernelPatch image missing or empty: bin/kpimg" +if [ ! -s "$MODPATH/bin/kpimg" ] || [ -L "$MODPATH/bin/kpimg" ]; then + abort "! Required KernelPatch image missing, empty, or unsafe: bin/kpimg" fi for _pn_script in \ boot_patch.sh \ @@ -69,16 +70,46 @@ for _pn_script in \ transactional_flash.sh \ fr014_gate.sh \ superkey_safety.sh; do - if [ ! -x "$MODPATH/patch/$_pn_script" ]; then - abort "! Required patch helper missing or not executable: patch/$_pn_script" + if [ ! -x "$MODPATH/patch/$_pn_script" ] || [ -L "$MODPATH/patch/$_pn_script" ]; then + abort "! Required patch helper missing/not executable/unsafe: patch/$_pn_script" fi done -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" +for _pn_tool in \ + device_validation.sh \ + arm_auto_recovery.sh \ + verify_auto_recovery.sh \ + export_recovery_boot.sh \ + validate_kpm_file.sh \ + kpatch_runtime_wrapper.sh; do + if [ ! -x "$MODPATH/$_pn_tool" ] || [ -L "$MODPATH/$_pn_tool" ]; then + abort "! Required runtime/validation tool missing/not executable/unsafe: $_pn_tool" fi done +command -v sha256sum >/dev/null 2>&1 || abort "! sha256sum is required" +PROVENANCE="$MODPATH/provenance/kpatch-public1158.json" +[ -f "$PROVENANCE" ] && [ ! -L "$PROVENANCE" ] || abort "! Public1158 provenance is missing or unsafe" +EXPECTED_KPATCH_SHA=$(sed -n 's/.*"binarySha256"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$PROVENANCE" | head -n 1) +ACTUAL_KPATCH_SHA=$(sha256sum "$MODPATH/bin/kpatch" 2>/dev/null | awk '{print $1}') +[ -n "$EXPECTED_KPATCH_SHA" ] && [ "$ACTUAL_KPATCH_SHA" = "$EXPECTED_KPATCH_SHA" ] \ + || abort "! Packaged Public1158 kpatch does not match provenance" + +# Centralize all runtime `kpatch kpm load` calls behind the same admission +# helper. This closes WebUI/direct-shell paths that could otherwise bypass the +# hardened ZIP installer. The reviewed ARM64 ELF remains available as +# kpatch.real and is what provenance authenticates. +rm -f "$MODPATH/bin/kpatch.real" +mv "$MODPATH/bin/kpatch" "$MODPATH/bin/kpatch.real" \ + || abort "! Cannot preserve validated Public1158 CLI as kpatch.real" +cp "$MODPATH/kpatch_runtime_wrapper.sh" "$MODPATH/bin/kpatch" \ + || abort "! Cannot install kpatch runtime wrapper" +set_perm "$MODPATH/bin/kpatch.real" 0 2000 0755 +set_perm "$MODPATH/bin/kpatch" 0 2000 0755 +[ -x "$MODPATH/bin/kpatch.real" ] && [ -x "$MODPATH/bin/kpatch" ] \ + || abort "! kpatch runtime wrapper installation failed" +[ "$(sha256sum "$MODPATH/bin/kpatch.real" 2>/dev/null | awk '{print $1}')" = "$EXPECTED_KPATCH_SHA" ] \ + || abort "! kpatch.real changed while installing runtime wrapper" + mkdir -p "$PNDIR" || abort "! Cannot create PatchNest state directory" chmod 0700 "$PNDIR" 2>/dev/null || true @@ -94,6 +125,7 @@ printf '%s\n' "$ROOT_MGR" > "$PNDIR/root_manager" || abort "! Cannot persist roo chmod 0600 "$PNDIR/root_manager" 2>/dev/null || true ui_print "- PatchNest files validated in manager-provided MODPATH" +ui_print "- kpatch runtime KPM admission wrapper installed" ui_print "- Persistent state initialized" ui_print "- Installation complete" ui_print "" From 6d6bfc22516019e0251f55be1e7c6a34e58becca Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:52:43 +0800 Subject: [PATCH 142/152] test(installer): verify kpatch runtime wrapper installation across managers --- tests/installer_contract.sh | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/installer_contract.sh b/tests/installer_contract.sh index 6bdadfb..a76f306 100644 --- a/tests/installer_contract.sh +++ b/tests/installer_contract.sh @@ -18,10 +18,11 @@ trap 'rm -rf "$TMP"' EXIT HUP INT TERM || 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" +grep -Fq 'kpatch.real' "$CUSTOMIZE" || fail "installer does not preserve the reviewed CLI behind a wrapper" make_fake_module() { _pn_dir=$1 - mkdir -p "$_pn_dir/bin" "$_pn_dir/patch" + mkdir -p "$_pn_dir/bin" "$_pn_dir/patch" "$_pn_dir/provenance" 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" @@ -39,6 +40,13 @@ make_fake_module() { printf '%s\n' '#!/bin/sh' 'exit 0' > "$_pn_dir/$_pn_tool" chmod 0644 "$_pn_dir/$_pn_tool" done + cp "$ROOT/module/validate_kpm_file.sh" "$_pn_dir/validate_kpm_file.sh" + cp "$ROOT/module/kpatch_runtime_wrapper.sh" "$_pn_dir/kpatch_runtime_wrapper.sh" + chmod 0644 "$_pn_dir/validate_kpm_file.sh" "$_pn_dir/kpatch_runtime_wrapper.sh" + _pn_sha=$(sha256sum "$_pn_dir/bin/kpatch" | awk '{print $1}') + cat > "$_pn_dir/provenance/kpatch-public1158.json" < "$_pn_dir/repos.json" } @@ -82,11 +90,16 @@ run_installer() { [ "$(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/patch/fr014_gate.sh")" = "755" ] || return 95 - [ "$(stat -c '%a' "$_pn_mod/device_validation.sh")" = "755" ] || return 96 - [ "$(stat -c '%a' "$_pn_mod/export_recovery_boot.sh")" = "755" ] || return 97 - [ ! -e "$_pn_mod/module.prop.bak" ] || return 98 + [ "$(stat -c '%a' "$_pn_mod/bin/kpatch.real")" = "755" ] || return 94 + [ "$(stat -c '%a' "$_pn_mod/patch/boot_patch.sh")" = "755" ] || return 95 + [ "$(stat -c '%a' "$_pn_mod/patch/fr014_gate.sh")" = "755" ] || return 96 + [ "$(stat -c '%a' "$_pn_mod/device_validation.sh")" = "755" ] || return 97 + [ "$(stat -c '%a' "$_pn_mod/validate_kpm_file.sh")" = "755" ] || return 98 + [ "$(stat -c '%a' "$_pn_mod/export_recovery_boot.sh")" = "755" ] || return 99 + grep -Fq 'kpatch.real' "$_pn_mod/bin/kpatch" || return 89 + _pn_expected_sha=$(sed -n 's/.*"binarySha256":"\([0-9a-f]\{64\}\)".*/\1/p' "$_pn_mod/provenance/kpatch-public1158.json") + [ "$(sha256sum "$_pn_mod/bin/kpatch.real" | awk '{print $1}')" = "$_pn_expected_sha" ] || return 88 + [ ! -e "$_pn_mod/module.prop.bak" ] || return 87 return 0 } @@ -100,6 +113,7 @@ 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" +[ ! -e "$BLOCKED/bin/kpatch.real" ] || fail "review blocker allowed installed-tree CLI mutation" grep -Fq 'ABORT:! FLASH_REVIEW_BLOCKED' "$TMP/blocked.log" || fail "review blocker abort reason missing" for spec in 'magisk:magisk' 'ksu:ksu' 'apatch:apatch'; do @@ -111,6 +125,7 @@ for spec in 'magisk:magisk' 'ksu:ksu' 'apatch:apatch'; do if ! run_installer "$mod" "$state" "$manager" "$expected" "$TMP/$manager.log"; then fail "$manager installer simulation failed" fi + grep -Fq 'UI:- kpatch runtime KPM admission wrapper installed' "$TMP/$manager.log" || fail "$manager did not install runtime wrapper" grep -Fq 'UI:- Installation complete' "$TMP/$manager.log" || fail "$manager install did not complete" done From 2f07bc2ed31f59078affa66cb15b602919c4c15e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:54:35 +0800 Subject: [PATCH 143/152] test(package): require centralized direct KPM runtime guard --- tests/validate_flash_package.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/validate_flash_package.js b/tests/validate_flash_package.js index 97e2e80..511de7a 100644 --- a/tests/validate_flash_package.js +++ b/tests/validate_flash_package.js @@ -55,6 +55,8 @@ for (const [rel, minSize] of [ ['install_kpm.sh', 1], ['compile_kpm.sh', 1], ['kpm_verify.sh', 1], + ['validate_kpm_file.sh', 1], + ['kpatch_runtime_wrapper.sh', 1], ['device_validation.sh', 1], ['arm_auto_recovery.sh', 1], ['verify_auto_recovery.sh', 1], @@ -140,6 +142,32 @@ if (!installer.includes('7f454c460201') || !installer.includes('b700')) { if (!installer.includes('FR014_DEVICE_CANDIDATE')) fail('FR-014 candidate does not block persistent KPM installation'); else pass('FR-014 candidate blocks persistent KPM installation'); +const directValidator = read('validate_kpm_file.sh'); +if (!directValidator.includes('7f454c460201') || !directValidator.includes('b700')) { + fail('direct KPM validator lacks AArch64 ELF admission'); +} else pass('direct KPM validator enforces AArch64 ELF admission'); +if (!directValidator.includes('KPM_CYCLE') || !directValidator.includes('FR014_DEVICE_CANDIDATE')) { + fail('direct KPM validator lacks explicit FR-014 diagnostic exception boundary'); +} else pass('direct KPM validator isolates FR-014 diagnostic KPM cycle'); + +const wrapper = read('kpatch_runtime_wrapper.sh'); +if (!wrapper.includes('kpatch.real')) fail('runtime kpatch wrapper does not delegate to kpatch.real'); +else pass('runtime kpatch wrapper delegates to kpatch.real'); +if (!wrapper.includes('"${1:-}" = "kpm"') || !wrapper.includes('"${2:-}" = "load"')) { + fail('runtime wrapper does not intercept kpm load'); +} else pass('runtime wrapper intercepts kpm load'); +if (!wrapper.includes('validate_kpm_file.sh')) fail('runtime wrapper does not invoke direct KPM admission helper'); +else pass('runtime wrapper invokes direct KPM admission helper'); + +const customize = read('customize.sh'); +for (const required of ['kpatch.real', 'kpatch_runtime_wrapper.sh', 'validate_kpm_file.sh', 'binarySha256']) { + if (!customize.includes(required)) fail(`installer missing runtime-wrapper invariant: ${required}`); +} +if (customize.includes('mv "$MODPATH/bin/kpatch" "$MODPATH/bin/kpatch.real"') && + customize.includes('cp "$MODPATH/kpatch_runtime_wrapper.sh" "$MODPATH/bin/kpatch"')) { + pass('installer preserves validated CLI and replaces entry point with runtime guard'); +} + const service = read('service.sh'); if (!service.includes('.autoload')) fail('service does not require explicit KPM autoload markers'); else pass('service requires explicit KPM autoload markers'); From 4eafe3c8a36e18fbefc001bc06f58d96d142327e Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:55:04 +0800 Subject: [PATCH 144/152] fix(package): require runtime KPM guard components --- scripts/package_module.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index 597aa46..05eb11d 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -72,6 +72,8 @@ for required in \ install_kpm.sh \ compile_kpm.sh \ kpm_verify.sh \ + validate_kpm_file.sh \ + kpatch_runtime_wrapper.sh \ device_validation.sh \ arm_auto_recovery.sh \ verify_auto_recovery.sh \ From ca78fd880f7d21218ed6a28461f65fcd19100690 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:55:25 +0800 Subject: [PATCH 145/152] test(kpm): execute centralized kpatch runtime admission wrapper --- tests/kpatch_runtime_wrapper_contract.sh | 83 ++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/kpatch_runtime_wrapper_contract.sh diff --git a/tests/kpatch_runtime_wrapper_contract.sh b/tests/kpatch_runtime_wrapper_contract.sh new file mode 100644 index 0000000..fe4d540 --- /dev/null +++ b/tests/kpatch_runtime_wrapper_contract.sh @@ -0,0 +1,83 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +MOD="$TMP/module" +BIN="$MOD/bin" +mkdir -p "$BIN" + +fail() { + echo "kpatch runtime wrapper contract: FAIL: $*" >&2 + exit 1 +} + +cp "$ROOT/module/kpatch_runtime_wrapper.sh" "$BIN/kpatch" +chmod 0755 "$BIN/kpatch" + +cat > "$BIN/kpatch.real" <> "$TMP/real.calls" +case "\${1:-}" in + hello) printf '%s\n' hello1158 ;; +esac +exit 0 +EOF +chmod 0755 "$BIN/kpatch.real" + +cat > "$MOD/validate_kpm_file.sh" <> "$TMP/validator.calls" +case "\${PATCHNEST_TEST_VALIDATOR_RC:-0}" in + 0) exit 0 ;; + *) exit "\$PATCHNEST_TEST_VALIDATOR_RC" ;; +esac +EOF +chmod 0755 "$MOD/validate_kpm_file.sh" + +printf '%s\n' synthetic > "$TMP/module.kpm" + +# 1. Non-load commands delegate unchanged and never invoke KPM admission. +PATH="$BIN:$PATH" "$BIN/kpatch" hello > "$TMP/hello.out" +[ "$(cat "$TMP/hello.out")" = "hello1158" ] || fail "hello output was not delegated unchanged" +grep -Fxq 'hello' "$TMP/real.calls" || fail "hello did not reach real CLI" +[ ! -e "$TMP/validator.calls" ] || fail "non-KPM command invoked KPM validator" + +# 2. kpm load must validate before real CLI and preserve all argv content. +: > "$TMP/real.calls" +PATH="$BIN:$PATH" "$BIN/kpatch" kpm load "$TMP/module.kpm" 'mode=test value=2' +[ "$(cat "$TMP/validator.calls")" = "$TMP/module.kpm" ] || fail "KPM path was not sent to validator" +grep -Fxq "kpm load $TMP/module.kpm mode=test value=2" "$TMP/real.calls" \ + || fail "validated KPM load was not delegated with original argv" + +# 3. Validator rejection must prevent any kernel CLI invocation. +: > "$TMP/real.calls" +: > "$TMP/validator.calls" +set +e +PATCHNEST_TEST_VALIDATOR_RC=17 PATH="$BIN:$PATH" "$BIN/kpatch" kpm load "$TMP/module.kpm" rejected >/dev/null 2>&1 +rc=$? +set -e +[ "$rc" -eq 17 ] || fail "validator rejection status was not propagated (rc=$rc)" +[ ! -s "$TMP/real.calls" ] || fail "rejected KPM still reached kpatch.real" +[ "$(cat "$TMP/validator.calls")" = "$TMP/module.kpm" ] || fail "rejected KPM was not validated first" + +# 4. Missing validator fails closed before real CLI. +rm -f "$MOD/validate_kpm_file.sh" +: > "$TMP/real.calls" +set +e +PATH="$BIN:$PATH" "$BIN/kpatch" kpm load "$TMP/module.kpm" >/dev/null 2>&1 +rc=$? +set -e +[ "$rc" -eq 3 ] || fail "missing validator did not fail closed with rc=3 (rc=$rc)" +[ ! -s "$TMP/real.calls" ] || fail "missing-validator path reached kpatch.real" + +# 5. Missing real CLI fails closed for every command. +mv "$BIN/kpatch.real" "$BIN/kpatch.real.missing" +set +e +PATH="$BIN:$PATH" "$BIN/kpatch" hello >/dev/null 2>&1 +rc=$? +set -e +[ "$rc" -eq 127 ] || fail "missing real CLI did not fail with rc=127 (rc=$rc)" + +echo "kpatch runtime wrapper contract: PASS" From 4318f34de5f111b625e4cd6e236304923aa5ca2d Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:55:55 +0800 Subject: [PATCH 146/152] ci(kpm): execute centralized runtime wrapper contract --- .github/workflows/flash-safety.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml index 66f39cc..f2be2b9 100644 --- a/.github/workflows/flash-safety.yml +++ b/.github/workflows/flash-safety.yml @@ -23,6 +23,7 @@ on: - 'tests/kpm_install_security_contract.sh' - 'tests/kpm_runtime_admission_contract.sh' - 'tests/kpm_signature_contract.sh' + - 'tests/kpatch_runtime_wrapper_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -49,6 +50,7 @@ on: - 'tests/kpm_install_security_contract.sh' - 'tests/kpm_runtime_admission_contract.sh' - 'tests/kpm_signature_contract.sh' + - 'tests/kpatch_runtime_wrapper_contract.sh' - 'tests/installer_contract.sh' - 'tests/recovery_export_contract.sh' - 'tests/runtime_abi_contract.sh' @@ -72,7 +74,7 @@ jobs: - name: Shell and validator syntax run: | set -euo pipefail - for file in module/patch/*.sh module/service.sh module/post-fs-data.sh module/customize.sh module/install_kpm.sh module/kpm_verify.sh module/uninstall.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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/candidate_prepatch_idle_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/kpm_install_security_contract.sh tests/kpm_runtime_admission_contract.sh tests/kpm_signature_contract.sh tests/installer_contract.sh tests/recovery_export_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/install_kpm.sh module/kpm_verify.sh module/validate_kpm_file.sh module/kpatch_runtime_wrapper.sh module/uninstall.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/transaction_backup_identity_contract.sh tests/bootloop_recovery_contract.sh tests/boot_resolution_failure_contract.sh tests/candidate_prepatch_idle_contract.sh tests/fr014_preflight_contract.sh tests/fr014_write_gate_contract.sh tests/kpm_install_security_contract.sh tests/kpm_runtime_admission_contract.sh tests/kpm_signature_contract.sh tests/kpatch_runtime_wrapper_contract.sh tests/installer_contract.sh tests/recovery_export_contract.sh tests/runtime_abi_contract.sh; do sh -n "$file" done node --check tests/validate_flash_package.js @@ -94,6 +96,8 @@ jobs: module/customize.sh \ module/install_kpm.sh \ module/kpm_verify.sh \ + module/validate_kpm_file.sh \ + module/kpatch_runtime_wrapper.sh \ module/uninstall.sh \ module/device_validation.sh \ module/arm_auto_recovery.sh \ @@ -112,6 +116,7 @@ jobs: tests/kpm_install_security_contract.sh \ tests/kpm_runtime_admission_contract.sh \ tests/kpm_signature_contract.sh \ + tests/kpatch_runtime_wrapper_contract.sh \ tests/installer_contract.sh \ tests/recovery_export_contract.sh \ tests/runtime_abi_contract.sh @@ -153,6 +158,9 @@ jobs: - name: Run KPM Ed25519 signature contract run: sh tests/kpm_signature_contract.sh + - name: Run centralized kpatch runtime wrapper contract + run: sh tests/kpatch_runtime_wrapper_contract.sh + - name: Run cross-manager installer contract run: sh tests/installer_contract.sh From 433998c79d35f45c68a0c6fce55f5f929d091746 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:57:26 +0800 Subject: [PATCH 147/152] test(kpm): annotate generated wrapper fixtures for ShellCheck --- tests/kpatch_runtime_wrapper_contract.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/kpatch_runtime_wrapper_contract.sh b/tests/kpatch_runtime_wrapper_contract.sh index fe4d540..d40f02a 100644 --- a/tests/kpatch_runtime_wrapper_contract.sh +++ b/tests/kpatch_runtime_wrapper_contract.sh @@ -16,6 +16,10 @@ fail() { cp "$ROOT/module/kpatch_runtime_wrapper.sh" "$BIN/kpatch" chmod 0755 "$BIN/kpatch" +# The heredoc intentionally writes literal shell expansions for the generated +# fake executable; they must expand when that fixture runs, not while this test +# creates it. +# shellcheck disable=SC2016 cat > "$BIN/kpatch.real" <> "$TMP/real.calls" @@ -26,6 +30,7 @@ exit 0 EOF chmod 0755 "$BIN/kpatch.real" +# shellcheck disable=SC2016 cat > "$MOD/validate_kpm_file.sh" <> "$TMP/validator.calls" From f927a2738b7b3ae4cfc4a811897a1be9bc6acbe3 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 02:59:10 +0800 Subject: [PATCH 148/152] test(kpm): run Android wrapper through host shell in CI --- tests/kpatch_runtime_wrapper_contract.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/kpatch_runtime_wrapper_contract.sh b/tests/kpatch_runtime_wrapper_contract.sh index d40f02a..9bae795 100644 --- a/tests/kpatch_runtime_wrapper_contract.sh +++ b/tests/kpatch_runtime_wrapper_contract.sh @@ -43,15 +43,21 @@ chmod 0755 "$MOD/validate_kpm_file.sh" printf '%s\n' synthetic > "$TMP/module.kpm" +# Production correctly uses /system/bin/sh. Ubuntu CI has no /system/bin/sh, +# so execute the same wrapper body through the host POSIX shell here. +run_wrapper() { + PATH="$BIN:$PATH" sh "$BIN/kpatch" "$@" +} + # 1. Non-load commands delegate unchanged and never invoke KPM admission. -PATH="$BIN:$PATH" "$BIN/kpatch" hello > "$TMP/hello.out" +run_wrapper hello > "$TMP/hello.out" [ "$(cat "$TMP/hello.out")" = "hello1158" ] || fail "hello output was not delegated unchanged" grep -Fxq 'hello' "$TMP/real.calls" || fail "hello did not reach real CLI" [ ! -e "$TMP/validator.calls" ] || fail "non-KPM command invoked KPM validator" # 2. kpm load must validate before real CLI and preserve all argv content. : > "$TMP/real.calls" -PATH="$BIN:$PATH" "$BIN/kpatch" kpm load "$TMP/module.kpm" 'mode=test value=2' +run_wrapper kpm load "$TMP/module.kpm" 'mode=test value=2' [ "$(cat "$TMP/validator.calls")" = "$TMP/module.kpm" ] || fail "KPM path was not sent to validator" grep -Fxq "kpm load $TMP/module.kpm mode=test value=2" "$TMP/real.calls" \ || fail "validated KPM load was not delegated with original argv" @@ -60,7 +66,7 @@ grep -Fxq "kpm load $TMP/module.kpm mode=test value=2" "$TMP/real.calls" \ : > "$TMP/real.calls" : > "$TMP/validator.calls" set +e -PATCHNEST_TEST_VALIDATOR_RC=17 PATH="$BIN:$PATH" "$BIN/kpatch" kpm load "$TMP/module.kpm" rejected >/dev/null 2>&1 +PATCHNEST_TEST_VALIDATOR_RC=17 run_wrapper kpm load "$TMP/module.kpm" rejected >/dev/null 2>&1 rc=$? set -e [ "$rc" -eq 17 ] || fail "validator rejection status was not propagated (rc=$rc)" @@ -71,7 +77,7 @@ set -e rm -f "$MOD/validate_kpm_file.sh" : > "$TMP/real.calls" set +e -PATH="$BIN:$PATH" "$BIN/kpatch" kpm load "$TMP/module.kpm" >/dev/null 2>&1 +run_wrapper kpm load "$TMP/module.kpm" >/dev/null 2>&1 rc=$? set -e [ "$rc" -eq 3 ] || fail "missing validator did not fail closed with rc=3 (rc=$rc)" @@ -80,7 +86,7 @@ set -e # 5. Missing real CLI fails closed for every command. mv "$BIN/kpatch.real" "$BIN/kpatch.real.missing" set +e -PATH="$BIN:$PATH" "$BIN/kpatch" hello >/dev/null 2>&1 +run_wrapper hello >/dev/null 2>&1 rc=$? set -e [ "$rc" -eq 127 ] || fail "missing real CLI did not fail with rc=127 (rc=$rc)" From fd02224bd9a96474615606dba6ff67fd63509f37 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 03:01:49 +0800 Subject: [PATCH 149/152] validation: unlock isolated FR-014 v2 candidate only --- module/FLASH_REVIEW_BLOCKED | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 module/FLASH_REVIEW_BLOCKED diff --git a/module/FLASH_REVIEW_BLOCKED b/module/FLASH_REVIEW_BLOCKED deleted file mode 100644 index 188341e..0000000 --- a/module/FLASH_REVIEW_BLOCKED +++ /dev/null @@ -1,2 +0,0 @@ -PatchNest flash-readiness review is still open. Do not flash this branch. -See ../FLASH_READINESS.md for the active release gates. From 3c79d5e4b7a187e1fb23a88ecabf761433525fe0 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 03:02:02 +0800 Subject: [PATCH 150/152] validation: mark isolated FR-014 v2 device candidate --- module/FR014_DEVICE_CANDIDATE | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 module/FR014_DEVICE_CANDIDATE diff --git a/module/FR014_DEVICE_CANDIDATE b/module/FR014_DEVICE_CANDIDATE new file mode 100644 index 0000000..fd0cb27 --- /dev/null +++ b/module/FR014_DEVICE_CANDIDATE @@ -0,0 +1,3 @@ +PatchNest FR-014 physical-device candidate v2. +Reviewed runtime base: f927a2738b7b3ae4cfc4a811897a1be9bc6acbe3 +Do not merge or publish as a general release until physical FR-014 evidence passes. From fb275c8fd25f0626b845a23727c16593c0981f69 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 03:02:26 +0800 Subject: [PATCH 151/152] validation: require candidate marker and reject review blocker --- scripts/package_module.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/package_module.sh b/scripts/package_module.sh index 05eb11d..e70b52c 100644 --- a/scripts/package_module.sh +++ b/scripts/package_module.sh @@ -26,6 +26,17 @@ 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; } +# This branch is an isolated physical-test package. Refuse to build unless the +# candidate identity is explicit and the normal review blocker is absent. +[ -f "$SOURCE_DIR/FR014_DEVICE_CANDIDATE" ] || { + echo "FR-014 candidate marker missing" >&2 + exit 1 +} +[ ! -e "$SOURCE_DIR/FLASH_REVIEW_BLOCKED" ] || { + echo "review blocker must not be present in FR-014 candidate" >&2 + exit 1 +} + if [ -s "$SOURCE_DIR/bin/kpatch" ] && \ [ -s "$SOURCE_DIR/bin/kptools" ] && \ [ -s "$SOURCE_DIR/bin/kpimg" ] && \ @@ -64,7 +75,7 @@ rm -f "$OUTPUT_ABS" unzip -Z1 "$OUTPUT_ABS" > "$STAGE/zip-list" for required in \ module.prop \ - FLASH_REVIEW_BLOCKED \ + FR014_DEVICE_CANDIDATE \ customize.sh \ service.sh \ post-fs-data.sh \ @@ -88,11 +99,15 @@ for required in \ patch/fr014_gate.sh \ patch/superkey_safety.sh; do grep -Fxq "$required" "$STAGE/zip-list" || { - echo "required package entry missing: $required" >&2 + echo "required candidate package entry missing: $required" >&2 exit 1 } done +if grep -Fxq 'FLASH_REVIEW_BLOCKED' "$STAGE/zip-list"; then + echo "candidate ZIP unexpectedly contains FLASH_REVIEW_BLOCKED" >&2 + exit 1 +fi if grep -Eq '(^|/)\.\.(/|$)|^\./' "$STAGE/zip-list"; then echo "unsafe or non-canonical path found in module ZIP" >&2 exit 1 From a056541b99cbb2b3f64b6de404bfe040ba5745a4 Mon Sep 17 00:00:00 2001 From: Axymorrsen Date: Sun, 9 Aug 2026 03:02:53 +0800 Subject: [PATCH 152/152] validation: record v2 candidate runtime diff policy --- CANDIDATE_V2_AUDIT.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CANDIDATE_V2_AUDIT.md diff --git a/CANDIDATE_V2_AUDIT.md b/CANDIDATE_V2_AUDIT.md new file mode 100644 index 0000000..b35dc89 --- /dev/null +++ b/CANDIDATE_V2_AUDIT.md @@ -0,0 +1,7 @@ +# FR-014 device candidate v2 + +Reviewed runtime base: `f927a2738b7b3ae4cfc4a811897a1be9bc6acbe3`. + +This branch is for physical FR-014 validation only. Relative to the reviewed runtime base, module runtime behavior may differ only by removal of `module/FLASH_REVIEW_BLOCKED` and addition of `module/FR014_DEVICE_CANDIDATE`. Repository-side candidate packaging may additionally require that marker and reject the normal blocker. + +Do not merge or publish this branch as a general release until the physical lifecycle evidence passes.