diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 12ff9377..1e41f98e 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,23 +91,30 @@ 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 - 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 \ 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 +179,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 +199,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 +223,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: | @@ -259,15 +308,23 @@ 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' 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 diff --git a/.github/workflows/flash-safety.yml b/.github/workflows/flash-safety.yml new file mode 100644 index 00000000..e14c09c4 --- /dev/null +++ b/.github/workflows/flash-safety.yml @@ -0,0 +1,127 @@ +name: Flash safety + +permissions: + contents: read + +on: + pull_request: + branches: [main] + paths: + - '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' + - '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' + - '.github/workflows/flash-safety.yml' + push: + branches: + - 'review/flash-readiness-hardening' + - 'review/flash-readiness-final' + paths: + - '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' + - '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' + - '.github/workflows/flash-safety.yml' + workflow_dispatch: + +jobs: + contract: + name: Transactional flash and ABI 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 zip unzip + + - 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 + sh -n "$file" + done + + - name: ShellCheck reviewed flash/runtime 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 \ + module/patch/transaction_safety.sh \ + 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 \ + 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 + 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 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 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 + + - 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 diff --git a/FLASH_READINESS.md b/FLASH_READINESS.md new file mode 100644 index 00000000..d71f5a05 --- /dev/null +++ b/FLASH_READINESS.md @@ -0,0 +1,194 @@ +# PatchNest flash-readiness review + +Status: **NOT FLASH READY — STATIC/CODE GATES CLOSED, PHYSICAL DEVICE GATE OPEN** + +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. + +Reviewed code/package baseline before this documentation commit: + +`63d94f480cddf398e76cfa2ca6826e7ce3e455c4` + +Verified at that baseline: + +- `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. + +## Resolved static findings + +### FR-001 — mixed userspace/kernel ABI family — **RESOLVED** + +The package no longer combines Public1158 kernel components with the historical +Next2026 `kpatch-android` release binary. + +`version.properties` pins reviewed PatchNest source commit: + +`7fed93c4e259a6edf191c1a9900874babb232c4b` + +and the module build independently compiles `kpatch-public1158` for Android ARM64. +The package provenance records the source commit and resulting binary SHA-256. + +The compatibility target is explicit rather than runtime mutation probing: + +- 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. + +`service.sh` accepts only exact `hello1158` / `hello2026` responses and derives +runtime capability from the resulting profile. Unknown successful hello output is +rejected. + +### FR-002 — shared patch workspace / stale image reuse — **RESOLVED** + +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. + +### FR-003 — rollback not bound to exact target/device/transaction — **RESOLVED IN CODE** + +A destructive write creates a unique validated rollback backup. Automatic restore no +longer scans or chooses a "latest" backup. + +The committed rollback transaction binds: + +- 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. + +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. + +`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. + +### FR-004 — destructive flash without mandatory readback — **RESOLVED** + +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. + +### FR-005 — root-shell `eval` fallback — **RESOLVED** + +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-006 — ambiguous A/B boot target / vendor_boot guessing — **RESOLVED** + +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. + +### FR-007 — block-device backup/hash gap — **RESOLVED** + +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. + +### FR-008 — backup name collision — **RESOLVED** + +Backup names use UTC second precision plus `mktemp` uniqueness. Rollback selection is +transaction-bound, not filename-order based. + +### FR-009 — embedded KPM validation fail-open — **RESOLVED** + +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. + +### FR-010 — KPM argument contract mismatch — **RESOLVED** + +Runtime uses `kpatch kpm load PATH [ARGS]`; a literal `--` is no longer passed as KPM +argument data. + +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. + +### FR-011 — lifecycle event path mismatched packaged ABI — **RESOLVED** + +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. + +### FR-012 — hidden runtime failures / false hello success — **RESOLVED** + +`kpatch hello` fails non-zero on syscall/authentication or foreign magic. Handshake, +exclusion, rehook and Public event failures are surfaced in logs/unresolved state. + +### FR-013 — final ZIP reproducibility unproven — **RESOLVED** + +`scripts/package_module.sh` normalizes timestamps, strips host-specific ZIP extras, +uses canonical root entry names and a stable lexical file order. + +The final assembled module tree is packaged twice and must compare byte-for-byte +identical before artifact upload. This passed in Build run `31233359168`. + +## Remaining release blocker + +### FR-014 — physical device lifecycle evidence — **OPEN / RELEASE-BLOCKING** + +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 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 + +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 diff --git a/build.sh b/build.sh index 23372a48..5a372f27 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. @@ -145,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" diff --git a/module/FLASH_REVIEW_BLOCKED b/module/FLASH_REVIEW_BLOCKED new file mode 100644 index 00000000..188341e8 --- /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. diff --git a/module/arm_auto_recovery.sh b/module/arm_auto_recovery.sh new file mode 100644 index 00000000..1b2de1c9 --- /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" diff --git a/module/customize.sh b/module/customize.sh index 6c17bbd2..80b02176 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -1,104 +1,103 @@ #!/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. -# 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. -[ -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'" +if [ -z "${MODPATH:-}" ] || [ ! -d "$MODPATH" ]; then + abort "! MODPATH is empty or missing: '${MODPATH:-}'" fi -# We only support arm64 -if [ "$ARCH" != "arm64" ]; then +# 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 + +if [ "${ARCH:-}" != "arm64" ]; then abort "! Only arm64 is supported" fi -# Detect root manager +# 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 export_recovery_boot.sh; do + [ ! -f "$MODPATH/$_pn_tool" ] || set_perm "$MODPATH/$_pn_tool" 0 0 0755 +done -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" +# 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 export_recovery_boot.sh; do + if [ ! -x "$MODPATH/$_pn_tool" ]; then + abort "! Required physical-validation tool missing or not executable: $_pn_tool" + fi +done -# Copy binaries (single source: KernelPatch-Public) -ui_print "- Installing KernelPatch binaries..." +mkdir -p "$PNDIR" || abort "! Cannot create PatchNest state directory" +chmod 0700 "$PNDIR" 2>/dev/null || true -# 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 -if [ ! -x "$MODPATH/bin/kptools" ]; then - abort "! kptools binary missing or not executable in $MODPATH/bin" +if [ -f "$MODPATH/repos.json" ]; then + cp "$MODPATH/repos.json" "$PNDIR/repos.json" || abort "! Cannot install repos.json" 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 -[ -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 +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 -# Copy environment detection script -cp -f "$MODPATH/detect_env.sh" "$MODDIR/detect_env.sh" 2>/dev/null || true +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:" -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. Click 'Start' to patch kernel" -ui_print " 5. Reboot again to activate" +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" diff --git a/module/device_validation.sh b/module/device_validation.sh new file mode 100644 index 00000000..75d6f6b3 --- /dev/null +++ b/module/device_validation.sh @@ -0,0 +1,288 @@ +#!/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 +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} +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 -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/" +} + +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 diff --git a/module/export_recovery_boot.sh b/module/export_recovery_boot.sh new file mode 100644 index 00000000..ce177670 --- /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.$$" </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" diff --git a/module/patch/boot_extract.sh b/module/patch/boot_extract.sh index 904f64cd..fbacbd99 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 diff --git a/module/patch/boot_patch.sh b/module/patch/boot_patch.sh index a8558d5f..4c5048f7 100644 --- a/module/patch/boot_patch.sh +++ b/module/patch/boot_patch.sh @@ -1,427 +1,307 @@ #!/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] ####################################################################################### 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" - -BOOTIMAGE=$1 -FLASH_TO_DEVICE=$2 +# shellcheck disable=SC1091 +. "$MODPATH/flash_safety.sh" +# shellcheck disable=SC1091 +. "$MODPATH/superkey_safety.sh" +# shellcheck disable=SC1091 +. "$MODPATH/transactional_flash.sh" + +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; } - -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 +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 -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 +KPIMG_SOURCE="$MODULE_DIR/bin/kpimg" +[ -s "$KPIMG_SOURCE" ] || KPIMG_SOURCE="$INVOCATION_CWD/kpimg" +[ -s "$KPIMG_SOURCE" ] || { >&2 echo "! kpimg missing or empty"; exit 1; } -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 +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 -# ============================================================ -# 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 +json_escape() { + printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' +} - # 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 +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" +} - # 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 +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 +} - # 4. APatch: /data/adb/ap - if [ -d /data/adb/ap ] || ls /data/adb/ap >/dev/null 2>&1; then - KP_STATE="apatch" - fi +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" + cat "$BOOT_TARGET" > "$BACKUP_CANDIDATE" || return 1 + sync + [ -s "$BACKUP_CANDIDATE" ] || return 1 + validate_boot_image "$BACKUP_CANDIDATE" || { + >&2 echo "! Backup validation failed; refusing to patch" + 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" + 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 - # 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" + _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 - # 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 + _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 - # 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" < "$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; } + [ "$(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; + } + _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; } } -# 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) + _pn_key_sha=$(patchnest_superkey_sha256) || return 1 + mkdir -p "$PNDIR" || return 1 + umask 077 + 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" - exit 1 -fi +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; } -if [ "$SHOULD_BACKUP" -eq 0 ] && [ "$KP_REBACKUP" = "1" ]; then - echo "- KP_REBACKUP=1: forcing fresh backup" - SHOULD_BACKUP=1 -fi +# 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 -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 -fi +echo "- Unpacking current boot image into private workspace" +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 [ "$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" +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 ! kptools -i kernel -f 2>/dev/null | grep -q 'CONFIG_KALLSYMS_ALL=y'; then + >&2 echo "! CONFIG_KALLSYMS_ALL is required" + 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 +validate_embedded_kpms "$@" || exit 1 -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 +if [ "$FLASH_TO_DEVICE" = "true" ]; then + write_verified_backup || exit 1 fi +mv kernel kernel.ori 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" - 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 "$BOOTIMAGE" >/dev/null 2>&1; then - >&2 echo "! Repack error" - 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_boot_image "$WORKDIR/new-boot.img" || { + >&2 echo "! Repacked boot image failed validation"; exit 1; +} 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 + # 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 "! 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 - echo "- Successfully Flashed!" + if ! write_flash_receipt "$WORKDIR/new-boot.img"; then + >&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 transaction" else - save_image_to_storage "new-boot.img" - echo "- Successfully Patched!" + _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 diff --git a/module/patch/boot_unpatch.sh b/module/patch/boot_unpatch.sh index 8bb4f259..fb3e513c 100644 --- a/module/patch/boot_unpatch.sh +++ b/module/patch/boot_unpatch.sh @@ -1,142 +1,179 @@ #!/system/bin/sh ####################################################################################### -# APatch Boot Image Unpatcher -# Imported from https://github.com/bmax121/APatch/blob/main/app/src/main/assets/boot_unpatch.sh +# PatchNest transaction-bound boot restorer +####################################################################################### +# Usage: +# 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%/*} -ARCH=$(getprop ro.product.cpu.abi) PNDIR="/data/adb/patchnest" BACKUP_DIR="$PNDIR/backup" AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" +RESTORE_RECEIPT="$PNDIR/last_restore.json" -# Load utility functions +# shellcheck disable=SC1091 . "$MODPATH/util_functions.sh" +# shellcheck disable=SC1091 +. "$MODPATH/flash_safety.sh" + +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 2; } +[ -e "$BOOTIMAGE" ] || { >&2 echo "! $BOOTIMAGE does not exist"; exit 2; } +BOOT_TARGET=$(readlink -f "$BOOTIMAGE" 2>/dev/null || printf '%s' "$BOOTIMAGE") + +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 helper unavailable"; exit 1; +} -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 - - command -v flash_image >/dev/null 2>&1 || { - >&2 echo "! auto_unpatch: flash_image function not available" - return 2 +BOUND_BACKUP='' +BOUND_BACKUP_SHA='' +BOUND_BACKUP_SIZE='' +BOUND_PATCHED_SHA='' +BOUND_PATCHED_SIZE='' +BOUND_DEVICE_SHA='' +BOUND_TARGET='' + +resolve_bound_backup() { + _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; + } + [ "$(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; } - if [ ! -d "$BACKUP_DIR" ]; then - >&2 echo "! auto_unpatch: backup dir not found: $BACKUP_DIR" - 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" - 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" + case "$_pn_backup_name" in + boot_backup_*.img) ;; + *) >&2 echo "! Unsafe rollback backup name"; return 5 ;; + esac + case "$_pn_backup_name" in + */*|*..*) >&2 echo "! Unsafe rollback backup path"; return 5 ;; + esac + + 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 + + _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 + } return 0 } -[ -e "$BOOTIMAGE" ] || { echo "- $BOOTIMAGE does not exist!"; exit 1; } - -echo "- Target image: $BOOTIMAGE" +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 || { echo "- Command magiskboot not found!"; exit 1; } -command -v kptools >/dev/null 2>&1 || { echo "- Command kptools not found!"; exit 1; } +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 +} -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 +resolve_bound_backup || exit $? -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 - -else - echo "- no need unpatch" - exit 0 +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 -if [ -f "new-boot.img" ]; then - echo "- Flashing 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 -fi +echo "- restore: transaction-bound backup: $BOUND_BACKUP" +echo "- restore: target: $BOOT_TARGET" +restore_exact_backup_with_retry || exit 8 + +# 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 + exit 9 +} -echo "- Flash successful" +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 -# Reset any error code -true +echo "- restore: exact transaction-bound rollback verified" +exit 0 diff --git a/module/patch/flash_safety.sh b/module/patch/flash_safety.sh new file mode 100644 index 00000000..428ac683 --- /dev/null +++ b/module/patch/flash_safety.sh @@ -0,0 +1,174 @@ +#!/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. + +# 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 + _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" +} diff --git a/module/patch/superkey_safety.sh b/module/patch/superkey_safety.sh new file mode 100644 index 00000000..e4593f0a --- /dev/null +++ b/module/patch/superkey_safety.sh @@ -0,0 +1,194 @@ +#!/system/bin/sh +# PatchNest Public1158 superkey lifecycle. +# 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 + _pn_len=${#_pn_key} + [ "$_pn_len" -ge 32 ] || return 1 + [ "$_pn_len" -le 63 ] || return 1 + 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_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 + [ ! -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; } + 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 secure root-owned mode 0600" + return 1 + } + # 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_existing + export PATCHNEST_SUPERKEY + return 0 + fi + + # 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 + } + + 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" || return 1 + + 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 + _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() { + [ "$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 + + if [ "$PATCHNEST_SUPERKEY_IS_NEW" -eq 0 ]; then + _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 + fi + return 0 + fi + + 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 + return 1 + } + [ "$_pn_committed" = "$PATCHNEST_SUPERKEY" ] || { + patchnest_revert_new_key_to_pending + return 1 + } + + 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 + + return 0 +} + +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" + _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" +} diff --git a/module/patch/transaction_safety.sh b/module/patch/transaction_safety.sh new file mode 100644 index 00000000..0731a3cc --- /dev/null +++ b/module/patch/transaction_safety.sh @@ -0,0 +1,307 @@ +#!/system/bin/sh +# 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_BACKUP_DIR="${PATCHNEST_BACKUP_DIR:-/data/adb/patchnest/backup}" + +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 -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/" +} + +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() { + _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 + 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_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_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 + [ -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 + 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) + _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 + + 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 + + patchnest_write_binding_record "$BOOT_TARGET" "$_pn_device_sha" "$_pn_backup_name" \ + "$_pn_backup_sha" "$_pn_patched_sha" "$_pn_patched_size" "$_pn_key_sha" false || return 1 + if [ "${FLASH_TO_DEVICE:-false}" = "true" ] && ! patchnest_clear_pending_transaction; then + rm -f "$PATCHNEST_ROLLBACK_BINDING_FILE" + return 1 + fi + patchnest_clear_recovery_required || true + return 0 +} + +# Complete the crash window after service.sh has proven that the pending key +# authenticates the running Public1158 kernel and that the pending transaction's +# exact patched bytes/device identity still match. This rebuilds rollback +# authorization without needing the vanished private patch workspace. +patchnest_commit_binding_from_pending_written() { + _pn_key=$1 + patchnest_pending_transaction_matches_written_key "$_pn_key" || 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_backup_name=$(patchnest_json_string rollback_backup "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_backup_sha=$(patchnest_json_string rollback_backup_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_patched_sha=$(patchnest_json_string patched_image_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_patched_size=$(patchnest_json_number patched_image_size "$PATCHNEST_PENDING_TRANSACTION_FILE") + _pn_key_sha=$(patchnest_json_string superkey_sha256 "$PATCHNEST_PENDING_TRANSACTION_FILE") + case "$_pn_backup_name" in boot_backup_*.img) ;; *) return 1 ;; esac + case "$_pn_backup_name" in */*|*..*) return 1 ;; esac + + _pn_backup="$PATCHNEST_BACKUP_DIR/$_pn_backup_name" + [ -f "$_pn_backup" ] || return 1 + [ "$(patchnest_hash_file "$_pn_backup")" = "$_pn_backup_sha" ] || return 1 + + patchnest_write_binding_record "$_pn_target" "$_pn_device" "$_pn_backup_name" \ + "$_pn_backup_sha" "$_pn_patched_sha" "$_pn_patched_size" "$_pn_key_sha" true || return 1 + if ! patchnest_clear_pending_transaction; then + rm -f "$PATCHNEST_ROLLBACK_BINDING_FILE" + return 1 + fi + patchnest_clear_recovery_required || true + return 0 +} + +patchnest_remove_rollback_binding() { + rm -f "$PATCHNEST_ROLLBACK_BINDING_FILE" +} diff --git a/module/patch/transactional_flash.sh b/module/patch/transactional_flash.sh new file mode 100644 index 00000000..240e95a3 --- /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 +} diff --git a/module/service.sh b/module/service.sh index ded5a43c..6a24ed34 100644 --- a/module/service.sh +++ b/module/service.sh @@ -4,168 +4,252 @@ 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" +BOOT_COUNT_FILE="$PNDIR/boot_count" +AUTO_UNPATCH_REQUEST="$PNDIR/auto_unpatch_requested" +AUTORECOVERY_MARKER="$PNDIR/autorecovery_active" -# 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 +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 ;; + 0|false) KPM_SIGNATURE_POLICY=off ;; + 1|true|yes|on) KPM_SIGNATURE_POLICY=strict ;; + *) 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 ;; + 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 +# shellcheck disable=SC1091 +. "$MODDIR/patch/superkey_safety.sh" 2>/dev/null || true +# shellcheck disable=SC1091 +. "$MODDIR/patch/transaction_safety.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 - ROOT_MGR="$_rm_sane" - fi + [ -z "$_rm_sane" ] || ROOT_MGR="$_rm_sane" 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".) +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 + [ ! -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=$? + 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 + + 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 + + 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" + _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" + hello_out="hello1158" + hello_rc=0 + 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 -while [ $retries -lt $max_retries ]; do - if kpatch hello >/dev/null 2>&1; then +hello_out="" +hello_rc=1 +while [ "$retries" -lt "$max_retries" ]; do + hello_out="$(kpatch hello 2>>"$LOG")" + 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 -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" - touch "$MODDIR/unresolved" - exit 0 + +if [ "$hello_rc" -ne 0 ] || [ -z "$hello_out" ]; then + 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 -echo "[$(date)] kpatch hello OK" >> "$LOG" -# Bootloop Auto-Recovery: healthy boot detected — reset the counter -# and clear any auto-recovery markers so we don't trigger unpatch. -echo "0" > "$PNDIR/boot_count" 2>/dev/null -rm -f "$PNDIR/autorecovery_active" "$PNDIR/auto_unpatch_requested" +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" +# IMPORTANT: hello does not prove a healthy Android boot. boot_count and +# autorecovery markers are cleared only after sys.boot_completed=1 below. -# 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 + 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")" @@ -174,7 +258,12 @@ for kpm in "$KPM_DIR"/*.kpm "$KPM_DIR"/*.ko "$KPM_DIR"/*.o; do fi fi - if ! kpatch kpm load "$kpm" -- "$args"; then + 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,59 +271,78 @@ 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 [ "$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 + 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" + 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 for boot completion (with 5 min timeout to avoid infinite loop on broken ROMs) 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, continuing anyway" >> "$LOG" + echo "[$(date)] ERROR: boot_completed timeout; failed-boot counter intentionally retained" >> "$LOG" + touch "$MODDIR/unresolved" break fi done -dispatch_event "BOOT_COMPLETED" +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 -# 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 @@ -242,6 +350,7 @@ if [ -f "$CONFIG" ]; then done < "$_cfg_tmp" rm -f "$_cfg_tmp" echo "[$(date)] exclusion: applied=$excluded_count failed=$excluded_failed" >> "$LOG" + [ "$excluded_failed" -eq 0 ] || touch "$MODDIR/unresolved" fi -echo "[$(date)] service.sh completed" >> "$LOG" +echo "[$(date)] service.sh completed boot_completed=$boot_completed" >> "$LOG" diff --git a/module/verify_auto_recovery.sh b/module/verify_auto_recovery.sh new file mode 100644 index 00000000..3d0dfa93 --- /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" diff --git a/scripts/device_validation.sh b/scripts/device_validation.sh new file mode 100644 index 00000000..a57b67a2 --- /dev/null +++ b/scripts/device_validation.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Source-tree convenience wrapper. The canonical, packaged physical-device +# validation harness lives at module/device_validation.sh. + +set -eu +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +exec "$ROOT/module/device_validation.sh" "$@" diff --git a/scripts/package_module.sh b/scripts/package_module.sh new file mode 100644 index 00000000..a91359c1 --- /dev/null +++ b/scripts/package_module.sh @@ -0,0 +1,69 @@ +#!/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 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"; } +trap cleanup EXIT HUP INT TERM + +mkdir -p "$STAGE/module" +cp -a "$SOURCE_DIR/." "$STAGE/module/" +find "$STAGE/module" -exec touch -h -t 200001010000.00 {} + + +mkdir -p "$(dirname "$OUTPUT_ABS")" +rm -f "$OUTPUT_ABS" + +( + cd "$STAGE/module" + 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; } + zip -X -q "$OUTPUT_ABS" -@ < "$STAGE/file-list" +) + +[ -s "$OUTPUT_ABS" ] || { echo "deterministic package is empty" >&2; exit 1; } +unzip -Z1 "$OUTPUT_ABS" > "$STAGE/zip-list" +for required in \ + module.prop \ + FLASH_REVIEW_BLOCKED \ + customize.sh \ + service.sh \ + 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 \ + 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 + +if grep -Eq '(^|/)\.\.(/|$)|^\./' "$STAGE/zip-list"; then + echo "unsafe or non-canonical path found in module ZIP" >&2 + exit 1 +fi diff --git a/tests/bootloop_recovery_contract.sh b/tests/bootloop_recovery_contract.sh new file mode 100644 index 00000000..1583b692 --- /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" diff --git a/tests/destructive_transaction_contract.sh b/tests/destructive_transaction_contract.sh new file mode 100644 index 00000000..25238235 --- /dev/null +++ b/tests/destructive_transaction_contract.sh @@ -0,0 +1,275 @@ +#!/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" +export PATCHNEST_BACKUP_DIR="$TMP" +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" "$PATCHNEST_SUPERKEY_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 on a destructive path MUST require and consume that exact +# state=written transaction. +BOOT_TARGET="$TARGET" +BACKUP_CANDIDATE="$BACKUP" +WORKDIR="$TMP/work" +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" + +# 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='' +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" + +# 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" + +echo "destructive transaction contract: PASS" diff --git a/tests/flash_safety_contract.sh b/tests/flash_safety_contract.sh new file mode 100644 index 00000000..5d1924a6 --- /dev/null +++ b/tests/flash_safety_contract.sh @@ -0,0 +1,222 @@ +#!/bin/sh +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" +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 + +fail() { + echo "flash safety contract: FAIL: $*" >&2 + exit 1 +} + +# 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 '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" + +for file in "$PATCH" "$UNPATCH" "$EXTRACT"; do + grep -Fq 'flash_safety.sh' "$file" || fail "$(basename "$file") does not source flash_safety" +done + +grep -Fq -- '--restore-bound-backup' "$UNPATCH" || fail "bound restore entry point missing" +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_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 + +# 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 +. "$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" + +# 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" + 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" +) + +# 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" + 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 + [ ! -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" + [ -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 the already-written kernel credential remains recoverable. +( + 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_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" + 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" +) + +# 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 a real destructive rollback record. A state=written +# 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" +PATCHNEST_DEVICE_IDENTITY='unit-test-device-A' +PATCHNEST_SUPERKEY='0123456789abcdef0123456789abcdef0123456789abcdef' +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" +BACKUP_CANDIDATE="$TMP/boot_backup_20260808T000000Z_TEST.img" +WORKDIR="$TMP/transaction-work" +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_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 "$BOOT_TARGET") +PATCHNEST_DEVICE_IDENTITY='unit-test-device-B' +export PATCHNEST_DEVICE_IDENTITY +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" diff --git a/tests/installer_contract.sh b/tests/installer_contract.sh new file mode 100644 index 00000000..8dd03f9d --- /dev/null +++ b/tests/installer_contract.sh @@ -0,0 +1,116 @@ +#!/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 + +! 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 export_recovery_boot.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() { chmod "$4" "$1"; } + set_perm_recursive() { + _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 + [ "$(stat -c '%a' "$_pn_mod/export_recovery_boot.sh")" = "755" ] || return 96 + [ ! -e "$_pn_mod/module.prop.bak" ] || return 97 + return 0 +} + +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" + +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" diff --git a/tests/recovery_export_contract.sh b/tests/recovery_export_contract.sh new file mode 100644 index 00000000..f0b435ec --- /dev/null +++ b/tests/recovery_export_contract.sh @@ -0,0 +1,76 @@ +#!/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 +} + +# 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" +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" diff --git a/tests/runtime_abi_contract.sh b/tests/runtime_abi_contract.sh new file mode 100644 index 00000000..045e5df8 --- /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 '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" +[ "$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 '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 +# 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" diff --git a/version.properties b/version.properties index 224353c2..ccf8788d 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=7fed93c4e259a6edf191c1a9900874babb232c4b + +# 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 diff --git a/webui/page/patch.js b/webui/page/patch.js index 4e327cee..cf281760 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