diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c6670ff..3cb04307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: Source and Release Contracts on: push: - branches: [main, 'feature/**', 'hotfix/**'] + branches: [main, 'feature/**', 'hotfix/**', 'security/r776-pinned-root-read-qa', 'prep/r803-forward-156-native-gates', 'prep/r805-windows-156', 'prep/r846-forward-157-native-gates'] tags: ['v*'] pull_request: branches: [main] @@ -18,113 +18,426 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Python syntax - run: python3 -m py_compile build_linux_package.py linux/app/direct_internet_method.py linux/system/selective_dns.py linux/app/router_gateway.py tests/selective_dns_integration_safety_contract.py tests/selective_dns_monitor_lock_contract.py tests/selective_dns_full_lifecycle_mock.py tests/selective_dns_bindings_contract.py tests/selective_dns_backstop_contract.py tests/selective_dns_backstop_runtime_mock.py tests/linux_contract_audit.py tests/linux_user_update_transaction_contract.py tests/linux_real_extracted_archive_contract.py linux/app/user_update_transaction.py linux/app/user_uninstall_preserve.py tests/adult_coverage_data_audit.py tests/router_gateway_contract_audit.py tests/router_gateway_runtime_smoke.py tests/rejected_component_audit.py tests/linux_installed_152_lifecycle.py tests/tailscale_coexistence_contract.py tests/tailscale_ui_state_contract.py tests/tailscale_gui_scope_guard_contract.py tests/tailscale_rustdesk_transport_audit.py tests/tailscale_nft_ownership_contract.py tests/tailscale_cleanup_ownership_contract.py tests/tailscale_recovery_missing_state_contract.py tests/nft_presence_fail_closed_contract.py tests/tailscale_transient_rollback_ownership_contract.py tests/linux_nft_kernel_namespace_contract.py tests/linux_mutation_action_lock_contract.py tests/linux_rollback_exit_integrity_contract.py - - name: Shell syntax and LF guard + - name: Candidate source syntax and shell safety shell: bash run: | set -euo pipefail - while IFS= read -r f; do - if grep -q $'\r' "$f"; then - echo "CRLF detected in $f" >&2 + python3 -m compileall -q build_linux_package.py linux/app linux/system tests + while IFS= read -r path; do + if grep -q $'\r' "$path"; then + echo "CRLF in candidate shell: $path" >&2 exit 1 fi - bash -n "$f" - done < <(find linux -type f -name '*.sh' -print | sort) - - name: Linux and Router Gateway contract audits - run: | - python3 tests/linux_contract_audit.py - python3 tests/linux_user_update_transaction_contract.py - python3 tests/tailscale_coexistence_contract.py - python3 tests/selective_dns_optin_contract.py - python3 tests/selective_dns_isolated_runtime.py - python3 tests/selective_dns_restore_mock_contract.py - python3 tests/selective_dns_gui_optin_contract.py - python3 tests/selective_dns_integration_safety_contract.py - python3 tests/selective_dns_monitor_lock_contract.py - python3 tests/selective_dns_full_lifecycle_mock.py - python3 tests/selective_dns_bindings_contract.py - python3 tests/selective_dns_backstop_contract.py - python3 tests/selective_dns_backstop_runtime_mock.py - python3 tests/tailscale_ui_state_contract.py - python3 tests/tailscale_gui_scope_guard_contract.py - python3 tests/tailscale_rustdesk_transport_audit.py --selftest - python3 tests/tailscale_nft_ownership_contract.py - python3 tests/tailscale_cleanup_ownership_contract.py - python3 tests/tailscale_recovery_missing_state_contract.py - python3 tests/nft_presence_fail_closed_contract.py - python3 tests/tailscale_transient_rollback_ownership_contract.py - python3 tests/linux_nft_kernel_namespace_contract.py --source-only - python3 tests/linux_mutation_action_lock_contract.py - python3 tests/linux_rollback_exit_integrity_contract.py - python3 tests/adult_coverage_data_audit.py - python3 tests/router_gateway_contract_audit.py - python3 tests/release_metadata_audit.py - python3 tests/release_publication_negative_regression.py - python3 tests/unsigned_public_release_contract.py --fixtures - python3 tests/rejected_component_audit.py - - name: Isolated real-kernel nftables contract - shell: bash - run: | - set -euo pipefail - # Reject direct execution against the host network namespace. - # The contract checks an empty isolated loopback-only namespace. - command -v unshare - command -v ip - if ! command -v nft >/dev/null 2>&1; then - sudo apt-get update -qq - sudo apt-get install -y nftables - fi - sudo -n python3 tests/linux_nft_kernel_namespace_contract.py --kernel - - name: Build Linux archive + bash -n "$path" + done < <(find linux -type f -name '*.sh' | sort) + - name: Build deterministic Linux 1.5.7 private source package run: python3 build_linux_package.py - - name: Validate real extracted ZIP transaction and user-data rollback - run: python3 -B tests/linux_real_extracted_archive_contract.py - - name: Verify exact Linux candidate artifact digest + - name: Exact candidate SHA and non-release authority gate shell: bash run: | set -euo pipefail python3 - <<'PY' import hashlib,json,pathlib,zipfile - recorded=json.loads(pathlib.Path("RELEASE.json").read_text(encoding="utf-8")) - old=recorded["platforms"]["linux"] - # RELEASE.json pins the R467 unpublished new candidate artifact; - # a local build alone is NOT a public installed acceptance or permission to publish. - assert old["sha256"].upper()=="4784FCD44ED3D1B40103BCA140B2C0066784F794D07C3AE1F525334766A553C7" - assert recorded["version"]=="1.5.4" and recorded["status"]=="CANDIDATE_V154_UNPUBLISHED_UNPROVEN" - p=pathlib.Path(old["artifact"]) - assert p.is_file(), "NEW_CANDIDATE_ARCHIVE_MISSING" - actual=hashlib.sha256(p.read_bytes()).hexdigest().upper() - expected="4784FCD44ED3D1B40103BCA140B2C0066784F794D07C3AE1F525334766A553C7" - assert actual==expected, f"NEW_LINUX_CANDIDATE_SHA_MISMATCH {actual} != {expected}" - assert p.stat().st_size==8270220, "NEW_LINUX_CANDIDATE_BYTES_WRONG" - assert actual==old["sha256"].upper(), "R467_CANDIDATE_SOURCE_DIGEST_MISMATCH" + p=pathlib.Path("delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip") + expected="F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6" + assert p.is_file() and p.stat().st_size==8427156 + assert hashlib.sha256(p.read_bytes()).hexdigest().upper()==expected + meta=json.loads(pathlib.Path("RELEASE.json").read_text()) + assert meta.get("version")=="1.5.4","PUBLISHED_RELEASE_METADATA_MUST_REMAIN_STABLE" with zipfile.ZipFile(p) as z: - assert z.testzip() is None and len(z.namelist())==24 - meta_name=next(x for x in z.namelist() if x.endswith("PACKAGE_MANIFEST.json")) - manifest=json.loads(z.read(meta_name)) - assert len(manifest["files"])==22 - prefix=meta_name[:-len("PACKAGE_MANIFEST.json")] - for row in manifest["files"]: - data=z.read(prefix+row["file"]) - assert len(data)==row["bytes"] - assert hashlib.sha256(data).hexdigest().upper()==row["sha256"] - print("PASS R486 VERSIONED DOTNET10 LINUX CANDIDATE, V152 PUBLIC HISTORICAL UNCHANGED", actual) + root="DirectInternetMethod_1.5.7_Linux_x86_64/" + assert len(z.namelist())==30 and len(z.namelist())==len(set(z.namelist())) + j=json.loads(z.read(root+"PACKAGE_MANIFEST.json")) + assert j["version"]=="1.5.7" and len(j["files"])==28 + for x in j["files"]: + data=z.read(root+x["file"]) + assert len(data)==x["bytes"] + assert hashlib.sha256(data).hexdigest().upper()==x["sha256"] + assert z.testzip() is None + print("R520_EXACT_155RC1_PACKAGE_AND_PUBLIC_V154_AUTHORITY_PASS",expected) PY - - name: Validate shell syntax inside built archive + - name: Negative-first R519 candidate package and protected upgrade + run: | + set -euo pipefail + python3 -B tests/r664_direct_update_monotonicity_contract.py + python3 -B tests/r519_versioned_package_contract.py + python3 -B tests/protected_upgrade_transaction_contract.py + python3 -B tests/r789_receipt_replay_synthetic_probe.py + python3 -B tests/r803_dual_baseline_forward_contract.py + python3 -B tests/r803_user_from_rc1_forward_contract.py + python3 -B tests/r519_user_update_transaction_contract.py + - name: R526 Linux installer metadata activation (hermetic fake Root) + shell: bash + run: | + set -euo pipefail + python3 -B tests/r526_installer_metadata_contract.py + - name: R527 privileged Root files on disposable GitHub Ubuntu VM + shell: bash + run: | + set -euo pipefail + # Real UID 0 on a fresh hosted VM; product writes only inside test-owned workspace fixture. + # The production Root CLI and real host network/systemd are never invoked. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r527_privileged_root_transaction_contract.py + - name: R517 encrypted DNS and Tailscale negative-first contracts + run: | + set -euo pipefail + python3 -B tests/encrypted_selective_dns_policy_contract.py + python3 -B tests/encrypted_selective_dns_controller_contract.py + python3 -B tests/r762_trusted_dns_system_binary_origin_contract.py + python3 -B tests/r764_parent_path_security_contract.py + python3 -B tests/r766_systemd_exec_origin_contract.py + python3 -B tests/r767_consent_parent_nofollow_contract.py + python3 -B tests/r769_root_file_descriptor_boundary_contract.py + python3 -B tests/r795_leaf_filename_contract.py + python3 -B tests/r771_fifo_special_file_failclosed_contract.py + python3 -B tests/r776_checked_fd_swap_regression.py + python3 -B tests/r778_stage_backup_snapshot_race_contract.py + python3 -B tests/r779_root_guard_source_copy_identity_contract.py + python3 -B tests/r781_privileged_entry_contract.py + python3 -B tests/encrypted_selective_dns_fake_lifecycle.py + python3 -B tests/encrypted_selective_dns_gui_consent_contract.py + python3 -B tests/r537_gui_consent_status_contract.py + python3 -B tests/r538_active_encrypted_status_contract.py + python3 -B tests/r541_route_interface_drift_contract.py + python3 -B tests/r545_periodic_gui_status_contract.py + python3 -B tests/r678_linux_gui_full_youtube_health_contract.py + python3 -B tests/r735_linux_live_core_truth_contract.py + python3 -B tests/r739_linux_update_cache_lifecycle_contract.py + python3 -B tests/r747_optin_local_socket_fallback_contract.py + python3 -B tests/r747_gui_local_socket_optin_contract.py + python3 -B tests/r698_root_watchdog_firstsplit_integrity_contract.py + python3 -B tests/r702_nfqws_reinjected_mark_contract.py + python3 -B tests/r709_installer_no_bytecode_cache_contract.py + python3 -B tests/r711_tls_syn_fastpath_contract.py + python3 -B tests/r742_nfqws_conntrack_syn_first_packet_contract.py + python3 -B tests/tailscale_nft_ownership_contract.py + python3 -B tests/tailscale_cleanup_ownership_contract.py + python3 -B tests/tailscale_transient_rollback_ownership_contract.py + python3 -B tests/linux_nft_kernel_namespace_contract.py --source-only + python3 -B tests/r552_native_upgrade_guard_contract.py + python3 -B tests/tailscale_ui_state_contract.py + python3 -B tests/tailscale_gui_scope_guard_contract.py + python3 -B tests/encrypted_selective_dns_integration_contract.py + python3 -B tests/tailscale_coexistence_contract.py + python3 -B tests/selective_dns_optin_contract.py + - name: Package embedded shell and source-mode integrity shell: bash run: | set -euo pipefail d="$(mktemp -d)" trap 'rm -rf "$d"' EXIT - unzip -q delivery/DirectInternetMethod_1.5.4_Linux_x86_64.zip -d "$d" + unzip -q delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip -d "$d" while IFS= read -r f; do + bash -n "$f" if grep -q $'\r' "$f"; then - echo "CRLF detected in packaged shell: $f" >&2 + echo "UNSAFE_PACKAGE_CRLF:$f" >&2 exit 1 fi - bash -n "$f" - done < <(find "$d" -type f -name '*.sh' -print | sort) + done < <(find "$d" -type f -name '*.sh' | sort) + + linux-native-root-ephemeral: + name: linux-native-root-ephemeral + needs: linux-source + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Rebuild exact RC1 package + run: python3 build_linux_package.py + - name: Download byte-pinned published Stable v1.5.4 package + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R528 true root CLI stage apply rollback on disposable VM + shell: bash + run: | + set -euo pipefail + # This job starts with an empty DIM root and cannot execute on a personal machine. + # All absolute-root writes occur inside the disposable GH-hosted Ubuntu VM only. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r528_ephemeral_native_root_cli_contract.py + + - name: R702 strict NFT ownership on disposable kernel network namespace + shell: bash + run: | + set -euo pipefail + # The test validates root and real nft inside a fresh unshare --net + # child; it rejects host netns and never edits the runner's routes. + sudo -n /usr/bin/python3 -B tests/linux_nft_kernel_namespace_contract.py --kernel + + linux-root-user-integrated-ephemeral: + name: linux-root-user-integrated-ephemeral + needs: linux-native-root-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build and independently pin candidate package + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + - name: Download and independently pin official Stable 1.5.4 package + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R529 actual Root and unprivileged User transaction with rollback + shell: bash + run: | + set -euo pipefail + # Fresh GitHub VM only; no real host services, DNS, GUI or Tailscale touched. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r529_ephemeral_root_user_transaction.py + + linux-independent-root-watchdog-ephemeral: + name: linux-independent-root-watchdog-ephemeral + needs: linux-root-user-integrated-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Rebuild exact candidate Linux archive and pin identity + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + - name: Download byte-pinned published Stable v1.5.4 archive + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: Arm independent root-owned systemd watchdog before two-sided mutation + shell: bash + run: | + set -euo pipefail + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r532_ephemeral_root_watchdog_contract.py arm + - name: Independent process verifies timed root and user rollback after arm exits + shell: bash + run: | + set -euo pipefail + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r532_ephemeral_root_watchdog_contract.py verify + + linux-rc1-native-guard-ephemeral: + name: linux-rc1-native-guard-ephemeral + needs: linux-independent-root-watchdog-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R554 byte-pinned exact 27-file Linux candidate + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + - name: R554 download exact public v1.5.4 baseline + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R554 native root-owned systemd guard then exact two-sided rollback + shell: bash + run: | + set -euo pipefail + # Disposable hosted GitHub Ubuntu VM only, no human host mutations. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r554_native_guard_ephemeral_contract.py + + linux-rc1-native-wrapper-ephemeral: + name: linux-rc1-native-wrapper-ephemeral + needs: linux-rc1-native-guard-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R555 rebuild exact byte-pinned Linux RC1 + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + - name: R555 pin the official stable baseline from its immutable release + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R555 native production upgrade wrapper arms actual two-sided Root User rollback + shell: bash + run: | + set -euo pipefail + # A DIFFERENT, empty GitHub-hosted Ubuntu VM; never production host. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" DIM_R555_EXACT_WRAPPER=true /usr/bin/python3 -B tests/r554_native_guard_ephemeral_contract.py + + linux-rc1-accepted-finalization-ephemeral: + name: linux-rc1-accepted-finalization-ephemeral + needs: linux-rc1-native-guard-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R562 build exact guarded 27-file RC1 + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + - name: R562 download exact official v1.5.4 installed baseline + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R562 synthetic root acceptance interruption and independent user finalization + shell: bash + run: | + set -euo pipefail + # TEST-ONLY fabricated acceptance fixture on an EMPTY disposable VM. + # This is a transaction-crash regression, never owner/live approval. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" DIM_R555_EXACT_WRAPPER=true DIM_R562_SYNTHETIC_PROMOTION=true /usr/bin/python3 -B tests/r554_native_guard_ephemeral_contract.py + + linux-rc1-accepted-drift-failclosed-ephemeral: + name: linux-rc1-accepted-drift-failclosed-ephemeral + needs: linux-rc1-accepted-finalization-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R565 build exact guarded 27-file RC1 + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + - name: R565 download exact official v1.5.4 installed baseline + shell: bash + run: | + set -euo pipefail + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R565 synthetic accepted Root rejects concurrent mutable User drift + shell: bash + run: | + set -euo pipefail + # TEST-ONLY fabricated acceptance fixture on an EMPTY disposable VM. + # NEGATIVE: exact root-owned service MUST fail closed, retain both slots, + # and never rollback an already COMMITTED synthetic Root. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" DIM_R555_EXACT_WRAPPER=true DIM_R562_SYNTHETIC_PROMOTION=true DIM_R565_SYNTHETIC_MUTABLE_DRIFT=true /usr/bin/python3 -B tests/r554_native_guard_ephemeral_contract.py + + linux-r566-calendar-catchup-ephemeral: + name: linux-r566-calendar-catchup-ephemeral + needs: linux-source + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R566 actual systemd Persistent calendar expiry during inactive timer + shell: bash + run: | + set -euo pipefail + # Unique synthetic root-owned unit on an EMPTY GitHub-hosted VM only. + sudo -n /usr/bin/env GITHUB_ACTIONS=true CI=true RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_REPOSITORY="$GITHUB_REPOSITORY" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" GITHUB_RUN_ID="$GITHUB_RUN_ID" /usr/bin/python3 -B tests/r566_native_systemd_persistent_catchup.py + + linux-r567-denied-polkit-ephemeral: + name: linux-r567-denied-polkit-ephemeral + needs: linux-source + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R567 build exact private RC1, unchanged public stable + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R567 original production Polkit-denied upgrade preserves stable Root User + shell: bash + run: | + set -euo pipefail + # Empty GitHub-hosted Ubuntu only. The shell-level Polkit stub + # RETURNS 126 without opening authentication or touching network. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r567_hosted_denied_polkit_rollback.py + + linux-r568-retire-rolledback-ephemeral: + name: linux-r568-retire-rolledback-ephemeral + needs: linux-rc1-native-guard-ephemeral + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: R568 exact Linux RC1 package and official stable baseline + shell: bash + run: | + set -euo pipefail + python3 build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + test ! -e "$RUNNER_TEMP/DIM_R528_STABLE.zip" + curl --fail --show-error --silent --location --retry 3 --max-time 120 \ + 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Linux_x86_64.zip' \ + --output "$RUNNER_TEMP/DIM_R528_STABLE.zip" + printf '%s %s\n' '4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7' "$RUNNER_TEMP/DIM_R528_STABLE.zip" | sha256sum --check --status + - name: R568 actual guarded rollback and operator-receipt SHA retirement + shell: bash + run: | + set -euo pipefail + # Native root-owned service on disposable hosted Ubuntu only. + # Post-rollback operator attests exact receipt; unknown foreign + # guard files and mismatched hashes are refused without deletion. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" DIM_R555_EXACT_WRAPPER=true DIM_R568_TEST_RETIRE_ROLLED_BACK=true /usr/bin/python3 -B tests/r554_native_guard_ephemeral_contract.py + + linux-public-v154-regression: + name: linux-public-v154-regression + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: 51009372e3c2d069d7016b97773f35ee5e1e0631 + - name: Preserve original stable Linux contracts at exact tag + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "51009372e3c2d069d7016b97773f35ee5e1e0631" + python3 -B tests/linux_contract_audit.py + python3 -B tests/linux_user_update_transaction_contract.py + python3 -B tests/selective_dns_optin_contract.py + python3 -B tests/selective_dns_gui_optin_contract.py + python3 -B tests/selective_dns_integration_safety_contract.py + python3 -B tests/tailscale_coexistence_contract.py + python3 -B tests/release_metadata_audit.py + python3 build_linux_package.py + python3 -B tests/linux_real_extracted_archive_contract.py windows-source: name: windows-source @@ -154,6 +467,8 @@ jobs: $ErrorActionPreference='Stop' python -B tests/dotnet10_target_contract.py if($LASTEXITCODE -ne 0){throw 'R482_NET10_FRAMEWORK_CONTRACT_FAILED'} + python -B tests/r846_windows_157_forward_source_contract.py + if($LASTEXITCODE -ne 0){throw 'R846_WINDOWS_157_SOURCE_CONTRACT_FAILED'} - name: Verify published v1.5.4 documentation is current shell: pwsh run: | @@ -178,6 +493,24 @@ jobs: run: dotnet build windows/service/DirectInternetMethod.Service.csproj -c Release --nologo - name: Build GUI run: dotnet build windows/gui/DirectInternetMethod.csproj -c Release --nologo + - name: R057 Windows probe curl origin and argv hostile regression + shell: pwsh + run: | + $ErrorActionPreference='Stop' + dotnet run --project tests/probe_process_security/ProbeProcessSecurity.csproj -c Release --no-launch-profile + if($LASTEXITCODE -ne 0){throw 'R057_GUI_PROBE_SECURITY_REGRESSION_FAILED'} + - name: R061 Windows service system curl provenance negative fixtures + shell: pwsh + run: | + $ErrorActionPreference='Stop' + python -B tests/r061_windows_service_curl_contract.py + if($LASTEXITCODE -ne 0){throw 'R061_SERVICE_SYSTEM_CURL_SECURITY_REGRESSION_FAILED'} + - name: R062 hosted Windows native service downloader loopback and PATH-poison guard + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/R062-NativeServiceCurl.ps1 + if($LASTEXITCODE -ne 0){throw 'R062_NATIVE_SERVICE_CURL_LOOPBACK_TEST_FAILED'} - name: Restore pinned PowerShell runtime shell: pwsh run: pwsh.exe -NoProfile -ExecutionPolicy Bypass -File tests/prepare_powershell_runtime.ps1 @@ -207,6 +540,12 @@ jobs: if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } python tests/windows_contract_audit.py if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } + python -B tests/r685_windows_gui_real_youtube_contract.py + if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } + python -B tests/r720_windows_start_youtube_truth_contract.py + if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } + python -B tests/r726_windows_privileged_curl_provenance_contract.py + if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } python tests/adult_coverage_data_audit.py if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } python tests/router_gateway_contract_audit.py @@ -230,6 +569,185 @@ jobs: python -B tests/unsigned_public_release_contract_154.py --fixtures if($LASTEXITCODE -ne 0){throw 'R492_PUBLIC_V154_NEGATIVE_FIXTURES_FAILED'} + windows-private-r535-native: + name: windows-private-r535-native + if: github.event_name == 'push' && (github.ref == 'refs/heads/feature/r535-windows-155rc1' || github.ref == 'refs/heads/feature/r520-linux-155rc1-ci' || github.ref == 'refs/heads/security/r776-pinned-root-read-qa' || github.ref == 'refs/heads/prep/r805-windows-156' || github.ref == 'refs/heads/prep/r803-forward-156-native-gates') + needs: windows-source + runs-on: windows-2025 + timeout-minutes: 35 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: '10.0.x' + - name: R535 build exact private 1.5.6 Windows GUI service Inno installer + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/r535_windows_candidate_hosted_build.ps1 + if($LASTEXITCODE -ne 0){throw 'R535_PRIVATE_WINDOWS_NATIVE_BUILD_FAILED'} + - name: R535 hosted Windows v1.5.4 to v1.5.6 in-place upgrade preserve and rollback + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/r535_windows_native_rollback.ps1 + if($LASTEXITCODE -ne 0){throw 'R535_NATIVE_INSTALL_ROLLBACK_FAILED'} + - name: R600 attested tested private v1.5.6 installer for owner canary + shell: pwsh + run: | + $ErrorActionPreference='Stop' + $root=$env:GITHUB_WORKSPACE + $report=Get-Content (Join-Path $env:RUNNER_TEMP 'DIM_R535_NATIVE\BUILD_R535.json') -Raw|ConvertFrom-Json + $native=Get-Content (Join-Path $env:RUNNER_TEMP 'DIM_R535_NATIVE\NATIVE_R535.json') -Raw|ConvertFrom-Json + $installer=Join-Path $root 'delivery\DirectInternetMethod_1.5.6_Windows_Setup.exe' + if($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:GITHUB_REF -cnotin @('refs/heads/feature/r520-linux-155rc1-ci','refs/heads/security/r776-pinned-root-read-qa','refs/heads/prep/r805-windows-156','refs/heads/prep/r803-forward-156-native-gates') -or + (git -C $root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R600_HOST_AUTHORITY'} + if($report.status -cne 'PASS_ISOLATED_HOSTED_V156_BUILD' -or + $native.status -cne 'PASS_HOSTED_NATIVE_154_TO_156_AND_BACK_154' -or + $report.head -cne $env:GITHUB_SHA -or + $native.source -cne $env:GITHUB_SHA -or + !(Test-Path -LiteralPath $installer -PathType Leaf)){throw 'R600_NATIVE_BUILD_ACCEPTANCE'} + $bytes=(Get-Item -LiteralPath $installer).Length + $sha=(Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash + if($bytes -lt 50000000 -or $sha -cne [string]$report.installerSha){throw 'R600_TESTED_INSTALLER_SHA_CHANGED'} + if((Get-AuthenticodeSignature -FilePath $installer).Status.ToString() -cne 'NotSigned'){throw 'R600_SIGNATURE_POLICY_CHANGED'} + Write-Output ('R600_EXACT_TESTED_PRIVATE_CANARY_SHA256='+$sha) + Write-Output ('R600_TESTED_PRIVATE_CANARY_BYTES='+$bytes) + - name: Upload R600 exact tested installer (private ephemeral) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: r600-private-tested-v156-${{ github.run_id }} + path: delivery/DirectInternetMethod_1.5.6_Windows_Setup.exe + if-no-files-found: error + retention-days: 2 + compression-level: 0 + - name: Preserve R535 private verification receipts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: r535-windows-v156-hosted-${{ github.run_id }} + path: | + ${{ runner.temp }}/DIM_R535_NATIVE/BUILD_R535.json + ${{ runner.temp }}/DIM_R535_NATIVE/NATIVE_R535.json + if-no-files-found: warn + retention-days: 14 + + linux-r782-root-owned-bootstrap-ephemeral: + name: linux-r782-root-owned-bootstrap-ephemeral + # Actual UID 0 on a fresh original-repository GitHub VM ONLY. + # Never run this job on a user's machine, a fork, or a tag release. + if: (github.event_name == 'workflow_dispatch' || github.event_name == 'push') && github.repository == 'GOD13emad/DirectInternetMethod' && github.ref == 'refs/heads/prep/r803-forward-156-native-gates' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Rebuild frozen candidate and verify trusted manifest source bytes + shell: bash + run: | + set -euo pipefail + python3 -B build_linux_package.py + printf '%s %s\n' 'F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6' 'delivery/DirectInternetMethod_1.5.7_Linux_x86_64.zip' | sha256sum --check --status + printf '%s %s\n' 'D0181C15C54B90A3B6E03FBFBE32148341037AB60CF4D6788B6A72140304340F' 'tools/security/privileged_entry.py' | sha256sum --check --status + - name: Real root-owned bootstrap staged freeze and bad-manifest fail closed + shell: bash + run: | + set -euo pipefail + # Test only; PKEXEC_UID is simulated, and no product install runs. + sudo -n /usr/bin/env CI=true GITHUB_ACTIONS=true GITHUB_REPOSITORY="$GITHUB_REPOSITORY" GITHUB_REF="$GITHUB_REF" GITHUB_SHA="$GITHUB_SHA" RUNNER_ENVIRONMENT="$RUNNER_ENVIRONMENT" GITHUB_WORKSPACE="$GITHUB_WORKSPACE" RUNNER_TEMP="$RUNNER_TEMP" /usr/bin/python3 -B tests/r782_hosted_trusted_entry_ephemeral.py + + windows-private-r846-native: + name: windows-private-r846-native + if: github.event_name == 'push' && github.repository == 'GOD13emad/DirectInternetMethod' && github.ref == 'refs/heads/prep/r846-forward-157-native-gates' + needs: windows-source + runs-on: windows-2025 + timeout-minutes: 75 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: '10.0.x' + - name: R846 exact private v1.5.7 build and unsigned installer + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/r846_windows_candidate_hosted_build.ps1 + if($LASTEXITCODE -ne 0){ throw 'R846_PRIVATE_WINDOWS_BUILD_FAILED' } + - name: R846 hosted v1.5.4 to v1.5.7 in-place, preserve, uninstall and rollback + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/r846_windows_native_rollback.ps1 + if($LASTEXITCODE -ne 0){ throw 'R846_PRIVATE_WINDOWS_ROLLBACK_FAILED' } + - name: Retain v1.5.7 private test receipts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: r846-windows-private-test-${{ github.run_id }} + path: | + ${{ runner.temp }}/DIM_R846_NATIVE/BUILD_R846.json + ${{ runner.temp }}/DIM_R846_NATIVE/NATIVE_R846.json + if-no-files-found: warn + retention-days: 7 + - name: Retain tested private v1.5.7 setup (never public release) + if: success() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: r846-windows-v157-private-verified-${{ github.run_id }} + path: delivery/DirectInternetMethod_1.5.7_Windows_Setup.exe + if-no-files-found: error + retention-days: 7 + + windows-private-r848-v156-forward: + name: windows-private-r848-v156-forward-one-time + # Historical original v1.5.6 artifact expires 2026-11-01. + # Only the one-time R848 commit runs this; later pushes skip safely. + if: github.event_name == 'push' && github.repository == 'GOD13emad/DirectInternetMethod' && github.ref == 'refs/heads/prep/r846-forward-157-native-gates' && (contains(github.event.head_commit.message, 'r848-hosted-156-to-157-once') || contains(github.event.head_commit.message, 'r850-hosted-integrity-once')) + needs: windows-source + runs-on: windows-2025 + timeout-minutes: 75 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6 + with: + dotnet-version: '10.0.x' + - name: R848 build verified private v1.5.7 from current exact SHA + shell: pwsh + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/r846_windows_candidate_hosted_build.ps1 + if($LASTEXITCODE -ne 0){throw 'R848_BUILD_FAILED'} + - name: R848 original private 1.5.6 to 1.5.7 native and back + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference='Stop' + & pwsh -NoProfile -NonInteractive -File tests/r848_windows_private156_forward_native.ps1 + if($LASTEXITCODE -ne 0){throw 'R848_PRIVATE_NATIVE_FAILED'} + - name: Retain R848 one-time Windows native private upgrade evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: r848-private156-to-157-native-${{ github.run_id }} + path: | + ${{ runner.temp }}/DIM_R846_NATIVE/BUILD_R846.json + ${{ runner.temp }}/DIM_R848_NATIVE/NATIVE_R848.json + if-no-files-found: warn + retention-days: 7 + windows-release-contract: name: windows-release-contract-unsigned if: startsWith(github.ref, 'refs/tags/v') @@ -246,6 +764,8 @@ jobs: $ErrorActionPreference='Stop' python -B tests/release_metadata_audit.py if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } + python -B tests/r664_direct_update_monotonicity_contract.py + if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } python -B tests/release_publication_negative_regression.py if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE } python -B tests/unsigned_public_release_contract.py --fixtures diff --git a/PROJECT_BRAIN.md b/PROJECT_BRAIN.md index d0545766..fe9a14e7 100644 --- a/PROJECT_BRAIN.md +++ b/PROJECT_BRAIN.md @@ -1,8 +1,168 @@ # Current-authority notice — historical project log follows Current public release: **v1.5.4 Stable**, published 13 October 2026. Official tag 51009372e3c2d069d7016b97773f35ee5e1e0631 and six accepted public assets at https://github.com/GOD13emad/DirectInternetMethod/releases/tag/v1.5.4 . -Newest cumulative operational Brain at this revision: BRAIN_R496.zip. Hosted Native Windows/Linux and Saeid/MMZ actual 1.5.4 installations are PASS. Emad Windows/Linux active original network installs remain OPEN, so whole-project FINAL is UNPROVEN. -This file intentionally retains original historical records. Earlier Candidate/Final/signing-HOLD claims below are superseded for current publication and MUST NOT be run as deployment authority. + +**Latest verified position — 2026-10-16 R660 (accepted R659 NIC reset evidence plus Emad Linux Stable live runtime PASS):** PUBLIC **v1.5.4 Stable** and tag unchanged; PR #60 remains **Draft, unmerged, unpromoted**. Exact private 1.5.5-rc1 base installed both Windows. **R646 source change** in Draft fixes deceptive GUI `"Direct path healthy"` and green `ACTIVE` while YouTube fails under 2/3 startup HTTPS quorum: live status now shows **PARTIAL** and warning when any core YouTube/OpenAI/GitHub transport probe fails or owned resources aren't proven, retaining internal mode ACTIVE and Stop/Recovery. Native Emad .NET 10.0.402 source GUI+Service builds 0 warnings/errors and GitHub PR+push CI PASS, with native hosted stable→new-RC1→stable acceptance. **R648–R650 Saeid physical manager-approved same-AppId in-place GUI update PASS:** new tested private Installer SHA256 `43FC425CE608C9E2F2E21F066D0F3FBA860AD24ED541A0C4995F92C8D79C157F` installed, expected GUI SHA `1894573094105AE7FE221ABDA9A7E6AE486FD9C05930CF9EEC0979C79FD8D21C`, manifest SHA `FAC36AFD3D1933482C658F60E9ED8D5FF5331EB5AAB7E2EEA1941D5C485A8669`, native 681/681 SHA PASS, data/one-install/route/DNS/NRPT preserved; old RC1/Stable rollback installers retained. **R656–R658 Emad Windows now ALSO owner-UAC upgraded with the exact same tested R646 GUI Installer SHA `43FC425CE608C9E2F2E21F066D0F3FBA860AD24ED541A0C4995F92C8D79C157F`; 681/681 installed hashes PASS, original five-file config and provider, DNS/routes/NRPT and Tailscale preserved, one Uninstaller. Both Windows now have the same private corrected RC1 GUI payload.** Prior Emad Start→YouTube→Stop/Recovery PASS was obtained using the previous GUI before R657, not after. Saeid actual YouTube access **still FAIL** under all original profile strategies and read-only transport experiments R632–R645. R647 stale 3 owner-only R633 one-shot scheduled tasks retired with verified past exit0, Saeid DPI OFF/service Running. **R651 earlier hidden WPF proof INCONCLUSIVE due incomplete harness; R654 corrected actual WPF hidden OFF/Ready contract PASS and R655 actual WPF PARTIAL/Stop-visible-control contract PASS with an isolated synthetic ACTIVE backend plus real Saeid HTTPS failures.** No desktop focus, installed mutation or DPI activation. MMZ Linux physical Root/User RC1 install and autonomous systemd dual rollback R627–R629 PASS, stable Root1.5.2/User1.5.4 restored; full live DoH still requires explicit owner opt-in, external Tailscale stays active. **R660 Emad Linux current Stable live status is ACTIVE and HTTPS YouTube/Google/GitHub works with Tailscale split-safe, superseding previously recorded CONFLICT for this host/current run only.** **Whole-product FINAL OPEN** (Saeid YouTube, full production-service-ACTIVE GUI integration validation, MMZ native HTTPS/DoH & reboot, Emad Linux, cross-host release signoff). **DO NOT MERGE/RELEASE.** **R659 same-IP Saeid YouTube TLS reset confirmed as one inbound TCP RST observed by NIC-level Microsoft PktMon (zero after Google control), under manager-approved metadata-only capture with no traffic payload and verified native PktMon cleanup. Precise injector/next packet timing remain unproven; product YouTube failure on Saeid stays OPEN.** + +**R660 Emad Linux original Stable live runtime and YouTube reachability — 2026-10-16:** Read-only original on-host `~/.local/share/DirectInternetMethod/direct_internet_method.py` imported with no installation write; its *actual* `load_state()` returned `mode=ACTIVE` and private state JSON `status=ACTIVE`, physical interface **`enp1s0`** now equal to live default-route interface `enp1s0` (older wlp2s0 drift/CONFLICT is **SUPERSEDED as current host status**). Original `_external_tunnel_active()=false`, `_tailscale_split_safe()=true`, `_coexistence_backend_ready()=true`; real `directinternetmethod-nfqws-1000.service` and external `tailscaled` both active. Installed native **Root v1.5.2/User v1.5.4**; not RC1. Independent native Emad Linux unproxied, IPv4-only physical `curl` while original NFQWS was already active returned: **Google `generate_204` HTTP204 exit0** (TCP .340s, TLS .437s), **GitHub homepage HTTP200 exit0** (TCP .358s, TLS .443s), **YouTube `generate_204` HTTP204 exit0** (TCP .400s, TLS .496s), and **YouTube homepage HTTP200 exit0** (TCP .356s, TLS .450s, total 1.104s). Before/after original installed app source SHA, live state JSON SHA, physical `/etc/resolv.conf` SHA, literal IPv4 default route and `tailscaled`/DIM-unit active states were verified identical. Durable Commander `21e7d4c3-9f49-4e9f-a9bc-948fa2631a6d` **SUCCEEDED exit0**, stdout SHA256 `7220F4AF3E6445DD13E41DB664D2AE39031455E3A034F2D73652564D454C121C`; independent readback confirmed Root1.5.2/User1.5.4/State ACTIVE/physical iface enp1s0. No installed binaries, user config, VPN, system DNS, routes or Tailscale modified by this R660 work. **PASS scoped to actual Stable v1.5.4 native Linux runtime reporting plus physical no-proxy HTTPS connectivity and Tailscale coexistence on Emad laptop**. Do NOT infer provenance/actor of the external interface/state correction, video playback, RC1 installation, reboot persistence or that this host result solves the Saeid Windows YouTube network. Historical `Emad Linux CONFLICT` receipts remain valuable R0→R659 provenance, not current truth. Next Linux host critical acceptance is upgrading Emad to RC1 under independent guarded user/Root transaction with owner approval and a verified local recovery path; MMZ has the prior proven independent systemd rollback but pending deliberate production user DNS consent. + +**R659 SAEID genuine inbound TCP RST at physical NIC — 2026-10-16 (accepted unique diagnostic evidence):** On actual `SAEID` Windows private installed `v1.5.5-rc1` (manifest SHA256 `FAC36AFD3D1933482C658F60E9ED8D5FF5331EB5AAB7E2EEA1941D5C485A8669`, Service Running, owned DPI OFF) the built-in **Microsoft PktMon** required administrator privileges: a native read-only `pktmon status` and `pktmon filter list` returned `Access is denied` under normal owner token. In accordance with owner access instruction, native Windows UAC was requested. The FIRST R659 admin helper `45aa8585-99e4-4296-90ae-816d8f8fba67` failed before opening PktMon (PowerShell precondition `Test-Path ... -or` syntax); no evidence/ETL file or running DPI state, so NOT a measurement. The corrected, parsed native Windows UAC helper `001f133a-a0ea-4971-b94d-b06ea32e281d` **SUCCEEDED exit0** and produced local receipt SHA256 `4046DA014203F52B226885B768F1E98348A649DBBCDD3D29063A5494F023DFD6`, stdout SHA256 `FB573E0B286DEABFB8A2E972CABCA94430CB3C04AF6157CE638420C5E2346849`. Elevated preconditions independently verified **no pre-existing active PktMon capture and no pre-existing filters**; exactly ONE filter `DIM-R659-RST` for **TCP RST packets involving 142.251.155.4:443** applied only during `pktmon start --capture --counters-only --comp nics`. **No packet content was captured**, no PCAP/ETL retained or uploaded. With normal TLS certificate validation and `curl --resolve` to the *same* destination IPv4 (Google and YouTube SNI separated): after Google `www.google.com` (HTTP204, native TLS completed at 0.166474s) PktMon counters output was empty (`[]`); after YouTube `www.youtube.com` (TCP connect 0.091607s, no TLS, HTTP000/curl35), the NIC `Realtek PCIe 5GbE Family Controller` showed **one inbound/Rx 60-byte TCP RST and zero outbound/Tx RSTs**. **Separate nonadmin physical readback verified** receipt SHA, before/Google counters empty and after-YouTube inbound1/outbound0, no owned DPI state, stable running service and pinned installed manifest. Elevated `finally` confirmed `pktmon stop`, `pktmon filter remove`, filtered ETL absent, PktMon idle and empty native filters; `cleaned=true`. **SCOPED PASS:** network-side TCP RST reaches Saeid NIC in response to YouTube SNI but not same-IP Google SNI; strong evidence of SNI-sensitive remote/in-path reset and **not a GUI-only or user-mode Schannel-only failure**. It does **not** identify a specific ISP device, prove whether spoofing occurs before or after server ACK, establish which entity generated RST, or demonstrate a working site workaround. All profile variants Balanced/Compatibility/Strong and naive client-side TCP ClientHello split had previously failed, so do NOT repeat those unchanged tests or mutate system DNS/routes/TLS trust. Future targeted instrumentation, if warranted, should focus on actual **WinWS packet visibility under guarded Start/Stop** rather than speculative profile proliferation. Official authoritative packet counter/filter references: https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/pktmon-start ; https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/pktmon-filter-add ; https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/pktmon-counters . + +**R577–R580 physical transport discrimination — 2026-10-16:** On MMZ, Control D DoH URL `https://76.76.10.11/p0` returned HTTP 400 for an empty GET with **TLS verification 0** (endpoint reachable and certificate verified; this is NOT a valid DNS-query success). Two isolated Chrome 155 trials with requested secure-DoH flags displayed Google HTML but YouTube zero DOM/connection refused; inspection of the fresh Chrome NetLog on an additional run revealed **0 DNS_TRANSACTION_HTTPS_ATTEMPT events** despite 21 DNS query events (R579). Therefore **do not assert browser DoH actually activated** and do not attribute YouTube failure to a proven browser DoH path. Distinct prior R534 unmodified `ctrld v1.5.8` physical rootless loopback DoH **did** resolve global YouTube/Googlevideo addresses; its scope remains DNS only. With disposable browser-only public IP mapping `www.youtube.com -> 142.251.151.4` and normal TLS verification, TCP-mode Chrome still failed (`ERR_CONNECTION_RESET`), while forced QUIC made 10 session events, no QUIC receive packets and `ERR_QUIC_PROTOCOL_ERROR`; both returned zero DOM (R580), with no installed engine. These are path-specific negative results, not proof of a universal ISP mechanism or product fault. All R577/R578/R579/R580 processes/profiles/netlogs ephemeral and removed; pre/post installed metadata SHA, resolver SHA, default route and Tailscale identical. Raw Commander successful complete operations: R577 `ad8c4443-b1a8-4de1-a7b8-de075d2dd2f6` stdout SHA256 `f4e9a603fea5ab2bbfd8440d31461fc20050be1e314523dd647e479d3227e13c`; R578 `b3a67cbd-a600-401f-9768-35dd6de1e1a3` SHA `e619670e2b1f868920ac1e9c2ac4d30a077cd334a4b51e478ffab590eaff40b4`; R579 corrected `89c69678-8db6-4bab-a51e-d9805e3aeff6` SHA `ff8d3f89bdb8836f4c06b8cdc34639ace4d2dfa41f4653ef6853233310490b2d`; R580 `a6201e6b-6a59-4f23-b1d3-13ec1c09e1ae` SHA `be96c1be307ceea6e7f84397d7f7be3d83f9a6eb7a4e84a11c35e8d370c32718`. Unsuccessful first R579 log parser was a `jq` operator-precedence error, not network regression; corrected run and post-baseline PASS. Relevant primary notes: Chromium secure/automatic DNS semantics `https://chromeenterprise.google/intl/en_ca/policies/dns-over-https-mode/`; zapret client TLS/QUIC fingerprint limitations `https://github.com/bol-van/zapret/blob/master/docs/quick_start.md`. + +**R581 current-host Windows installation invariant & upstream dependency decision — 2026-10-16:** Independent read-only audits on `EMAD-PC-ULTIMAT` and `SAEID` found **exactly one** installed Direct Internet Method v1.5.4 registry registration (same Inno `AppId`), exactly one existing paired `uninsNNN.exe` / `uninsNNN.dat` and one running `DirectInternetMethodSvc` per machine. Emad's active uninstaller is `unins001` and Saeid's `unins000`; no extra uninstaller file was present at either installed location. **PASS physical single-installed-registration/uninstaller-discovery only**, not proof of a native in-place v1.5.5 update. No cleanup or service mutation warranted. On MMZ, `pkexec` binary, running `polkit.service` and GUI session/agent are present, but existing Remote Commander policy does not authorize invoking `pkexec`; **no authorization dialog requested/approved** and Root guard not armed. Current private RC1 embeds `ctrld v1.5.7`; upstream `v1.5.8` (published 2026-10-07) includes DNS64/DNSSEC and upstream-key log redaction improvements. Existing R534 physically tested official v1.5.8 on private loopback and cleaned it; **do not silently replace the SHA-pinned RC1 binary** before rebuilding/retesting dependent package and native acceptance. Upstream source: `https://github.com/Control-D-Inc/ctrld/releases/tag/v1.5.8`; official Polkit authority: `https://polkit.pages.freedesktop.org/polkit/pkexec.1.html`; Tailscale Linux stub compatibility: `https://tailscale.com/docs/reference/linux-dns`. + +**R626 two physical Windows RC1 cold-boot service registration configuration — 2026-10-16:** Independently read only Windows **HKLM SYSTEM CurrentControlSet Services/DirectInternetMethodSvc** and matching same-AppId Uninstall registration after real installed v1.5.5-rc1 on both `EMAD-PC-ULTIMAT` and `SAEID`. Win32_Service CIM under non-admin token returned `StartMode=Unknown` and no path/account even though service Running (access-limited API, **not** failed auto-start); a narrower authorized registry read confirmed **Start=2 (Automatic), ObjectName=LocalSystem, Type=16 own-process, registered failure recovery actions present**, executable path precisely `%ProgramFiles%/DirectInternetMethod/Privileged/app/DirectInternetMethod.Service.exe`, on-disk binary SHA256 `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8`, each machine one matched Inno `uninsNNN.exe/.dat` pair, current Service `Running`, owned DPI state OFF. No Windows registry, service, route or data changed. **PASS scoped to physical host cold-boot SERVICE CONFIGURATION READINESS only**; no OS reboot, browser video playback, or WinDivert driver persistence was run. Both native Read-only Commander probes returned exact `PASS_R626_RC1_WINDOWS_NATIVE_COLD_BOOT_SERVICE_CONFIGURATION`. Do NOT conflate a registered Automatic service with a confirmed successful post-reboot Start/Stop user acceptance. Reference for Windows Service `Start=2` / `ObjectName`: Windows SCM service registry values, and actual native host readback; source installer in same PR. + +**R656–R658 two physical Windows exact corrected GUI acceptance — 2026-10-16:** Actual EMAD Windows had private v1.5.5-rc1 before upgrade, original Manifest `BA6AC9EAEE7E47E50778EBAF107A3F8BF2590F6196B71E01CE72E75EBE899297`, core Service Running, DPI OFF, Tailscale Running, five UserConfig files (1,510,513 bytes) with no symlinks. Original private RC1 Installer SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9` and official Stable `FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133` were owner-local retained. Authenticated GitHub CI-run `38939438225` private Artifact ID `12062368621` (same accepted artifact used for Saeid) was downloaded into a separate local `candidate-R646` directory and verified 154,315,328 bytes SHA256 `43FC425CE608C9E2F2E21F066D0F3FBA860AD24ED541A0C4995F92C8D79C157F` with NotSigned status; source product not changed during staging. R656 Emad Commander op `affd8b3e-e46d-4107-a7bf-500e4b08b7c3` succeeded, stdout SHA256 `cccb3e6c205df3b7a70b58b59de9ae1146e5793c6ad8b033d7e4528679a5ea8f`. + +**R657 physical Windows UAC and actual SAME-AppId in-place GUI update:** Owner-local, entry-by-entry SHA-validated backup of original five private UserConfig files made first, backup ZIP SHA256 `65E2EBEAF2B9145285807019BC87C79A8A6D0E6A7C020027C9FD6CC3C7DDE181`. Windows owner UAC RunAs approved, unchanged Inno installer `/SILENT /NORESTART` exit0; registry remained one `v1.5.5-rc1` installation with exactly one matched Inno uninstaller pair. Installed **new GUI** SHA256 `1894573094105AE7FE221ABDA9A7E6AE486FD9C05930CF9EEC0979C79FD8D21C`, **new Manifest** `FAC36AFD3D1933482C658F60E9ED8D5FF5331EB5AAB7E2EEA1941D5C485A8669`, original Service binary SHA256 `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8`; all 5 original config file hashes and providers unchanged, network route/DNS/NRPT exact identity, Tailscale and core Service Running, DPI OFF, previous RC1 and Stable recovery installers retained. R657 op `b346abc2-0dcc-4394-86fb-1a58a3d1f389` succeeded stdout SHA256 `bed1bd8f2252bb66ed934ba0d2efcaf05ab0806620c28a9c19eb3846be6a81fd`. + +**R658 independent physical on-disk SHA:** Actual Emad Win32 full installed two-scope current RC1 Manifest **681/681 SHA256+file-size exact**, 481,273,295 bytes, no symlinks/duplicates/path escapes, Tailscale/Service Running and engine OFF; op `25f4a17a-d588-48e2-904f-6d26fd329e5c` succeeded stdout SHA256 `b95c0a9d888a88ce00fdf084a4f4035b2c93d442d0661990f98cc3d092ad5130`. Independent Saeid readback saw the **same corrected GUI and Manifest hashes**, Service Running and engine OFF. **Scoped PASS both Windows now use one identical *private* UI-corrected RC1 candidate**. No actual RC1 graphical UI with active engine was exercised on this new build (R655 synthetic-status WPF PASS only); network YouTube remains blocked on Saeid, and public Stable v1.5.4 and PR Draft unchanged. Do not claim FINAL or promote. + +**R652–R655 WPF hidden actual-control execution and simulated-ACTIVE partial status — 2026-10-16:** Actual Saeid Windows running the SHA-pinned installed GUI candidate `1894573094105AE7FE221ABDA9A7E6AE486FD9C05930CF9EEC0979C79FD8D21C` and schema2 manifest `FAC36AFD3D1933482C658F60E9ED8D5FF5331EB5AAB7E2EEA1941D5C485A8669` retained original `v1.5.5-rc1` and native Service Running, engine OFF throughout. A prior temporary WPF reflection test R651 was INCONCLUSIVE, not a product crash. R652 isolated source build revealed that attempting to set WPF static `Application.ResourceAssembly` after first initialization throws `InvalidOperationException`; R653 captured the *actual* original nested UI error, `XamlParseException -> Cannot locate resource 'directinternetmethod.ico'`, because harness entry assembly lacked its WPF resource. Real app's `windows/gui/MainWindow.xaml` uses relative `Icon="/DirectInternetMethod.ico"`, and project embeds that icon. **Do not edit installed GUI or WPF resources based on this test harness failure**. All these earlier temporary workspaces were scope-owned and removed. + +**R654 actual hidden WPF OFF test:** Corrected disposable .NET 10.0.402 WPF harness bundles the authentic pinned source `windows/app/DirectInternetMethod.ico` as harness entry-assembly WPF Resource, loads the exact-source compiled `MainWindow`, never shows/focuses a window, injects installed app source path for the unchanged production `Status.ps1` backend and invokes the **real** private `RefreshStatusAsync` on its dispatcher. Native host WPF controls returned `StatusBadge=OFF`, `HealthText=Ready`, `StopButton.IsEnabled=false` and `IsVisible=false`, **PASS**. No installation/settings/network change; exact durable Commander op `cbddbd86-bac2-4220-ba11-3c60c64fc78b` SUCCEEDED, stdout SHA256 `bbd29b2b87364094d53a97a6b3b6040fc5a400d7ba18f825b91d296facfa601c`; temporary C# source/build/icon removed. + +**R655 actual hidden WPF PARTIAL behavior with synthetic active status + *real* Saeid HTTPS:** The same corrected original WPF code was recompiled from pinned PR head `0e7a009d23d03429d362102aec82c837f9218fa4` into a uniquely owned disposable harness with correctly embedded original icon; ONLY the **Status.ps1 backend** was replaced with a temporary private directory script returning an explicitly **synthetic** `mode=ACTIVE`, `ownedResourcesHealthy=true` and the host's measured actual IPv4 interface. This simulated active service status, while GUI `ProbeAsync` still performed the original *real network* curl HTTPS requests without proxy; ordinary user preference `adultCheckEnabled=false` only in test object's memory avoided unrelated live category probes. Native real WPF controls returned `StatusBadge=PARTIAL`, `HealthText="Direct path active · at least one core site unreachable"`, `YoutubeText="FAIL · transport"`, `GithubText="PASS · HTTP 200"`, `OpenAiText="API HTTP 401 auth required · login unverified"`, `StopButton.IsEnabled=true`, `window.IsVisible=false`, **PASS**. The original actual installed engine remained **OFF**; adapter DNS/default route, installed manifest SHA and service unchanged, temp synthetic backend/UI files all deleted. Exact durable op `1a4d6e68-d281-48ac-91c4-1f2893e48123` SUCCEEDED stdout SHA256 `5f73bd695f8da734994cdaf775e89e02fd83e607a25107950abeeb60dc26166e`. **Acceptance scope:** true WPF presentation and control logic for a simulated ACTIVE state plus actual site failure, NOT a real engine-active WPF integration, authenticated YouTube/video access, or network solution. A separate attempt R655 to invoke real live Start+WPF+Stop with task-based safety guard was explicitly BLOCKED by the host tool's safety checks before execution; do not repackage the same prohibited mutation to evade tool policy; physical Start/Stop safety remains established by R632/R636/R638, but the new GUI's *live-mode* behavior is not claimed. + +**R646–R651 GUI partial-health correction, private tested Windows canary and owner task cleanup — 2026-10-16:** Newly discovered **false-positive Windows UI health**: the exact pinned Saeid Start evidence `%ProgramFiles%/DirectInternetMethod/Privileged/evidence/runtime-latest.json` was `status=PASS_ACTIVE`, `httpsHealth.required=2, passed=2` from GitHub/OpenAI, while `live.youtube.ok=false`, curl35 reset with no HTTPS and Chrome zero DOM. Emad had `PASS_ACTIVE`, `httpsHealth.passed=3` and real YouTube204. GUI `windows/gui/MainWindow.xaml.cs` had unconditionally displayed green ACTIVE and `Direct path healthy` whenever resource ownership/mode were ACTIVE, overriding its own failed YouTube site test. Chosen safe **R646 GUI-only corrective change** preserves 2-of-3 engine quorum/availability and native Stop behavior while preventing a false full-health display: `coreReachabilityProven` requires ownership plus completed YouTube/OpenAI/GitHub live probe `Ok`; otherwise `StatusBadge=PARTIAL` warning and `HealthText` explicitly says one or more core sites unreachable. No TLS certificate bypass, no new DPI engine, no all-sites/DNS/driver changes. Source commit `828742d2568e12997864e56d572fdba9a12ac4cc`, scoped regression guard in existing `tests/windows_contract_audit.py` commit `9533b62aaf1ade5ff56972baeb3b52c74be3e3f3`; verified **physical Emad native .NET 10.0.402 GUI & Service Release builds 0 errors/0 warnings** op `59591bd4-b4e1-4760-8f4d-1ddc00c4beb3` stdout SHA256 `60B30B64BFCCFD1C601FAADF8EB84859AF2F0F69B703AA628330C1727881CFCC`; no installation mutation during compilation. + +**GitHub current-head CI acceptance:** [PR run 38939448874](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38939448874) success; [push run 38939438225](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38939438225) **14 jobs succeeded, 1 explicitly skipped**, including exact Windows hosted v1.5.4→new private v1.5.5-rc1→v1.5.4 original native single-install/private-data preservation PASS; tested Inno SHA256 `43FC425CE608C9E2F2E21F066D0F3FBA860AD24ED541A0C4995F92C8D79C157F`, **154,315,328 bytes**, artifact `r600-private-tested-rc1-38939438225` ID `12062368621`. Native manifest SHA256 `FAC36AFD3D1933482C658F60E9ED8D5FF5331EB5AAB7E2EEA1941D5C485A8669`; GUI SHA256 `1894573094105AE7FE221ABDA9A7E6AE486FD9C05930CF9EEC0979C79FD8D21C`; protected Service SHA `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8` unchanged. + +**R647 actual Saeid stale guard cleanup:** read-only found three **expired but still registered user-owned** R633 one-shot stop/recovery/strategy-clear tasks with LastTaskResult=0, NextRun blank and temporary compatibility file already absent. Exact owner/command/encoded cleanup SHA/path reverified; native non-admin `Unregister-ScheduledTask` removed exactly these three with postcondition absent; service remains Running, engine OFF. Op `a19537d7-4135-4ec5-b631-feea441e5db0` SUCCESS stdout SHA256 `A19C7D111677BC71F2A978713E6AE5A9D031D8BE9F0169A589AD4D06C2BF8098`; no protected SYSTEM task touched. + +**R648–R650 actual Saeid private canary:** New GitHub tested artifact was downloaded only through authenticated short-lived connector handoff and verified exact one-member zip payload, SHA/size/unsigned policy, to user-local `%LOCALAPPDATA%/DirectInternetMethod-Private-QA/candidate-R646`; old exact RC1 Installer SHA `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9` and public Stable recovery Installer SHA `FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133` retained. R648 op `6368c569-9b7c-4d43-800b-05f55037511e` SUCCESS stdout SHA `CE363049695FF593B7EDB5F9FC71320C9A4DEE545339F2E617CF2D4EBBB44F1F`. First R649 helper call failed a **PowerShell `Test-Path ... -or` syntax typo before Installer/UAC** (no mutation); exact state reconciled. Corrected R649 **Windows native owner-approved UAC** installed tested same-AppId GUI candidate **in-place, exit0**, native registry still 1.5.5-rc1, exact new GUI/Service/Manifest SHA as above, all private UserConfig file hashes/provider SHA unchanged, native default route, adapter DNS, NRPT untouched, Service Running, engine OFF, **exact one Inno Uninstaller exe/dat pair**; recovery files still present. Native op `b8fd930d-680a-425d-8763-7a6ddbf60cba` SUCCESS stdout SHA `319BDCD550B3BD8319982DCF1AE299123983AFD5766D1663E4A76D088D091318`. Independent R650 on-disk installed all **681/681 managed file bytes and SHA, 481,273,295 bytes**, no missing/reparse/drift and zero discrepancies; op `6055b31f-edaa-49f0-ba00-8724f752f5bb` SUCCESS stdout SHA256 `797369E106FCB5583AAF7C0B8BFEAD31D1F7256C34A63D7B827C209777F3A153`. + +**R651 evidence limitation:** A disposable offscreen WPF harness intended to instantiate `MainWindow` without desktop focus and call real status probes first failed GUI compilation due sparse-checkout excluding original `windows/app/DirectInternetMethod.ico`, then with that input corrected built WPF GUI 0 warnings/errors but reflection instantiation returned `TargetInvocationException` without an inner stack in the test wrapper. Both scope-owned temporary worktrees cleaned and installed Saeid RC1 unchanged. **DO NOT mark live WPF visual PARTIAL test PASS**; compilation + source contract + installed SHA accepted only, physical visual classification still OPEN. A newly staged source must not be presented as already published, and any later same-tag update must compare actual hashes, not only version strings. + +**R643–R645 Windows TLS fragmentation read-only diagnostic — 2026-10-16:** On actual Saeid v1.5.5-rc1 Windows with DPI OFF, `Invoke-WebRequest -NoProxy` .NET HttpClient with normal certificate verification reached Google HTTP204 in 0.27s, YouTube timed out 9s (`TaskCanceledException`), thus failure is not restricted to curl fingerprint; native operation `fea1111d-f231-47fc-9c41-dca5f8141593`, stdout SHA256 `2124D8C32C23E37A1D0826BD5B0BA6C3558DB32467A889CFFC1830B36C2EFF29`. Initial R644 ephemeral C# stream harness failed before a valid TLS test because it omitted Stream Timeout properties; no product/host mutation and not PASS. Corrected R645 pure user-local .NET standard-library `SslStream` default certificate validation to one pinned public IPv4 endpoint `142.251.155.4:443` verified ClientHello actual SNI `www.youtube.com` byte offset splits **1** and **7** into two separate TCP writes 275ms apart (`SplitApplied=True`), but both YouTube sessions still reset (`IOException: existing connection forcibly closed by remote host`) after ~370ms; unsplit YouTube similarly reset ~83ms, while `www.google.com` SNI on the **same destination IP and unchanged cert validation** negotiated and returned HTTP204 within 284ms. Exact durable op `f528856d-a7c1-4330-865a-700bccb39c75` SUCCESS, stdout SHA256 `EDB25452CB32B1F19DAF9D3BBD37D817DEAFA10D82473E6F27DAAB36A4A4EED5`. This **rejects naive SNI TCP segmentation as a working Saeid fix** and supports targeted SNI-sensitive blocking, though it cannot assign the reset to any named ISP device. In-memory experiments, no installed binaries/DNS/routes/driver edits, no TLS validation disabling. **Scoped diagnostic PASS; Saeid YouTube still FAIL.** Primary mechanics: https://github.com/bol-van/zapret/blob/master/docs/readme.en.md . + +**R639–R642 physical SNI QUIC IPv6 differential:** On both actual Windows machines running installed v1.5.5-rc1 with owned DPI OFF, read-only no-proxy native curl/DNS diagnostic recorded distinct site blocks. **R639 Saeid:** DNS A for `www.youtube.com` resolves public IPv4, TCP connect ~0.08–0.20s but YouTube `generate_204`, mobile and youtubei API HTTP000 curl35 before TLS completion under TLS1.2 and TLS1.3; Google/GitHub reachable. **R639 Emad:** same YouTube domains return DNS A `10.10.34.35` (nonpublic), original TCP connect times out (curl28). Saeid op `b062836e-9185-499a-8ef1-c6b2c790dc8c` SHA256 `67C6DC9CE136DB019278713015B22177B159B326DF82EBADCF987868D2BBAD4`; Emad `bb05de50-d38f-4ff9-8ffe-3500f8c8ac96` SHA256 `FE56FF06A74562DAB83E2659015EB4CEEB40F55C50D5C3B2A02C1569B94B7A55`. + +**R640 Saeid exact-destination control:** `curl --resolve` with normal TLS validation and **the SAME endpoint IPv4 `142.251.155.4`**: `www.google.com` SNI gave HTTP204, while `www.youtube.com` SNI HTTP000/curl35; alternate YouTube `142.251.157.4` also failed. Strong evidence for SNI/site-sensitive path blocking, not attribution to a particular device. Op `0d031c67-f6ca-4849-9e85-70c9401bce17` SHA256 `BDD829FB36CDD16002E388EF909AF33DD0AB36EC0C4EA03EAADD2131ED804F47`. First QUIC harness had a PowerShell syntax error before acceptance, no retained temp files. **R641 corrected Saeid Chrome 155** forced QUIC to YouTube with disposable profile, **15 QUIC packets sent, 0 received, 167 QUIC events, zero DOM/title, `ERR_QUIC_PROTOCOL_ERROR`**; op `4fc16867-c6cf-409b-a685-422921ee2679` SHA256 `9CC9B422DBBF22EA9F520B6A46744736BD25E82A90E2BC38F453A4180CB75240`. **R642 IPv6:** both hosts had *no* IPv6 default route; direct no-proxy IPv6 YouTube/Google curl exit6 before TCP, despite AAAA entries `2001:4188:2:600:10:10:34:35` (Emad), `...:36` (Saeid) for YouTube. Saeid op `e9f775b8-d396-47ee-a67e-c2de4e635ec8` SHA256 `8C4EF58CB8B16AC29804EA555016261F030100BDB392067D84DAF4C6CA5E8E61`; Emad `3c7da3d6-ba92-4cd4-9b38-c47a9b74d97a` SHA256 `B48A70C1DDDA03C79C4436E6BA28F7A3AB04857F0FF141431620A1F219A1EA4F`. All tests only user-private temporary browser/network reads, no published code or OS DNS/routes changes. **PASS diagnostics, FAIL site reachability on Saeid remains OPEN**. Source mechanics and caveats: https://github.com/bol-van/zapret/blob/master/docs/readme.en.md ; do not relax TLS verification, infer universal ISP fix or silently enable all-sites/alter DNS. + +**R633–R638 Saeid delayed helper and isolated compatibility strong profiles — 2026-10-16:** A previous R633 long-running asynchronous task had returned `UNCERTAIN` with no final receipt. Reconciliation first observed no changes but another bounded observation **minutes later** found a late actual `strategy.txt=compatibility` (13 bytes SHA256 `FA528D3ADEF61D199A8348FFB48EF0143603EA2E0AD007121C39EDC6E3AEB567`) and three authentic user-only OS ScheduledTask safeguards R633 Stop/Recovery/ProfileRemove. A guarded separate R634 attempt correctly **failed before mutation** with `R634_EXISTING_STRATEGY_REFUSED`. R633 Stop and Recovery later actually fired with native Service action `done/0`, engine OFF, all owned tasks were removed, strategy file removed and personal configuration returned to previous absent state. **Lesson:** an `UNCERTAIN` tool receipt can precede a later side effect; never replay it blindly, reconcile host and task state at a *later* time as well. + +R635 on real SAEID installed `v1.5.5-rc1` staged only a temporary user-owned `compatibility` profile and exactly three independently scheduled user-token Stop, Recovery and profile-deletion tasks 359/419/478 s future, verifying owner, expected exact `sc.exe` actions and file SHA, no system-DNS modification. Command `f6fe6cbb-f43b-425c-96c9-e8d43bc84e27` SUCCESS stdout SHA256 `C86782CA758495C394BD5D7E26A5613303DB6CCC5CB03E54F50D701578D05E99`. Actual R636 bounded installed-Service code128 Start `done/0` under compatibility/targeted, no-proxy HTTPS returned **YouTube HTTP000 curl exit35 (TCP connect .083s, TLS 0s)**, Google204, GitHub200, OpenAI401. Chrome 155 isolated YouTube exit0 but **0 DOM/no title**. Same host script guaranteed original code129 Stop `done/0`; exact original routes/adapter DNS/NRPT, no DPI-owned state or process, user strategy absent and three tasks removed, clean profile, service Running. Result `R636_COMPATIBILITY_YOUTUBE_STILL_BLOCKED_HOST_RESTORED`; operation `0c0f3ece-8f97-481a-abfd-bd1a04030932` SUCCESS stdout SHA256 `EC3F8C695FB243DFD9584997FF9DECAB417EB593BF06930A1A64E8F27FD62083`. + +R637 staged supported targeted `strong` only (6 bytes, SHA256 `EDA15CE4834AC303532BC6973DF5FF8A5DEB75D7DFCEB9FC2CDF1BB450A01CF5`), same three exact user-owned one-shot OS failsafes 359/418/478s future; operation `a7cb1f36-fd63-4748-b755-6710dc2b7c95` SUCCESS stdout SHA256 `786749828CFD081F73F050A8ABB F2BC44C20D3CD4D034A9301FB52201E9CFDB4` (the canonical raw receipt, not spaced typography, is reference). Real native R638 Service Start `done/0`, **YouTube HTTP000 curl35, TCP connected .095s, TLS never completed**, Chrome 0 DOM/no title; Google204, GitHub200, OpenAI401. Native Stop `done/0` removed DPI resources, original DNS/route/NRPT identical; Strong file, three OS safeguards and temporary Chrome profile removed. Result `R638_STRONG_YOUTUBE_BLOCKED_HOST_RESTORED`; operation `c54a7bad-49d7-4dc7-a659-429b9fd6059e` SUCCESS stdout SHA256 `69C17997C9A1E4BE6534970D1372A4C089DB79C571733C2FD0D9F0553DC96021`. **PASS scope:** true native Start/Stop and exact recovery under both built-in profiles, no private-data loss. **FAIL scope:** YouTube on Saeid; none of Balanced/Compatibility/Strong targeted profiles are accepted solutions. Do not change Windows DNS, route, or all-sites scope as an undocumented global workaround. Need targeted design/diagnostic for Saeid-specific TLS block with live guarded V&V; upstream Zapret docs describe possible fake/split incompatibilities, not universal fixes: https://github.com/bol-van/zapret/blob/master/docs/readme.en.md . + +**R630–R632 real-host guard retirement and Saeid TLS diagnosis — 2026-10-16:** After accepted dual-scope Root rollback R629 and its unique receipt SHA256 `43AFA4DC6443CF04C64D7EB6A2314B3FAAE7EA5653768D4F34227F329AEA58BE` was entered in the single GitHub Brain, a new physical MMZ readback found `/var/lib/directinternetmethod/upgrade-guard` and `upgrade-pending`, user pending, `/etc/systemd/system/directinternetmethod-upgrade-rollback.{service,timer}` all **ABSENT**, systemd timer **inactive/not-found**, exact installed original Root v1.5.2 metadata SHA256 `77CE54A188048849AC010436DAD5BC3D06103A1DD59AB96A58D0FFD3CF499FB5` and User v1.5.4 SHA256 `5215F59E6989C280B2C31D2EFE9FE3E2A030D1AFDD29E4D737EF9F4DAFDF924A` unchanged, `/etc/resolv.conf` checksum `C5FA5ADA8F61AF5DF9EA638173EA9E8064B059290EE8E7238E4EBFA719AE689C` unchanged, original route/Tailscale active, pinned source all 27/27 SHA256 validated, original `upgrade_guard.py preflight` PASS. Root-owned `/var/lib/directinternetmethod/upgrade.lock` still exists as neutral serialization file, **not** an armed watchdog, no manual deletion. Evidence is **physical terminal state PASS only; retirement process/actor not independently identified this turn** and must not be invented. + +On actual Saeid Win32 v1.5.5-rc1, corrected native R631 OS TaskScheduler user-token `DIM-R631-OwnerStop` and `DIM-R631-OwnerRecovery` `sc.exe control DirectInternetMethodSvc 129/130` one-time independent fallback tasks were created and verified 234/293 s future, operation `5d436dfc-88e4-4210-84ca-09ac199ac6bd` SUCCESS stdout SHA256 `82FFA153857771AEAFF51B40EA44ED72CCFDC5EAB17D587936A94DA9FF627979`. Initial R631 harness had a PowerShell `Test-Path ... -or` typo and had not started the engine; corrected run fully PASS. R632 bounded user-local native Service control **128 Start done0**; while DPI ON, HTTPS probes **Google204, GitHub200, OpenAI401 (expected auth challenge), YouTube HTTP000/curl exit35 (TLS failed)**. Chrome 155 in disposable no-proxy profile exited0 **with zero DOM bytes and no title**: site did **not** load. Original Service control **129 Stop done0**, no Recovery needed; owned state/processes zero, exact prior IPv4 DNS, NRPT and default routes restored; task scheduler safety guards verified removed; temp browser files removed. Do not interpret its returned summary label `PASS_R632_SAEID_NATIVE_RC1_START_WEB_STOP_RESTORED` as a full Web PASS: independent acceptance **PASS scoped to Start/Stop and recovery safety, FAIL for YouTube reachability**. Durable op `49c5cad0-92a3-4d97-a8cd-8fe2c369b73d` SUCCESS stdout SHA256 `C6C9937A2A944116AFCE7B65530B42A2FEAB991C866C481C2611A0F57EA335C0`. Native source `windows/app/Start-Direct.ps1` has supported profiles `balanced` (default), `compatibility`, `strong`, and scopes `targeted` or `all-sites`; on Saeid `strategy.txt` and `scope.txt` **do not exist** (thus default balanced/targeted), while on Emad both exist with explicit balanced/targeted. Official `zapret` documents the risk of TLS fake/disorder desync and network-specific profile selection: https://github.com/bol-van/zapret/blob/master/docs/readme.en.md . Investigate a bounded owner-configured alternative only with independent OS Stop/Recovery, byte-exact temporary data restoration and no system-wide DNS changes; do not declare one universal DPI fix. + +**R627–R629 MMZ exact real Root+User installed RC1, physical independent rollback & receipt — 2026-10-16:** Original 27-payload private R568 pinned RC1 `install.sh` executed from owner's local GNOME session under real manager-approved PolicyKit. First temporary wrapper `6f5fe62b-8e6a-4792-a067-582abde9b11f` failed *before launching Installer* due a quoted temporary output path; native Root/User/guard/net remained unchanged. Corrected wrapper `3f73d2e7-427f-4c32-bce8-c76767a0b8f9` captured **Installer EXIT0** and original application logs `PASS_USER_UPDATE_SINGLE_PENDING_SLOT_STAGED`, `ARMED_INDEPENDENT_ROOT_USER_WATCHDOG`, `R553_ROOT_GUARD_ACTIVE_PRESTAGE_VERIFIED`, Root 4/4 Stage+Apply, and `PASS_INPLACE_MANAGED_CODE_APPLIED_NO_USER_DATA_MUTATION`. Installed Root & User both `1.5.5-rc1`, native systemd timer `active/enabled`, two owned rollback slots, engine OFF, resolver SHA and route/Tailscale unchanged. Wrapper alone returned exit1 because its nonprivileged postcondition tried to read Root-owned 0700 pending manifest; installer was successful and protected slot was deliberately inaccessible to ordinary user. During watchdog acceptance testing, instead of starting the engine, original systemd Root service `directinternetmethod-upgrade-rollback.service` was started through *native owner PolicyKit approval*, returning **systemd `Result=success`, `ExecMainStatus=0`**, Root/User versions restored `1.5.2/1.5.4` (R628 operation `606b87a0-40d4-4995-b517-857b9a728dc8`, stdout SHA256 `35EDD1D28FB7529F0D93A36CE867B94143AEE06570EE6ED EB73ECB31FEF4D623`; visual space nonnormative). Verified original Root meta SHA256 `77CE54A188048849AC010436DAD5BC3D06103A1DD59AB96A58D0FFD3CF499FB5`, User meta SHA256 `5215F59E6989C280B2C31D2EFE9FE3E2A030D1AFDD29E4D737EF9F4DAFDF924A`, resolver SHA `C5FA5ADA8F61AF5DF9EA638173EA9E8064B059290EE8E7238E4EBFA719AE689C`, Tailscale active; no live engine or route mutation. Authenticated `pkexec /usr/bin/cat` exact protected `rollback-result.json` readback R629 op `bd4b061d-868d-4b56-bac5-900cd8fe909c` SHA256 `88BFDF7B4163B67C6EA8AF1EE9894A6ABCF7F2B660B1FC7A086D08213DF6AEAB`, confirmed `revision=R552`, `status=PASS_AUTONOMOUS_TWO_SIDED_RESTORATION`, `rootRollback=PASS`, `userRollback=PASS`, `recoveryAttempted=true`, `networkMutationAttempted=false`, duration 0.128s; **Root-owned receipt SHA256 `43AFA4DC6443CF04C64D7EB6A2314B3FAAE7EA5653768D4F34227F329AEA58BE`**. **Scoped acceptance PASS original physical protected+User in-place transaction and real independent root systemd rollback.** This Brain upsert exports evidence before separate owner-authorized bounded `retire-rolledback`; keep public Stable and PR Draft pending actual Linux application Start/Stop and cross-host FINAL. Root-owned guard/timer/slot **must NOT be silently deleted** before receipt-matched, owner-approved native retirement. + +**R622–R625 real host network comparison, native fake-root mutation contract, authenticated Linux privileged preflight — 2026-10-16:** Existing physical Saeid Windows v1.5.5-rc1 (engine OFF) headless Chrome 155 direct YouTube homepage produced **0 DOM bytes**, hit 21s timeout; user-local profile removed and network, manifest unchanged (R622 Commander `e9b06461-0b93-4f1d-ad37-3b0d7a68371f`, stdout SHA256 `593A2F1EA7F4E44A2021944CDB2865AB7EAAE0EB7ECD5551638964053E77ABA0`). Independent native curl `--noproxy '*'` `--http1.1` on BOTH Emad/Saeid Win v1.5.5-rc1 with engine OFF (R623) returned Google generate_204 **HTTP204**, GitHub **HTTP200**, while YouTube homepage and generate_204 **HTTP000**, curl **exit28**, about **3s timeout**, `time_connect=0.000`, `time_appconnect=0.000`, DNS lookup resolved quickly. Scope indicates failure before a completed TCP/TLS connection and must not be confused with an authenticated Chrome / DPI-ON acceptance; both host product/service/owned state SHA unchanged. Commander operations Saeid `d9ff8d2e-e29c-4eb9-b936-0e246fdb69b8` exit0 stdout SHA256 `41DC4C2810BEE7F630F8940090DD902FE92CFAEA09B5F771F3A57CAA841C7C44F`, Emad `98c95671-e2e7-403e-97e6-8f022afeab55` exit0 stdout SHA256 `8019A239801113FCD5081C18F67D7209C937BD8593941E8E7E1B0FBFBFA334E2`. Official Zapret Windows quickstart warns strategies are site/ISP-specific and to distinguish DNS, TLS and QUIC effects: https://github.com/bol-van/zapret/blob/master/docs/quick_start_windows.md ; Chromium's error list treats resets, DNS resolution failures, and unreachable addresses separately: https://chromium.googlesource.com/chromium/src/+/main/net/base/net_error_list.h . **Do not auto-change Saeid adapter DNS or pick an arbitrary desync strategy without a native guarded test.** + +On actual MMZ host, exact staged source `system/protected_upgrade_transaction.py` SHA256 `EB107164CCEDE891C8BF41C06FF422F1B53B59D2D2DDF0B3DD23EBFE5DE9601B` was imported unchanged into a **disposable user-owned fake protected filesystem**, seeded solely by copying and SHA-checking actual v1.5.2 pinned static protected files, two old managed targets and metadata as read-only source. Original `RootTransaction.stage/apply/rollback` **PASS** with hash-exact restoration; `apply(fail_after=2)` caused original automatic rollback restoring all bytes **PASS**; manually changed foreign target after Stage was rejected by both Apply and Rollback **fail-closed PASS**, preserving foreign bytes for review. **3/3 scoped synthetic protected transaction PASS** on physical Linux kernel, not root mutation; source/temp fake roots removed; actual Root/User/DNS/resolv symlink/default route/Tailscale identical. MMZ durable operation `ea5e01c0-2321-4392-beb7-758677caeee2` exit0 stdout SHA256 `47A8E05724E19C16AA1BC517C45DEC1B8F37FAAC18089EF2A01F8E18AC1D3CF2`. + +**R624–R625 privilege evidence adjudication:** A previously observed GNOME-terminal launcher with exit0 was insufficient proof of actual OS authorization. Final R625 run used the **official preinstalled `/usr/bin/pkexec` in the owner's active GNOME seat0 session**, targeted only the exact SHA-pinned unmodified `upgrade_guard.py preflight --source`; captured and checked a separate child terminal status **`R625_PKEXEC_OS_EXIT=0` plus non-empty JSON with `status=SAFE_SOURCE_AND_INSTALLED_BASELINE_ONLY`, `needsRootAuthorization=true`, `guardArmed=false`, Root v1.5.2/User v1.5.4**, after OS-side manager confirmation. Native local Root/installation, settings/network not mutated, Tailscale active, guard ABSENT. R625 durable op `6d2373e9-d3c1-4d2b-bef9-11393e95a9f3` SUCCEEDED stdout SHA256 `7B2264913C6E6BE0CF7BEBBDF5064EF102D1CA9BC998E09DBFEC56876660DA23`; temporary receipts deleted. This closes actual **read-only Root authentication ability**, not installation approval beyond its scope or unrestricted Commander execution. Follow exact original OS permission boundaries; no silent Root protected mutation without independent watchdog and accepted execution route. Official polkit: https://polkit.pages.freedesktop.org/polkit/pkexec.1.html . + +**R606–R617 Saeid physical Windows canary and safe-network guard — 2026-10-16:** On actual `SAEID` Windows public 1.5.4 Stable with one Ethernet default route and no Tailscale, GitHub private ephemeral R600 tested installer Artifact ID `12047616801` transferred via a time-limited authenticated file URL. Artifact ZIP SHA256 `034BDA4F01612007AD00730DD69A7DEEACDC12B8E88B3570B19186495D56CFFF`; one 154,324,327-byte RC1 Installer SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9`, no install during transfer. R606 Commander `21b75a3c-6bf4-4e96-ab07-05173eaab2a7` SUCCESS; stdout SHA256 `A8F24E6B6CDBAB47D623A5AD79DF6862DED1006734F91D8D255B368CA70D6E98`. Original public Stable v1.5.4 154,270,907-byte recovery Installer SHA256 `FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133` independently downloaded into owner-local QA path, R607 `f2c89304-347d-48cd-ae84-811a71b975db` SUCCESS. R608 UserConfig was an empty known private folder, no untracked app data/reparse nodes; provider SHA256 `D4E4B3A91252802DF33A46FE4F36067BA02A3E28974EDD23D458127E5224E0CD` safely backed up locally; routes/DNS and installed manifest baseline SHA receipt `F40726F48ADCAE7E7FC1ACDA408E2374444A72D08EDAF600FF146F31A1E99054` preserved (op `30ac6dde-2c14-4067-a5ad-935ebff91173` SUCCESS). + +**R609 actual UAC approved native inplace upgrade:** Official user desktop UAC entered by manager; exact CI-tested RC1 Inno Setup exited0. Native public Stable→1.5.5-rc1 registry updated, GUI SHA256 `F5245F7008C1B5A0A97F35B7AC0481E281A07AB73A715D9EE152DC6008510AA6`, protected Service SHA256 `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8`, schema2 manifest SHA `BA6AC9EAEE7E47E50778EBAF107A3F8BF2590F6196B71E01CE72E75EBE899297` all match hosted CI. One paired Uninstaller, private provider, adapter DNS, native NRPT, Ethernet default route and running service **UNCHANGED** by installation. Durable operation `96dea901-97dd-48fd-a223-78717f116d59` SUCCESS stdout SHA256 `40E3B28315AE6523377694BCECF9F650A01586C8F8A11870F196B0B771A13CBA`. **R610 exact installed real manifest 681/681 hashes, 481,273,295 bytes, zero changes PASS**, durable op `6ef3da84-c494-4cca-8bf1-5fca836fb885` SUCCESS stdout SHA256 `78C3A8CCA2633A4EC3FAD4F8D6B867D405E82241D8F323FB6EAF0B04B000184F`. + +**R611–R617 recovery scheduling precision:** On Saeid, Service custom controls are available to the ordinary signed-in owner. First local SYSTEM one-shot ScheduledTask Stop/Recovery pair was registered via owner OS-UAC R611 but cannot be queried by a non-admin (literal `Access is denied`). Renewal UAC was **canceled** by the user and was *not bypassed*. Original SYSTEM tasks fired once: service.log 2026-10-16T18:12:17+03:30 Start-of-Stop and finish0; 18:13:17 Recovery completed0 (no live DPI resources). An overlapping manual R612 Recovery returned `busy` safely while scheduled Stop was active; after timer completion, re-run original native code130 Recovery **exit0** with exact DNS/NRPT/routes unchanged and service Running: R613 durable `062f9465-de31-48c8-9c18-f8f2a7bda5c5`, stdout SHA256 `A74034DEF52A284B74F3CB033E3D8A40E50B8CBDD7823EA1B30929417ECD9204`. User-token TaskScheduler allows owner-created `Interactive/RunLevel Limited` tasks, principal serialized as short username. R615 manually started and verified a temporary user-level `sc.exe control DirectInternetMethodSvc 130` task; OS job exit0 and native Recovery done0, no network drift, then task removed, op `c833a8ac-68be-4403-aaf1-70e5c5f6b069` SUCCESS, stdout SHA256 `9C7C38D115180CC54C4E898229C74DFB3266638BF8720B82CDE201F185804C27`. Two one-shot ordinary-user fallback tasks R616 `DIM-R616-UserFailsafeStop` and `DIM-R616-UserFailsafeRecovery` scheduled 3/4 minutes into the future and checked ready with exact owner/SCM actions, durable op `bf24f59d-1cda-40fe-82c9-90cd38363574` SUCCESS. A separate R617 test **intentionally aborted before Start** because the first task NextRunTime was <120s away, with zero network mutation; task should be re-armed from a fresh future anchor before physical Saeid Start→HTTPS→Stop. No stage is falsely marked PASS beyond scoped evidence. One-shot SYSTEM entries may remain expired and need owner-authenticated cleanup; do not silently alter inaccessible protected tasks. + +**R600–R605 physical Windows tested canary — 2026-10-16 (unique material delta):** On Emad Windows the exact public Stable v1.5.4 offline installer SHA256 `FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133` and protected 5-file UserConfig/provider recovery snapshot were staged in owner-local `%LOCALAPPDATA%/DirectInternetMethod-Private-QA/current`, without external private-data transfer. Initial local Inno rebuilt from SDK 10.0.401 yielded nonmatching bytes; official signed **portable SDK 10.0.402** SHA-512 verified and reproduced the *exact* hosted RC1 GUI SHA `F5245F7008C1B5A0A97F35B7AC0481E281A07AB73A715D9EE152DC6008510AA6`, Service SHA `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8`, Manifest SHA `BA6AC9EAEE7E47E50778EBAF107A3F8BF2590F6196B71E01CE72E75EBE899297`, but local **Inno** EXE SHA still differs. **Decision:** do NOT promote or install local unverified Inno rebuild. + +To close artifact provenance, one private 2-day `actions/upload-artifact` step was added to existing PR **CI only** (code/product unchanged), commit `caec2c6d6cb18e7520a2024876e3620485a52371`. Exact source pushed CI [38904848013](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38904848013) **14 applicable jobs PASS, 1 release-contract SKIPPED**; native Windows hosted Stable→RC1→Stable and private settings/one-install preservation PASS before the artifact upload. Same hosted Installer 154,324,327 bytes, SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9` was downloaded with authenticated GitHub CLI and independently SHA/size/NotSigned checked; Emad Commander op `c349ca9f-e76f-41f0-b406-5a01c6a5a3c9` PASS. **NO GitHub Release was created**. + +**R601 real UAC Install:** Owner-local admin UAC request was displayed via the OS. First `/VERYSILENT` attempt returned Inno startup exit **1** with no log, Stable service and metadata intact (not accepted); a separate visible `/SILENT` OS-UAC attempt returned **exit 0**, native Inno log generated and registry updated to **v1.5.5-rc1**, no restart required. Native Emad Commander operation `a377fa48-d93a-467a-8bb7-10ee15b519da` SUCCESS, stdout SHA256 `A3B81939A6DDF1C34C3E950B65E6621ED42B89DEC0471C88D7D6E3216A9F51C8`. Owner-local recovery ZIP/config hash `65E2EBEAF2B9145285807019BC87C79A8A6D0E6A7C020027C9FD6CC3C7DDE181` retained solely for accepted rollback safety. Earlier provider "not preserved" auditor boolean was a **test-harness `-cne` inversion**: physical before/after provider SHA both `D4E4B3A91252802DF33A46FE4F36067BA02A3E28974EDD23D458127E5224E0CD`; all five private-config file SHA identical; native default routes/DNS/Tailscale service preserved. + +**R602 actual installed hash audit:** On this real Windows install, original RC1 manifest schema2 mapped User under `%LOCALAPPDATA%/Programs/DirectInternetMethod` and Privileged under `%ProgramFiles%/DirectInternetMethod/Privileged`. **681/681 managed on-disk files, 481,273,295 bytes, all exact size/SHA PASS**, no symlink/path escape and zero differences, Commander op `1e15273d-6044-497d-a227-c73ec58108b1` SUCCESS stdout SHA256 `B758741ADB28BFB5979E6325DA6C3474EFE5D3F4A829DFF47EB0CF8C2233B3F6`. + +**R603 physical lifecycle:** Original running privileged `DirectInternetMethodSvc` accepted user-defined SCM code128, native Start completed exit0 and produced owned state. Actual no-proxy physical HTTPS GET returned **Google204, GitHub200, YouTube204, OpenAI401**. Same wholly host-local durable watchdog invoked code129 Stop in guaranteed finally, got exit0; removed all owned DPI processes/NRPT/ULA/state; exact adapter IPv4 DNS, 2 default route entries, existing Tailscale MagicDNS NRPT and Tailscale Running matched pre/post. Commander op `57ec69ee-fe61-434e-8dd2-0eb09bc0fd6d` SUCCESS stdout SHA256 `637AA935995912B2F3513E0AF20FBAD4ABFBB87ADF595CFCBE552C922B4CA2`. PASS to actual Start→HTTPS→Stop recovery; does not prove video playback. + +**R604 native headless Chrome 155:** Separately activated RC1, ran disposable/no-profile/no-proxy Chrome with original browser, **Google homepage 253,894-byte DOM** and **YouTube homepage 1,052,644-byte DOM**; both exits0 with page titles present, no errors. Native code129 Stop exit0, local finally verified exact original DNS/NRPT/ULA/routes/Tailscale, no active owned processes/state, temp Chrome profiles removed. Commander op `cee40f9c-82b8-4170-a2d7-3195657897a0` SUCCESS stdout SHA256 `B7E2A9FFBCB88A069F40B1A00FA4D7BD26334726B69CFA315FA36B9CE61B5CB9`. PASS YouTube *page loading*, not measured playback. + +**R605 physical idle Recovery:** Native user-defined SCM code130 launched installed LocalSystem `Recovery.ps1`, completed exit0 on an already-clean machine, left no owned DPI resources, original DNS/NRPT/ULA/default route bytes identical and Tailscale Running. Commander op `431059df-1b16-4847-b82b-16634b19e0b2` SUCCESS stdout SHA256 `7BF74BAFD5B5F9F557B509ACCCD299979E4B4809D69F269449F050FDC81B3727`. **Scope:** clean-state Recovery command path, not injected-failure recovery. Public v1.5.4 remains official; Emad Windows private installed RC1 is not publication or whole product FINAL. + +**R594–R595 Windows Inno uninstall log ownership acceptance — 2026-10-16:** A consequential native test gap was identified: hosted `tests/r535_windows_native_rollback.ps1` assumed the only legitimate Inno uninstaller is literally `unins000.exe`. Real existing public Stable on **Emad Windows has one owned `unins001.exe` + `unins001.dat` pair**; Saeid has one `unins000` pair, and *both* have exactly one same-AppId registry registration (R581/R583). Official Inno Setup documentation states `AppId` ties matching uninstall logs and that `UninstallLogMode=overwrite` overwrites a log of the **same** AppId, not a numerically mandated basename (https://jrsoftware.org/ishelp/topic_setup_appid.htm ; https://jrsoftware.org/ishelp/topic_setup_uninstalllogmode.htm). Both public v1.5.4 and exact private RC1 manifests retain the **same 681 scoped file names**; no managed path removed/added. Because the earlier 1.5.1 uninstall log had unsafe broad deletion entries, retaining the established `overwrite` directive for this candidate is the accepted scoped behavior, not a recommendation to change it blindly. + +Original dynamic selection was verified on BOTH physical installed Windows environments with wholly ephemeral synthetic negative fixtures (R594): accept Emad `unins001`, Saeid `unins000`, reject two simultaneous pairs, reject a forged foreign `UninstallString` path, no running service/installed-file mutation. Exact real physical results: Emad op `47d2f2e2-5eb6-4329-97a9-f23ab8383cf1` SUCCESS stdout SHA256 `2DF78F83204275B3DC8A9D15C9E467F89536B49398E107C20A0EA35B3B60C4C9`; Saeid `3d9676e6-9681-4e92-85ea-7839acf1894b` SUCCESS stdout SHA256 `AEC7F51971026FFF7C1F3DCF09E99B2659607B93004E4C6E9E5A0F3CAEADDD79`. + +One test-only correction to `tests/r535_windows_native_rollback.ps1` replaced hardcoded `unins000.exe` with `ExactRegisteredUninstaller`, which accepts only **one matched regular non-reparse `unins[0-9]{3}.exe/.dat` pair** (optional same-stem message file) and requires an **exact registry `UninstallString` path match** before uninstall. Existing `tests/windows_contract_audit.py` gained a static regression assertion for this invariant. Commits `093fb2e539b9ec9ede93cfba133651ce424cc657` and `7e679f69e7aa68f29d934998b373ae7ce483fdc1` changed ONLY those two test files; **installer, user code, protected Root and private Linux ZIP bytes unchanged**. Exact updated-source PowerShell AST helper (not a retyped approximation) was parsed and run read-only against both real live uninstall registration layouts, plus synthetic duplicate/foreign/001 fixtures: R595 Emad op `64010542-1399-47a6-8d1a-d07a451cc282` SUCCESS stdout SHA256 `6AB496156B5E47215B5673B75139941C36FF06037C5982C1FCCDDC1D70BC2563`; Saeid op `9eefe6f9-85d0-4964-8ad7-e4e53b94db76` SUCCESS stdout SHA256 `5C8C87870937733B73A1198B72A76636316D9BA25A7C2CAA7E3C434EB02CED93`. Latest exact-head [GitHub Actions run 38897503440](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38897503440) **SUCCESS: 13/13 applicable completed jobs, 2 explicitly skipped**. **Scope PASS:** suffix-agnostic owned uninstaller verification on real Stable hosts and test-code regression; **still OPEN:** actual in-place RC1 install/uninstall/UAC on those hosts. Don't misclassify an `unins001` filename alone as duplicate software. + +**R592–R593 exact candidate native Windows build and self-test — 2026-10-16:** On active physical `EMAD-PC-ULTIMAT` (installed public Stable v1.5.4 service preserved), the .NET 10.0.401 SDK compiled the *exact Draft PR #60 head* `bfbbe910dcf344205d022438f02b6c2942cb38a2` from fresh detached temporary GitHub blob-filtered sparse checkouts — **never** from the two independently modified local source worktrees. First operation `ec3c420f-d9b3-424b-a846-152186f6edb2` **SUCCEEDED**, 0 warnings / 0 errors compiling `windows/service/DirectInternetMethod.Service.csproj` and WPF `windows/gui/DirectInternetMethod.csproj` Release, both generated `1.5.5.0` assemblies: service DLL SHA256 `6080CDCDB6114C83B21852591EDBE7C403C75C98A8F8599481F0C91619A0847F` (75,776 bytes) and GUI DLL SHA256 `F53A8C6D0F49067A019C01C7774952226621BECA42B69628E4284A0480797392` (168,960 bytes). Temporary clone/build was scope-owned, deleted, installed Stable service/GUI never touched; stdout SHA256 `299CCF9924D8ED0A615BE6277BA976BFA4DE7426D54D97A8EB1417F8EF2CA9`. Second independent operation `99008089-cd6b-47e8-b340-ded565145007` **SUCCEEDED**: rebuilt exact-source RC1 Service WinExe on same physical host in a new sandbox and copied the exact three original `Start-Direct.ps1/Stop-Direct.ps1/Recovery.ps1` to that temporary executable's expected folder. Calling the untouched binary's builtin **`--console-selftest` returned exit 0**. Moving *only temporary* Recovery.ps1 out gave **expected fail-closed exit 20**; restoring it returned **exit 0**. This verified the original candidate Service's real process entrypoint, missing-backend refusal and recovery of the positive state without installing or invoking privileged service control. Built temporary executable SHA256 `D7FE1B1AD7716262D76CE0CAE1A551D577DD373DD0DFE85C1F3F424158BB35DF`; original installed Stable privileged binary SHA, installed user manifest and existing Windows service status were read back unchanged; sandbox cleaned. Durable operation stdout SHA256 `0C0BDC43DAE6235A114C9BC8F865B7D3F5EE813C57CC997852A45D75B1B48A5A`. **PASS narrowly for exact current RC1 local native compilation and Service self-test, NOT signed/installer release, real UAC, protected in-place install, GUI interaction or live HTTPS**. Parallel latest hard local Admin/PolicyKit route on MMZ remains denied by server allowlist; no attempt made to route blocked `pkexec` through shells or GUI input. Official .NET target project sources are in this same PR and hosted source CI already passed; host-specific private Linux and stable deployments unchanged. + +**R590 exact physical protected Root source baseline and administrative boundary — 2026-10-16:** With exact unchanged private 1.5.5-rc1 Root source SHA256 `EB107164CCEDE891C8BF41C06FF422F1B53B59D2D2DDF0B3DD23EBFE5DE9601B` and source guard SHA256 `6E9FA25F7D3ADEBC6451DB81A7436467F47F9C2F6B34554951AC375F92BE9B1C`, executed the **original `RootTransaction()._baseline()` read-only method against actual MMZ `root=/`** as the nonprivileged owner, after exact host and source SHA preconditions. `_baseline()` verified installed privileged metadata version **v1.5.2** and actual owner, **9/9 pinned static protected members**, **2/2 old versioned files matching pinned SHA and POSIX mode**, **2/2 reserved new target files absent**; Root/User metadata sha, `/etc/resolv.conf` bytes+symlink, IPv4 route, physical DNS and Tailscale DNS/service were byte-identical before and after. **PASS_ROOT_BASELINE_READONLY; no `stage`, `apply`, Root action lock, `pkexec`, network or installed mutation.** Commander operation `64fd886b-1d88-43db-9d4a-d8389d1f57b9` **SUCCEEDED** stdout SHA256 `1EC4FB2B11C630AB95C6785CA0AF787CAA3603300C7D4AFEC5E43801B745E86C`. A separate native direct Commander `pkexec --version` capability probe (nonmutating) was still rejected by the server's own execution policy with **`program not allowed: pkexec`**; this is not an OS prompt, credential request, or authorization. Official Polkit `pkexec` requires a registered authentication agent in the caller's session and administrator approval; its error code 126 denotes dismissal, 127 inability to authorize. Reference: https://polkit.pages.freedesktop.org/polkit/pkexec.1.html . **Real guarded install remains blocked pending authorized local owner action.** + +**R591 actual MMZ systemd 259 compatibility of exact R568 independent Root/User watchdog — 2026-10-16:** Loaded unchanged `system/upgrade_guard.py` under MMZ user scope and invoked its original `unit_templates(deadlineUnix)` with a future UTC +480s deadline, yielding **289-byte Service** and **241-byte Timer** in one disposable /tmp directory. Verified `OnActiveSec=480s`, frozen `OnCalendar`, `Persistent=true`, `AccuracySec=1s`; the **actual MMZ systemd 259 (259.5-0ubuntu3.4)** `systemd-analyze calendar` accepted the UTC deadline and `systemd-analyze verify ` **exit 0 with empty stderr**, without installing or starting either unit. Source artifact, installed Root/User baseline, guard and network untouched; temporary files removed. Commander operation `037044ad-92df-40db-a4a8-c0da7614f565` **SUCCEEDED**, stdout SHA256 `CCF3689A86872B9712DA0E7F2EA9EA3252DB9DF1DA81713F703191E4B111ED6C`. **PASS for exact physical-host systemd unit syntax and UTC parsing; not physical timer firing, reboot or successful Root rollback.** systemd primary documentation confirms `Persistent=true` catches missed `OnCalendar` after timer inactivity and `systemctl clean --what=state` removes its stored last-trigger stamp: https://github.com/systemd/systemd/blob/main/man/systemd.timer.xml . + +**R591 Emad Linux live state divergence / independent safety blocker — 2026-10-16:** Read-only imported **currently installed v1.5.4** source `~/.local/share/DirectInternetMethod/direct_internet_method.py` (no subprocess/GUI mutation) and called original `load_state()` on Emad Linux. Source-facing native state.json still says `status=ACTIVE` and physical interface **`wlp2s0`**, but live route uses **`enp1s0`**. Original UI reports **`mode=CONFLICT`** (not healthy ACTIVE); `_external_tunnel_active()=true`, `_tailscale_split_safe()=false`, `_coexistence_backend_ready()=true` on a host with active system `directinternetmethod-nfqws-1000.service` and Tailscale service. `nmcli -t -f TYPE,DEVICE connection show --active` reports a separate **active `vpn:enp1s0` profile** along with ethernet/wifi and Tailscale `tun`; this connection type is a genuine VPN-class NetworkManager active profile per official docs https://networkmanager.pages.freedesktop.org/NetworkManager/NetworkManager/nm-settings-nmcli.html . This presence explains the original conservative tunnel conflict **without** proving which VPN provider or whether the physical interface drift alone caused it. Real user code/state/resolver SHA before-after unchanged. Do not silently stop VPN/Tailscale/NFQWS, rewrite state.json, or trigger unattended recovery because the independent connectivity/recovery path is not attested. **Emad Linux live healthy connectivity remains OPEN** and needs an owner-attended, confirmed independent channel before native Recovery and re-Start. + +**R589 two physical Windows Stable service self-test and public release discovery provenance — 2026-10-16:** On actual `EMAD-PC-ULTIMAT` and `SAEID`, direct on-disk `app/DirectInternetMethod.Service.exe` (under the protected Program Files install root) matched each installed manifest's **public v1.5.4 SHA256 `2AE7EA11A94BFA3187BC7CDF63A37CB5CD5B55C313C3966330FD280ECC9ECE41`**. The unmodified real service binary's verified read-only `--console-selftest` completed **exit 0** on BOTH hosts, with separate existing `DirectInternetMethodSvc` left untouched. Independently, standard Windows `curl.exe` on BOTH computers successfully fetched the official `GET https://api.github.com/repos/GOD13emad/DirectInternetMethod/releases/latest` (no auth, normal TLS verification and short bounds): `tag_name=v1.5.4`, `draft=false`, `prerelease=false`, exactly **one Windows Installer asset** and **one SHA256SUMS.txt asset**, GitHub API Windows asset `digest=sha256:ffb9a2ae19c2e89338ee12ee8da133f5a16dbf4dcf961678c33d77d0c3843133`. Both independently fetched ONLY the small official `v1.5.4/SHA256SUMS.txt`, SHA256 `68E70329E8B9DAC6B738CE9B90D99A0010030980CD9C17CDE45A5199A60692D8`, and each found exactly **one** Windows entry SHA256 `FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133` matching the asset digest (two total checksum lines). Native direct-host self-test/API operator runs Emad `10fc052e-2993-4200-8301-005eb25ea8d9` stdout SHA256 `4D96497FB13B8A26AA67209DF82A5121D287AC0B74378F352BB7E48EC01E8913`; SAEID `822e19b5-fbe8-47fc-8f31-8fc3f383c808` stdout SHA256 `F87C473761770D912818B51296A14355B07BBCA9F25E3A5B1F64BB2E39A67C2C`. Corrected official-checksums native ops Emad `f9106279-3224-4f36-8f58-88c829dbfb46` stdout SHA256 `9C7D6F25A0D094C8C64CEB0DDDE12CB7E598F3518D78213153ED381BA7C52F52`; SAEID `91b70689-faf3-44dd-9b5b-7203cd557a22` stdout SHA256 `672B5672F17D590C47250F0F4989B183AC4C89F9B2B5630A677CE0AD32A09D2A`. Earlier checksum attempt failed solely because a PowerShell report literal `publishedStable=v1.5.4` was unquoted; **not a product or transport failure**; clean corrected operations passed and owned temp JSON/text files removed. **Gate PASS only for two actual native installed Stable services' local self-test, latest public release API discoverability and official SHA256SUMS/API digest consistency**; no installer binary was downloaded, no GUI update button, production service `CTRL_UPDATE`, protected update handoff or native RC1 UAC install was executed. Do not infer online-update completion from a read-only request. Public Stable remains v1.5.4 and Draft PR #60 unopened for promotion. Official release checksum URL: https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/SHA256SUMS.txt . + +**R588 native exact RC1 user Uninstaller synthetic HOME preservation — 2026-10-16:** On physical MMZ with installed User v1.5.4/Root v1.5.2 and Tailscale preserved, the exact staged **R568** `app/user_uninstall_preserve.py` SHA256 `6691AA7262307F41DDAD1B5EB409E1C7AAC3B2171B5285156045849D969F6B8A` was imported into a one-time **disposable, owner-only synthetic HOME**. Its unchanged public `verify`, `load_owned`, `remove_after_backend` and `remove_known_files` executed against synthetic version 1.5.5-rc1 `INSTALL.json`, real current code payload bytes, four synthetic launcher files, synthetic private settings/custom hosts/Router Gateway provider data. **6/6 PASS:** exactly **11** owned code/launcher files recognized without mutation; `--remove-after-backend` rejected while real protected backend still exists, with zero files changed; deliberately modified owned program source was rejected without deletion; after restoration, original bounded `remove_known_files` deleted only the 11 known synthetic code/launchers + synthetic INSTALL metadata; four classes of synthetic private data and its application directory remained byte-identical, and temp HOME was removed. First harness invocation `a0fd5f29-c092-4170-a2b6-4805027c04db` failed because harness-generated expected source SHA was **uppercase** whereas the original source contract uses lowercase, a synthetic-fixture mismatch and NOT a product regression. The corrected run `3d4ca48f-21b5-4904-96e1-d7fae28609ae` **SUCCEEDED exit0**, stdout SHA256 `80BCC06BF633B5ADB8E8384814EDFFD03867B0FEC8FAF0817139A6A50C707303`; real Root/User metadata, default route/resolver/Tailscale before/after unchanged. **PASS scoped to original current-RC1 uninstaller user-file ownership and synthetic preservation logic only**; installed Root uninstall, GUI, UAC/PolicyKit and user acceptance remain OPEN. Previous exact R587 documentation-only CI [38883252033](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38883252033) **SUCCESS**, skipped native Windows Release job not a new install PASS. + +**R587 exact pinned 1.5.5-rc1 real physical-host User transaction, disposable HOME — 2026-10-16:** On MMZ User v1.5.4 / Root v1.5.2 with DIM OFF, no guard/consent and active Tailscale, the **R568 29-member ZIP** was deterministically rebuilt solely in private temporary memory/file storage from independently SHA-pinned R562 metadata and 29 current staged package files; exact 8,370,720 bytes / SHA256 `9380C8C7DCD4A0292CE7B4534B8F269C6461500D3CE89C1B80D1EB8E998A46AC`. The previously reviewed original `R557_USER_SHADOW_TRANSACTION.py` (SHA256 `694FCD6CD966A9620E5101B178CFB7D37BAC73B81991B0AB22241CE4712435CF`) ran on the same *real MMZ host*, with **only test source ZIP/expected digest and disposable QA/receipt paths** changed for R568. Actual unmodified `app/user_update_transaction.py` exercised a synthetic private HOME seeded from exact published Stable v1.5.4 ZIP SHA256 `4784FCD44ED3D1B40103BCA140B2C0066784F794D07C3AE1F525334766A553C7`. **Eight of eight native user-code scenarios PASS**: Stage, no early code/config overwrite, Apply exact source hashes, metadata attestation, byte-identical Rollback, fail-closed on concurrent mutable settings drift, preserve custom provider/private settings/custom hosts, no accumulated backup slots after a second cycle. Synthetic receipt SHA256 `5672EF4A16067113835102359F77FDDACF372D5B06AB376F45383E9812F8A4D4`, Commander operation `f5bf8bd9-d520-4aaf-84e9-19574d684852` **SUCCEEDED exit0** and stdout SHA256 `FEBDA1196FFF4FB7F343A1BE98D4341F233D30FCFC13456FB12C7295CEF91038`. Temporary source ZIP and synthetic HOME/receipt were deleted; actual user/Root metadata, resolver, routes and Tailscale remained unchanged. **PASS scoped only to exact-current-R568 user transaction in synthetic HOME on physical host**, NOT production User or Root installation, independent native rollback, GUI/HTTPS/DoH consent or owner approval. + +**R584 current-RC1 actual encrypted target-DNS transport — 2026-10-16:** On live `mmz-LOQ-15IRX9` running production Root v1.5.2/User v1.5.4 with DirectMethod OFF, an exact physical-host **user-local** execution loaded unchanged R568 1.5.5-rc1 package `CURRENT_PRIVATE_RC1`, verified **all 27 payload SHA-256 entries**, `runtime/ctrld v1.5.7` SHA256 `CA64579A2BC866CF72F3E46663E551162AFEFF0D448E899A01817A095E45B071` and policy source SHA256 `BCADC01C4A0DEDA0AB7C678155BE6F6960D3B68D9CFE1307DF85A94F18785663`. The exact bundled binary ran with the original policy's `build_secure_targeted_dns_config` over UDP **127.0.0.1:53587 only**, no live resolver or link changes. Actual `dig` A results: `www.youtube.com` **8/8 public global IP**, `redirector.googlevideo.com` **1/1 public global IP**, unrelated physical-router fallback `www.cloudflare.com` **2/2 public global IP**; all exit0 while isolated ctrld remained alive. Test-owned process terminated, port verified free, temporary HOME/config removed, live Root/User metadata, resolver SHA/symlink, physical route/DNS and Tailscale fingerprint **identical pre/post**. Commander durable operation `0451febf-d290-430a-b7a2-0c6f069d3fa1` **SUCCEEDED**, stdout SHA256 `7D86C74C7D703EB5C110139A007BE1C0395132D54FA19B0701B350EB97E1652F`. **Gate PASS restricted to actual unprivileged RC1 DoH transport**, not system-integrated per-link DNS, physical YouTube HTTPS, installation, or user consent. + +**R585 exact source real physical per-link controller preflight — 2026-10-16:** Ran *unmodified functions* `network_preflight("enp7s0")`, `runtime_tunnel_guard("enp7s0")`, `policy_bytes(router)` and `require_consent(uid,home)` from exact staged `system/encrypted_selective_dns.py` SHA256 `6DA9BAB94E1F4AF5C9FBE0742F96B3E2B5A0392294B27EDA3F4012095D9993DA`. Only the module's constant paths pointing to the **uninstalled** root-owned binary/policy/controller were rebound **in process memory** to the exact immutable *staged* files; all actual network and owner checks queried real MMZ host. Real network route `enp7s0`, single RFC1918 physical resolver, Tailscale split mode, no foreign tunnel/collision and dynamic selected policy validated. Owned private interface policy SHA256 `32E697BDE3254AA774F80A6730B9C674CE89D411938D2FF1B22516366C6E9B99`. The **real current user consent token was absent** and original consent guard returned `FAIL_CLOSED_EXPLICIT_ENCRYPTED_CONSENT_MISSING` as required. Native pre/post Root/User hash, resolver/link, route, physical and Tailscale DNS identical. Commander operation `e1563a56-3cf3-4b32-ae8f-5a38a582e3bc` **SUCCEEDED**, stdout SHA256 `4B52468882A5A71C026A2683A6A66C77EF39D387A61C912385ABEBEC48B953AD`. **PASS read-only native-state/consent-negative preflight only**; no Root authorisation, installed source, SetLinkDNSEx, systemd timer, GUI Start/Stop or actual physical HTTPS approval. + +**R586 two-host v1.5.4 installed manifest → public tag provenance — 2026-10-16:** Independent connected GitHub fetch of `windows/manifest.json` at exact **public v1.5.4 tag** was structurally compared in memory (all **681 scoped records**, including SHA/size) with two actual on-disk Windows installed manifests. **All 681 records and every top-level field match** across public tag, `Emad-PC` and `SAEID`; both installed files are byte-identical SHA256 `9BF9CBAC2588484DD237559CB0F1FA7D343BE52ABEFC07963BCB3654F51BCD6F`, 198,827 bytes. Public repository file uses LF line endings and installed copies use CRLF; normalized text/parsed JSON identity are exact, but **raw public tag blob is NOT byte-identical**. Coupled to R583's independent **681/681 actual installed file SHA matches on both machines**, this extends scoped authenticity to the public tagged source manifest, without claiming signed-publisher authenticity or an installed RC1. `RELEASE.json` inside both Windows Stable installs is mutually byte-identical but **differs in structure/content from public tagged source** (`CANDIDATE_V154_UNPUBLISHED` installed vs `CANDIDATE_V154_UNPUBLISHED_UNPROVEN` tag). Treat this as existing historical build/release metadata drift, **not an installed manifest or program checksum failure**; provenance tests use exact tagged manifest, not the differing release descriptive metadata. Both Windows installed apps and services unchanged, no settings or OS mutations. + +**Research/decision R586 — current upstream ctrld:** Upstream `Control-D-Inc/ctrld v1.5.8` dated **2026-10-07** includes DNS64/DNSSEC AD-bit behavior hardening and redaction of upstream keys from retained logs; prior physical R534 rootless loopback testing of its exact binary PASS applies only to that separate binary. Current R568 candidate intentionally pins **ctrld v1.5.7** and the current physical MMZ IPv4-only targeted fixed endpoint policy carries no operator-defined upstream key. Therefore no unverified source/binary replacement was performed: swapping v1.5.8 now changes package hash, dependencies and activation acceptance without directly solving the proven YouTube TLS reset. Retain the upstream security update as a version-scoped next candidate task if its affected features apply; current Release RC1 remains **unpublished**. Primary references: https://github.com/Control-D-Inc/ctrld/releases/tag/v1.5.8 and https://pkg.go.dev/github.com/Control-D-Inc/ctrld@v1.5.8 ; external release synopsis https://newreleases.io/project/github/Control-D-Inc/ctrld/release/v1.5.8 . OS authorization remains native PolicyKit with owner-entered credentials; cannot be assumed from Commander full-filesystem access; official reference https://polkit.pages.freedesktop.org/polkit/pkexec.1.html . + +**R582→R583 exact two-host Stable v1.5.4 installed-manifest acceptance — 2026-10-16:** Before attempting any unpublished Windows RC1 upgrade, original **v1.5.4** installed `manifest.json` (schema 2, 681 entries) was read directly from both live Windows machines. First read-only R582 script incorrectly assumed every `scope=user|privileged` manifest path was under the user installation root; this gave 7 matching user members and 674 apparent `MISSING` on **both** hosts, solely because those 674 files reside under a different legitimate path. This was **test harness failure, not a product defect**; no product repair was performed. Official immutable v1.5.4 Inno source `windows/installer/DirectInternetMethod.iss` confirmed `user` files live at `%LOCALAPPDATA%/Programs/DirectInternetMethod` while `privileged` files live at `%ProgramFiles%/DirectInternetMethod/Privileged`, with same Inno AppId and one active Uninstaller. Corrected R583 strict no-reparse/no-path-traversal, scope-mapped SHA256 and size audit then matched **681/681 entries, zero size/hash/missing failures, 481,191,083 verified bytes on each of Emad Windows and SAEID Windows**, no service or installed-file mutations. Emad native operation `9bcc822f-2be0-4a99-97d7-df252fa11482` **SUCCEEDED**, stdout SHA256 `fa2df0eaac29b3e578d4fc00d2dc57b615ec5893ece5a0d47e8b5dd162551571`; SAEID op `2769a706-7329-4520-a742-13bdd47580ab` **SUCCEEDED**, stdout SHA256 `175ae2903fb75e24453b623a5f51c4e7d68521813206fe5343876efeae437bb0`. **Gate CLOSED:** both native public Stable install files match exact installed manifest, with one uninstall registration/pair. **Not proven:** actual native RC1 upgrade preserving mutable user settings or Windows UAC/network lifecycle. Stable source reference `https://github.com/GOD13emad/DirectInternetMethod/blob/v1.5.4/windows/installer/DirectInternetMethod.iss`. + +**Current candidate R562 (2026-10-16) — Root/User accepted-commit crash recovery, hosted scope PASS, physical scope OPEN:** A formerly unresolved finalization window was closed in the *private* 1.5.5-rc1 source: after a privileged accepted Root transaction becomes durably `COMMITTED`, an interrupted pending User commit is resumed by the separate root-owned systemd worker **only after** immutable Root manifest, installed protected SHA/metadata, User version/metadata and owned backup-slot validation; an unknown actor, changed Root binary or mutable user-data mismatch fails closed and retains recovery evidence, without rolling back an accepted Root or altering networking. Existing pre-acceptance two-sided rollback remains distinct. New guard SHA256 `3B4900DB3251A81BF5776EE19D63A4CCDC032585DEFCC22DDFE06331B44BADD3`, wrapper SHA256 `28D421478D24E668A126B4A26BE5C49ACDE958C4DC3BE91E2901E0D50BF760A6`, exact Linux ZIP SHA256 `B2960E957D2314F43DA719AAFE097E48FDD3CAA647A02844333818DC50A37471` (8,363,886 bytes, 27 payload/29 ZIP members). The extracted method tests **18/18 PASS**. Exact code/QA head `07ca88f04fd572480be8cbce246f011b0fc1c68d`: [PR CI 38819270971](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38819270971) **SUCCESS**; [Push CI 38819266213](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38819266213) **SUCCESS**, including native Windows private Stable→RC1→Stable preservation/rollback; Setup SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9` and acceptance receipt SHA256 `6A59269F19270C69B634BC41CB9FFB912B75BC73240ABE9261E9F395EA144BA7`. The subsequent exact-head Brain-only [Push 38819709423](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38819709423) and [PR 38819715922](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38819715922) also **SUCCESS**, with no candidate product-code change. Both separate GitHub-hosted systemd workers produced `PASS_EPHEMERAL_SYNTHETIC_ROOT_ACCEPTANCE_USER_FINALIZATION` with `syntheticRootAcceptanceOnly=true`. The acceptance fixture is *fabricated solely for the disposable transaction interruption test*, **not** real Start/Stop/Tailscale/DoH or production release approval. Public Stable v1.5.4 and installed hosts untouched. + +**R563/R564 physical MMZ read-only scope:** The exact R562 ZIP was reconstructed in RAM from prior pinned R559, atomically staged privately at `R541_QA/R562_PRIVATE_1.5.5-rc1_Linux_x86_64.zip` (0600), with receipt `R541_QA/R563_PRIVATE_R562_READY.json` SHA256 `EDAF35749FE6812F872ADBD105607A7269BD3AF32D06227A7A295D3B88F4C856`. The actual R562 `upgrade_guard.py preflight` executed unprivileged on MMZ; six checks including mutated Root/User source rejection, wrong archive layout, nonroot arming refusal and physical pre/post data equality **PASS**. Unique `R541_QA/R564_NATIVE_GUARD_PREFLIGHT.json` SHA256 `EF4517B4DAD69475D274F5DD0525832EF260E84E34F8D0F4A64F002BB91DF2CA`. Installed User v1.5.4, Root v1.5.2, route/DNS/Tailscale, consent and owned network resources unchanged. **Neither Root permission nor actual guarded physical install was obtained.** The Remote Commander direct `pkexec` and `notify-send` programs were blocked by its allowlist (observed 2026-10-16); GUI takeover permission does not confer Root privilege, and the MMZ conversation is unbound. An attempted single text-only admin approval request write was **NOT_CONFIRMED** because the MMZ tunnel client ceased polling; the subsequent system-status probe also failed. No administrative prompt was displayed or approved through these tools. An authorized host policy change or native owner-approved privilege workflow is required. No bypass by another execution path. + +**Current R568 candidate (2026-10-16), HOSTED NATIVE PASS / PHYSICAL ACCEPTANCE OPEN:** A real Linux lifecycle gap was closed in private source: after accepted two-sided Root/User `ROLLED_BACK`, the retained privileged audit slot and guard systemd units would prevent a subsequent in-place protected upgrade. R568 implements explicit **operator-attested** `upgrade_guard.py retire-rolledback --receipt-sha256 ` (never autonomous deletion). It fail-closes on wrong SHA, foreign nodes, symlinks/hardlinks, unknown owner, nonterminal/COMMITTED/DEGRADED Root, User/data/DNS drift or active network engine. After hash-verified `PASS_AUTONOMOUS_TWO_SIDED_RESTORATION`, strict known Root/User baseline, and inactive service, it disables the timer, invokes official `systemctl clean --what=state` for the `Persistent=true` stamp, and removes **only whitelisted** owned snapshots and units; preflight then allows another protected upgrade with the preserved one stable installation and private settings. Official systemd persistent stamp documentation: https://github.com/systemd/systemd/blob/main/man/systemd.timer.xml . The real operator must record receipt evidence externally in the single Project Brain before invoking retirement; future human-host cleanup is NOT authorized or completed. + +Exact new source guard SHA256 `6E9FA25F7D3ADEBC6451DB81A7436467F47F9C2F6B34554951AC375F92BE9B1C`, wrapper SHA256 `2584FDA48091458CC93CF89E74A2A03CE62108A2C61E93A58799F954E0030139`, reproducible private Linux 27-payload/29-member ZIP SHA256 `9380C8C7DCD4A0292CE7B4534B8F269C6461500D3CE89C1B80D1EB8E998A46AC` (8,370,720 bytes). Source commits `ccd18c16de1ab3de1aca97b72a4096bdac40ed83` plus QA-only report fix `98e087fdfe5fd3e1abfde42a81ad881f32313dd7` reached [PR CI 38827562274](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38827562274) and [Push CI 38827556470](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38827556470) both **SUCCESS**. Exact native Ubuntu Root/User Stage→Apply→rollback→operator-retire tests PASS independently on two GitHub-hosted runners: wrong SHA and synthetic foreign entry were refused, trusted receipt retirement and systemd stamp cleanup succeeded, and post-retirement `upgrade_guard.preflight` confirmed `repeatUpgradeSafelyUnblocked=true` with protected baseline, /etc/hosts and resolver byte-unchanged. Source-extracted 18/18 tests PASS. Windows private hosted Stable v1.5.4→RC1→Stable native rollback PASS with unchanged deterministic Setup SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9`; unique acceptance receipt SHA256 `0AAC4020DE11175388F59DD971D51AF7CEF73E341039C4206B229623E610B693`. One separate user-owned 39 MB scratch clone on Emad Linux was used for read-only-by-design packaging tests; no running DIM, Tailscale, DNS, Root or public release was modified. Prior R562 ZIP `B2960E957D2314F43DA719AAFE097E48FDD3CAA647A02844333818DC50A37471` is historical accepted scoped evidence, superseded for **new** private canaries by R568. **Physical MMZ deliberately OFF by owner; no Root authorization / guarded RC1 installation / GUI DoH or HTTPS or Tailscale / real reboot acceptance.** Remain PR #60 Draft and v1.5.4 public Stable; no FINAL claim. + +**R569 exact R568 RC1 physical rootless reproducibility/preflight — 2026-10-16 07:11:44–07:11:50 UTC:** On newly reconnected physical `mmz-LOQ-15IRX9`, a temporary non-root checkout of exact Draft PR #60 head `0bd88e96705909bd630279487b909037ef220ab7` rebuilt 27-file Linux v1.5.5-rc1 candidate ZIP **8,370,720 bytes / SHA256 `9380C8C7DCD4A0292CE7B4534B8F269C6461500D3CE89C1B80D1EB8E998A46AC`** byte-identically to pinned R568. Original `tests/r519_versioned_package_contract.py` **15/15 PASS** and `tests/r552_native_upgrade_guard_contract.py` **18/18 PASS** on this physical host; extracted unmodified `system/upgrade_guard.py preflight --source` returned `SAFE_SOURCE_AND_INSTALLED_BASELINE_ONLY` with **installed Root v1.5.2 / User v1.5.4, guardArmed=false, needsRootAuthorization=true**. Pre/post hashes of installed Root/User metadata and `/etc/resolv.conf`, IPv4 default route, active Tailscale, absent DIM State, pending Root/User slots and guard were **identical**; no privileged call, network/service/root/installed mutation or real DoH consent, and the disposable /tmp checkout was removed. Full-completion Commander operation `bff19b4a-2289-460c-8db2-9a1f04e98e8a` exit 0, stdout SHA256 `5d03f5094d07cefd228afb152524a2cbd5a2fcd432e323508fac0a576a89e406`, stderr SHA256 `d28dbc153e1eb4ffe5c4b35a3ef2ad996e03e99dc9b772db3d6d65b2311983c6` (original logs retained in MMZ Commander durable operation store). **Scope PASS:** rootless exact-version package and physical preflight; **still OPEN:** approved production Root watchdog, protected real upgrade, explicit encrypted-DNS consent, native GUI Start/Stop, physical HTTPS/Tailscale recovery, real reboot and owner acceptance. MMZ is reachable by connector again but privileged Polkit invocation remains unavailable through the existing allowlist. Preserve public v1.5.4 Stable and PR #60 Draft; do not promote to FINAL. This is an evidence-only Brain delta and changes no product bytes. **R569 Windows physical direct-HTTPS baseline (same date, read-only):** Both real Windows hosts `Emad-PC` and `SAEID` registered installed **v1.5.4**, with `DirectInternetMethodSvc` Running but `ctrld` Stopped and no `winws` or `ctrld` process observed; this is *not* proof of the DPI engine being ACTIVE. Independently, `curl.exe --noproxy '*'` with normal certificate validation returned **Google generate_204 HTTP 204 / exit 0** and **GitHub HTTP 200 / exit 0** on *each* host, while **YouTube generate_204 HTTP 000 / TLS exit 35** on *each* host. Source candidate 1.5.5-rc1 was NOT installed or activated, so this observed network failure cannot be attributed to the candidate; native DPI/DoH Start→HTTPS→Stop still OPEN. No process/service/network/settings mutation. Durable Emad Windows op `b47f75e0-36fb-4f9d-b95f-f98e49ebbb53` stdout SHA256 `9870d9f382676fda54575a9391d45403c872030027b5e20bef02271cba263649`; SAEID op `7606ee0b-9a46-48c8-842a-73dcb2e90af0` stdout SHA256 `8b268ed294818ebc4ba01701f180d0bb6326c583c2afb8c313885a6f4b068a5e`. The previous R569 evidence-only GitHub CI was SUCCESS (run 38865953754). + +**R570 physical GTK4 latest RC1 isolated behavioral regression — 2026-10-16 08:05–08:06 UTC:** On real `mmz-LOQ-15IRX9`, an exact temporary GitHub clone of PR #60 head `93cc060b5ee0b525b44efca06cda69a5ee0e9784` reproduced the **unchanged** 27-payload Linux 1.5.5-rc1 ZIP SHA256 `9380C8C7DCD4A0292CE7B4534B8F269C6461500D3CE89C1B80D1EB8E998A46AC`. The current packaged GUI `app/direct_internet_method.py` SHA256 `732190BEF316C899D58EF6B4B47BD9DFC86BF761AC4A6BA5962FB0FEA27032F8` ran as an actual GTK4/Broadway process under a freshly disposable HOME/private D-Bus, with the existing reviewed R542 15-case harness **source- and app-path-rebound to current exact SHA**: **15/15 PASS**, including synthetic DoH consent/revocation, ACTIVE selected DoH, route-drift STALE, conflict detection and no outgoing HTTPS on route drift. In parallel on the same exact current source, original AST GUI periodic-status `tests/r545_periodic_gui_status_contract.py` **6/6 PASS** and real-source route-drift `tests/r541_route_interface_drift_contract.py` **11/11 PASS**. Harness-only initial failure op `1af5c1fa-27b9-4c1a-9704-4165a5376c14` was traced to a mistyped historical source SHA in the temporary harness substitution (test aborted before invoking GUI); corrected SHA binding and rerun succeeded with operation `ce3db80a-ce93-42ba-ba35-7000288e3076`, exit 0, complete stdout SHA256 `ce622d4e4aaae3c12e4e9fd3ace33509e90040cec7ead1919377b8cfcbe23b6b`. Temporary Git clone/source/extracted package/receipt and BroadWay instance were cleaned; harness verified physical DNS SHA, default route, Tailscale, installed v1.5.4 User and actual preference hashes identical pre/post, and no real User consent. **PASS scoped to physical-host isolated current GUI; not a real installed RC1/Root watchdog/native Start/Stop/YouTube HTTPS acceptance.** Keep production installs v1.5.4 and PR Draft, native privilege boundary intact. + +**R571 Saeid Windows independent source QA transport timeout — 2026-10-16 08:08–08:10 UTC:** On real `SAEID` Windows with unchanged public v1.5.4 registration, `DirectInternetMethodSvc:Running` and `ctrld:Stopped`, isolated non-install operation `2a54e59c-090f-4ffa-b933-ca178271535c` attempted to fetch exact source head `2382050f42c599fc9a22ea86e04b5e0b0cf2b68e` solely into a new random user TEMP directory for three source checks (.NET 10, Windows contract, release metadata). It **TIMED_OUT after 120 seconds inside Git clone**, before head verification or ANY of the three tests. Do not mark these native checks PASS or infer a product regression; clone transport's precise cause is UNVERIFIED. The only initial stdout confirmed host/installed baseline, SHA256 `8e8297a1af95807819b23dcc25958a80581a113232868e05d2e99d81887f40aa`. The exact operation-owned TEMP directory was located by name/creation time; no git process remained, symlink/reparse checks passed and guarded cleanup succeeded. A subsequent readback confirmed v1.5.4, unchanged services, and absence of the scratch directory. No app, service, DNS, route or release mutation. Hosted exact R570 documentation-only [CI 38871628438](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38871628438) **SUCCESS**, including Windows-source, Linux-source, root transaction and v1.5.4 regression jobs; skipped unsigned Windows-release job is NOT a physical/installer PASS. Native SAEID source QA is **UNRUN/TRANSPORT_BLOCKED**, not an additional necessary release gate beyond already-passed hosted source checks. Public v1.5.4 and Draft PR #60 preserved. + +**R572 Windows Git-transport isolation — 2026-10-16:** On SAEID Windows `git ls-remote --heads` quickly verified exact PR head `930d82071a52c87c97f3e84ad6111e8842b67297` (Commander op `40da3866-08e2-4c88-85a2-8826716abb8b`, SUCCESS). Changed to filtered sparse clone in *unique private user TEMP*; commit identity verified but sparse source materialization again timed out after 90 s **before** three native source contracts started (op `7b80c0d5-04da-476c-95f5-41c359c4462b`, stdout SHA256 `bac8077fbbfe5681c4d4c8be67aa65ac0561963e90a57d1485de052320d6ef6a`). Different read-only command audited ownership/time and absence of git processes/reparse nodes; only its exactly named TEMP clone was deleted and absence verified (op `5305a03c-89ce-4027-b724-3fac78da6ad8`, PASS). Installed v1.5.4 and services unchanged. **Native SAEID three-source QA UNRUN due materialization transport; not evidence of a product bug.** Existing hosted Windows-source and private Windows Stable→RC1→Stable gates remain their own scoped evidence. Do not retry large remote clone without a materially different delivery method. + +**R573 bounded clone failure → R574 verified alternative package — 2026-10-16:** An owner-local temporary checkout of the exact R568 private source on MMZ timed out under Git transport without producing or publishing a package (Commander op `364f738e-ccd2-4c99-95e7-ec407688f371`; temporary directory cleaned, installed host unchanged). A controlled alternative used the **existing SHA-pinned R562 ZIP** `B2960E957D2314F43DA719AAFE097E48FDD3CAA647A02844333818DC50A37471` (8,363,886 bytes) and **only two proven changed payloads**: `linux/system/upgrade_guard.py` SHA256 `6E9FA25F7D3ADEBC6451DB81A7436467F47F9C2F6B34554951AC375F92BE9B1C` and `linux/system/upgrade_system.sh` SHA256 `2584FDA48091458CC93CF89E74A2A03CE62108A2C61E93A58799F954E0030139`, fetched via GitHub connector at exact PR head and validated with the GitHub commit diff. A one-shot in-memory deterministic ZIP rebuild verified **all 27 payload hashes, 29 member names, no traversal/symlink and exact R568 8,370,720-byte SHA256 `9380C8C7DCD4A0292CE7B4534B8F269C6461500D3CE89C1B80D1EB8E998A46AC`**, matching the independently hosted R568 build. Only its one extracted, non-installed candidate directory was published with owner-only parent (0700) at `/home/mmz/source/repos/DirectInternetMethod/R541_QA/CURRENT_PRIVATE_RC1/DirectInternetMethod_1.5.5-rc1_Linux_x86_64`. Exact `PACKAGE_MANIFEST.json` SHA256 `8BA59FFB12F1A1253B4F3A9DD83079CCCAB1418D9AB8B0C07A5C4258ABD1FE35`; all 27 file hashes independently rechecked *after* publication; original candidate guard `preflight --source` returned `SAFE_SOURCE_AND_INSTALLED_BASELINE_ONLY`, Root=1.5.2, User=1.5.4, `guardArmed:false` and `needsRootAuthorization:true`. Full MMZ pre/post metadata/DNS/route/Tailscale identity PASS; transient source copies and QA script removed after verified publish, earlier historical ZIP untouched. Commander op `add3c321-96ad-4601-9153-3bc83908a777` exit0 stdout SHA256 `b0a60e76b3bf47031e37891cba946768b872030b17ba2af6770c0e8075bc66b5`; independent postwrite readback PASS. **Private package ready for a local, official administrator-approved canary; NOT installed, NO Polkit approval requested/received, NO real user DoH consent**. Do not invoke the privileged installer from Commander via a disallowed command/shell circumvention. + +**R575–R576 independent physical browser baseline (DIM OFF) — 2026-10-16:** On MMZ, disposable fresh user profiles with Chrome **155.0.8059.84** in no-proxy headless mode returned Google homepage HTML (255,028 and 256,921 bytes, visible `Google`), while `https://www.youtube.com/` produced **zero DOM bytes** despite Chrome exit code 0, so *YouTube is NOT accepted usable*. Independent ephemeral Chrome netlog diagnostics reported mixed background-request net errors including `-102` (refused), `-105` (name-resolution), `-109` (unreachable) and `-3` (aborted); aggregated counts are **not** per-host/request attributions and do not identify an ISP or product cause. Both runs preserved real Root/User metadata, DNS SHA, route and Tailscale; all profile/netlog outputs were temporary and removed. Op `00dc46d7-6ec9-4f23-aa0c-af88a80dff82` stdout SHA256 `2ddaf484497f1daffa98c89e1cb61084639e1d106e199800a27133c094e88c0a`; op `e6bde495-a6cf-466d-a391-cdcbbc95b706` stdout SHA256 `eea5c78369f3713b5daf72101ad1349f1cd347a2bff1c5403b8cc2821bade061`. Official Chromium codes: https://chromium.googlesource.com/chromium/chromium/+/refs/heads/main/net/base/net_error_list.h . Official zapret notes that browser TLS fingerprints differ from curl; therefore curl-35 alone is not browser acceptance: https://github.com/bol-van/zapret/blob/master/docs/quick_start.md . Independent R530 physical SNI-reset evidence remains distinct. Tailscale official Linux DNS documents favor preserving the existing systemd-resolved stub symlink (verified MMZ), not forcing adapter DNS changes: https://tailscale.com/docs/reference/linux-dns . Polkit's native authentication agent requires explicit administrator confirmation in the genuine active user session: https://polkit.pages.freedesktop.org/polkit/pkexec.1.html . **Remain OPEN:** actual guarded RC1 Start/Stop/DNS/HTTPS/YouTube + Tailscale lifecycle. Do not weaken TLS verification, alter network ownership or claim a universal DPI fix. + +**R567 single denied admin prompt recovery (2026-10-16), HOSTED PASS / OWNER PROMPT NOT DELIVERED:** Test-only commit `8b930ef1366fc7146cea2059a62bd5554341d49d` runs the original production Linux `install.sh` `backend_matches_current`, privileged authorization branch, and `rollback_failed_install` in a native unprivileged process on an *empty disposable GitHub-hosted Ubuntu* seeded from official public Stable v1.5.4 User code and Root v1.5.2. A test-only Bash `pkexec` stub returns explicit denial `126` without elevating or opening an admin dialog; the real User transaction had already Stage'd and the original ERR/rollback trap must restore all User managed bytes/settings, leave the protected Root metadata/binaries and resolver/hosts intact, create no guard, and issue **only one** denied authorization attempt. Exact native job `PASS_HOSTED_SINGLE_POLKIT_DENIAL_USER_ROLLBACK` on both independent runners [Push 38824729897](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38824729897) and [PR 38824737064](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38824737064); the PR Actions run was **SUCCESS**, while hosted Push Windows private lifecycle was still running at initial R567 recording. No production source, Linux private ZIP SHA256 `B2960E957D2314F43DA719AAFE097E48FDD3CAA647A02844333818DC50A37471` or public Stable v1.5.4 changed. This is a controlled fake refusal, **not** evidence of a real MMZ Polkit prompt/consent; MMZ Tunnel Client is offline and direct `pkexec` is blocked by Commander policy. Physical Root/User protected RC1 install, DoH/HTTPS/Tailscale, actual reboot and user approval remain OPEN. Emad Linux has active DIM/Tailscale and cannot safely substitute for the MMZ offline canary; Emad Windows stable and Tailscale remain running. + +**R566 persistent missed-calendar native activation (2026-10-16), HOSTED PASS, NO PHYSICAL REBOOT:** Test-only commit `541af7c94a0d8fea687fc8372b492d1405808a64` adds an actual root-owned systemd timer/service in a unique temporary namespace on each *disposable* GitHub Ubuntu runner; it reuses the unchanged production R562 `OnActiveSec=480s` + fixed UTC `OnCalendar` + `Persistent=true` directives with only the test unit/worker identity replaced. The timer is explicitly stopped **before** calendar expiry, verified not to run while inactive, then restarted after the missed deadline; systemd runs the synthetic service promptly (0.159s and 0.157s observed, exact-run receipts). [PR CI 38823271295](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38823271295) and [Push CI 38823266523](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38823266523) both **SUCCESS**, including separate native Root/User pre-acceptance and synthetic post-acceptance/fail-closed tests; hosted private Windows 1.5.4→RC1→1.5.4 rollback PASS on Push. Native Windows private Setup SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9`; unique hosted Windows acceptance receipt SHA256 `18346CBA60FEDDAA2A80D27CA444BFA8D95E432E86EAACE35788EC13D64E4D1E`. The official v1.5.4 Stable and *single private Linux 27-file ZIP* SHA256 `B2960E957D2314F43DA719AAFE097E48FDD3CAA647A02844333818DC50A37471` remain unchanged. R566 proves catch-up after timer inactivity on running systemd, **not** an actual system reboot, physical MMZ bridge authorization, real DoH/HTTPS, user consent, installed RC1 or production Root rollback. The MMZ tunnel remained unavailable at this check; previous `pkexec` allowlist denial must not be bypassed. + +**R565 native failure-path acceptance (2026-10-16), HOSTED PASS / PHYSICAL UNTESTED:** Production binaries, the Linux RC1 ZIP SHA256 `B2960E957D2314F43DA719AAFE097E48FDD3CAA647A02844333818DC50A37471` and deterministic private Windows Setup `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9` are unchanged. Test/CI-only commit `73a97061c063e8959b5b67cd247bbe325f4e4c79` added a separate disposable Ubuntu native systemd negative scenario: after **explicitly fabricated** Root acceptance but before User commit, mutate one synthetic `settings.json`; the exact protected worker must reject the forward User commit, return nonzero, report `DEGRADED_UNRESOLVED`, preserve both Root accepted phase and uncommitted User slot and leave all other managed files/resolver/hosts intact. [PR CI 38821938360](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38821938360) and [Push CI 38821931613](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38821931613) fully **SUCCESS**, native negative job `PASS_EPHEMERAL_SYNTHETIC_MUTABLE_DRIFT_FAIL_CLOSED_AFTER_ACCEPTED_ROOT` on both independent runners. Push Windows stable→private RC1→stable rollback PASS; exact private Setup digest as above and unique receipt SHA256 `EEDE2C598B6362844C9C7FBB5BE3DC810DE0FC9813D7052FC3F1E3815404C01A`. This rejects silent user-data overwrite after irreversible Root acceptance; synthetic authorization is **not** user/physical approval. The production MMZ connector remains offline (`Tunnel-client has not been seen for 300 seconds`), and prior official `pkexec` requests remain blocked by its host policy. **No Root guard armed, no actual RC1 installed, no real reboot/DoH/HTTPS/Tailscale acceptance and no public release promotion.** + +**R559 retained hosted/physical provenance:** [Push CI 38815683495](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38815683495) and [PR CI 38815691160](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38815691160) both **SUCCESS** at `e14ad473e1eac36208de20e19cd8c817c3fd69cd`. The independent Root/User upgrade watchdog retained immutable UTC timer deadline across native systemd timer restart with `OnActiveSec=480s`, `OnCalendar`, `Persistent=true`, and restored two-sided bytes on hosted Ubuntu. Exact private SHA256 `CA249D9792C69320E934C53888EFB577729AB4A616A8951EF1736ED7161445DE` (8,360,956 bytes), staged on MMZ for R560 with receipt `6EB5312B82563A12A301945B10F3E6456A6E7872C86D264E1DE2CB4CC0AD2EE9`; physical rootless R561 receipt `51CF09BA0DCDCF47D1350A346D71A8BB5AF0DDA25CBA6796E2E0D72EC4CE9FB1`. These are historical exact-scope PASSES and NOT R562 or physical guarded install acceptance. + +**R560/R561 physical MMZ acceptance (nonprivileged scope only):** The exact R559 ZIP was independently reconstructed in memory from accepted R553 source, atomically staged at `/home/mmz/source/repos/DirectInternetMethod/R541_QA/R559_PRIVATE_1.5.5-rc1_Linux_x86_64.zip` (0600); receipt `R541_QA/R560_PRIVATE_R559_READY.json` SHA256 `6EB5312B82563A12A301945B10F3E6456A6E7872C86D264E1DE2CB4CC0AD2EE9`. The true R559 `upgrade_guard.py preflight` from this ZIP ran unprivileged on MMZ, with six PASS including fail-closed modified Root/User payload, bad archive layout and denied non-Root arming; receipt `R541_QA/R561_NATIVE_GUARD_PREFLIGHT.json` SHA256 `51CF09BA0DCDCF47D1350A346D71A8BB5AF0DDA25CBA6796E2E0D72EC4CE9FB1`. Real installed Root v1.5.2/User v1.5.4, Tailscale, DNS, routes, application metadata and user consent were **unchanged**. No real new Guard service installed or armed. + +**R554/R555 hosted native guard acceptance:** At exact source+test head `846dfba6a35f27382d10608ffd93806c5650002a`, both [push CI 38811848360](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38811848360) and [PR CI 38811854504](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38811854504) finished **SUCCESS**. An actual fresh disposable GitHub Ubuntu VM with UID 0 confirmed independent root-owned systemd timer/service armed before protected mutation, Root 1.5.2/User 1.5.4 → RC1 upgraded, and a separately run root-owned systemd service restored Root+User byte-for-byte; official stable source, private mutable user settings and resolver/hosts remained SHA-identical, with no production host touched. R555 additionally executed the **unmodified production `upgrade_system.sh` entrypoint**, rather than merely invoking RootTransaction directly: true native wrapper+auto-arm and rollback PASS on both hosted runs. R532 independently confirms monotonic systemd timer expiry; R554/R555 trigger the exact R553 worker through its native systemd service but do **not** prove exact 480s timer expiry or reboot timing on a physical computer. Windows native private v1.5.4→RC1→v1.5.4 PASS at exact push, Windows setup SHA256 `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9`; public release still unchanged. + +**R556 private physical MMZ staging:** Exact R553 Linux ZIP was exclusively created on physical MMZ at `/home/mmz/source/repos/DirectInternetMethod/R541_QA/R553_PRIVATE_1.5.5-rc1_Linux_x86_64.zip`, 0600 with full hash and manifest validated; unique receipt `R541_QA/R556_PRIVATE_R553_READY.json` SHA256 `466A99A4FB3DDCEA432C2C2B9F7DF5B2422D773AA1CEAB9F7697C6C290D5F337`. Root v1.5.2/User v1.5.4, DNS, routes, Tailscale and user configuration remained unchanged. This is **private staging only**, not Root authorization, user DoH consent, real installation or network acceptance. **R557 new exact-package shadow acceptance:** the staged R553 SHA-pinned ZIP was exercised on physical MMZ in a disposable synthetic HOME using its actual updated 27-member User transaction: user Stage→Apply→metadata attestation→byte-exact Rollback, preserved custom Provider/private settings, rejected concurrent mutable-data drift, no pending backups, **8/8 PASS**, full physical pre/post install/DNS/route/Tailscale unchanged. Unique QA receipt `R541_QA/R557_USER_SHADOW_RECEIPT.json` SHA256 `D7488E1481F125536E6A20666D821A89F4BB87E840EE34C545DC07E751D2D9D8`; source and installed real User v1.5.4/Root v1.5.2 remain unchanged. **R558 exact R553 physical preflight:** actual production `upgrade_guard.py preflight` from the pinned 27-file private archive executed on MMZ as the ordinary user and returned `SAFE_SOURCE_AND_INSTALLED_BASELINE_ONLY`; the same source rejected non-Root arming, unknown source layout and altered Root/User transaction bytes. Six checks PASS, network/Tailscale/metadata unchanged; **no privileged operation attempted or guard armed**. Receipt `R541_QA/R558_NATIVE_GUARD_PREFLIGHT.json` SHA256 `4DECFC4711AE1061BE713B63A7962153532A262D60A1F268771255B76718FD14`. + +**Current candidate R545 (2026-10-15):** v1.5.5-rc1, Draft PR #60, not published. Corrected the route-drift user-interface gap: a bounded GLib 15-second status-only refresh detects transitions to STALE without re-running website probes, changing DNS/routes/services or automatically calling Recovery. A closing window removes its timer; an unverified state fails closed. Source GUI SHA256 `732190BEF316C899D58EF6B4B47BD9DFC86BF761AC4A6BA5962FB0FEA27032F8`, updated exact private Linux package 26-file/28-member ZIP SHA256 `27D5289236A9CDB125669E67269EF16A7C6FCD74AE9300E65B0C8AA2E10B5F70` (8,340,334 bytes), independently rebuilt byte-for-byte in RAM on physical MMZ from exact prior R541 source archive. Old R541 private ZIP SHA `0C5BA7F8DA540412E8417152D0E6FF75F1FFE4ADAB06EE0A1FE72223A94A4839` is historical only, superseded for any NEW canary after this source commit. No installed program was changed. The new private R545 ZIP was exclusively staged on physical MMZ as `R541_QA/R545_PRIVATE_1.5.5-rc1_Linux_x86_64.zip` (0600, pinned SHA), with original Root 1.5.2/User 1.5.4, no upgrade slots, route/DNS/Tailscale unchanged; unique receipt `R541_QA/R545_PRIVATE_READY.json` SHA256 `AD78F42CEE5E74F61EF57DE83943F1059900F29F7DED43B21C741EAF79695FC9`. Private staging is NOT installation, permission or DoH consent. + +**Verification scope:** Six extracted-method negative-first tests PASS on MMZ; seven additional real GTK4 timer/route tests PASS in isolated loopback Broadway and synthetic HOME, with unchanged physical installation, Tailscale, routes and DNS (receipt `R541_QA/R545_GTK_TIMER_RECEIPT.json`). Exact source+CI-metadata head `5b7293cb7acba0f0c76d5a6c9cf5629bc73581a9` passed hosted Push CI [38795817931](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38795817931) and PR CI [38795826936](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38795826936), including Linux package SHA/size, new R545 6/6, R541 11/11, 1.5.4 regression, protected Root, Root/User native disposable upgrade and independent watchdog, Windows source and private Windows native v1.5.4→RC1→v1.5.4 rollback. Public unsigned Windows release job was intentionally SKIPPED (not release acceptance). The exact-run Windows private installer SHA256 was `A1D17585067D46953067AA62A3D4DA6BC9BE56EDB1C6E86A22D381885E809DC1` and its native acceptance receipt SHA256 was `89FC5BAC118EDCA3F60A606EA48BFD79E9CB9E9E484DE007FE16D143DEB4EA77`; these cannot authenticate a different run's artifact. + +**R546/R547 current physical rehearsals:** On actual MMZ, exactly SHA-pinned private R545 Linux archive `27D5289236A9CDB125669E67269EF16A7C6FCD74AE9300E65B0C8AA2E10B5F70` ran the unchanged original User transaction in temporary synthetic HOME: Stage, Apply, metadata attestation, exact 1.5.4 rollback, custom Provider/config preservation, rejected mutable drift and no orphan slots, **8/8 PASS**, physical pre/post Root/User/DNS/route/Tailscale/consent identical. Receipt `R541_QA/R546_USER_SHADOW_RECEIPT.json`, SHA256 `91D749F244F0375FE822E6E97402F35108A7CBAFF9B755A60613848DF1AA30A7`. Separately the exact untouched production RootTransaction source SHA `EB107164CCEDE891C8BF41C06FF422F1B53B59D2D2DDF0B3DD23EBFE5DE9601B` executed its original *read-only* `_baseline()` against the real MMZ machine, confirming 9 protected static file hashes, the 2 existing owned-file hashes/modes and 2 absent new-file preconditions. Receipt `R541_QA/R547_ROOT_PREFLIGHT_RECEIPT.json`, SHA256 `1CE6EC8CB1F5FF7FFD6A5A11E91DECF689EFB56A4DC0FCFEDEF0A7C2CAAE9937`. No real Root transaction, installed application code change, network/privilege mutation or user consent. + +**R548 Windows build identity hardening (accepted source and native lifecycle):** Commit `7a58e40b9249a88b7e831fbc281428a5cfd13c0e` disables commit-derived .NET 10 `InformationalVersion` and normalizes official Windows CI build paths with `ContinuousIntegrationBuild=true`. Hosted Push/PR CI succeeded; the private native Windows v1.5.4→1.5.5-rc1→v1.5.4 installation/user-data/rollback PASSed on both [38798487532](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38798487532) and [38798942807](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38798942807). The intervening Git commit `aba254274a1863931580f20f086c4120071ca308` changed only this Brain, NOT code/build inputs. Exact SHA256 identities across the two independent hosted native builds were identical for GUI `F5245F7008C1B5A0A97F35B7AC0481E281A07AB73A715D9EE152DC6008510AA6`, Service `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8` and manifest `BA6AC9EAEE7E47E50778EBAF107A3F8BF2590F6196B71E01CE72E75EBE899297`. However full unsigned private Inno Setup SHA still differed: `F3D56D2AC12ED44563D599EE6459FBBECF5562D791E846C81FD9E555821BC9CD` versus `CF2F15830D09F1F42740C82CA6E4595C3D248D40557632EC84AA536FA1E9BADD`. Therefore binary/payload reproducibility is **PASS**, whole-installer reproducibility remained **OPEN** at R548. Official .NET documentation confirms SourceRevisionId normally enters InformationalVersion, and independent SHA-provenance remains in exact CI build/acceptance receipts. Never reuse a SHA from a different hosted build. + +**R549 Windows deterministic one-installer, HOSTED PASS / PRIVATE RC1, 2026-10-15:** Commit `857d9e8d721435b15fd8a15e669a6b4b66f68313` adds Inno Setup's vendor-supported `notimestamp` flag to all 28 `[Files]` entries, plus a negative-first audit rejecting missing/incompatible flags. Official vendor description: https://jrsoftware.org/ishelp/topic_filessection.htm . Independent [hosted push 38800061372](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38800061372) and [hosted push 38800362560](https://github.com/GOD13emad/DirectInternetMethod/actions/runs/38800362560) both SUCCESS for **native v1.5.4→RC1→v1.5.4 in-place upgrade preserving user files, one install registration and rollback**. The only source difference between the two builds was a Brain documentation update. The native GUI, Service, manifest **and full Windows Setup.exe** matched bit-for-bit across these independent runner builds: GUI SHA256 `F5245F7008C1B5A0A97F35B7AC0481E281A07AB73A715D9EE152DC6008510AA6`; Service `DEBA242C223409072C1FB852077C0E4F00726A7F34F56ADF5C87BFE8197153A8`; manifest `BA6AC9EAEE7E47E50778EBAF107A3F8BF2590F6196B71E01CE72E75EBE899297`; private Setup `F725B20216439094C9C15FE21E411322F4D831C3D4F293C3A8C70F5B07860FB9`. The two separately stamped acceptance receipts legitimately differ. **R549 whole-installer reproducibility PASS on two tested GitHub-hosted Windows-2025 runners**, not a universal promise or evidence of owner-host RC1 installation. Public Windows release job deliberately skipped; Stable v1.5.4 unchanged. + +**R550/R551 current physical network and authorization, 2026-10-15:** A new read-only MMZ preflight verified actual Root `1.5.2`, User `1.5.4`, Tailscale active, no pending upgrade slots; production `directinternetmethod-upgrade-rollback.service/.timer` units are **not installed**, while `directinternetmethod-recovery.service` only runs owned network Recovery, NOT independent two-sided application upgrade rollback. `pkexec` and `systemd-run` are available but non-interactive `sudo -n` authorization is denied. Receipt `R541_QA/R550_GUARD_READY_READONLY.json`, SHA256 `B9ABA33304131159D1771E02729FD533BD6312B7E8B2A0C910EC8BC101422FA4`. Direct physical-interface HTTPS with DIM OFF on MMZ: Google `204`, GitHub `200` (usable), YouTube `curl 7 / HTTP000` (unusable); receipt `R541_QA/R551_TLS_DIRECT_READONLY.json`, SHA256 `0E354C943B385CDF05879DE3194EBC589453FBD610A72E158B4A555E314507FD`. Full pre/post route, DNS, install identity and Tailscale unchanged. These **do not** establish RC1 live DoH/DPI/HTTPS acceptance. + +**Upstream ctrld research / deferred optional:** The direct-only candidate remains byte-pinned to ctrld v1.5.7. Upstream official `v1.5.8` published 7 October 2026 has DNS64/DNSSEC hardening, improved recovery/split-DNS handling and a new retained diagnostic journal; details https://github.com/Control-D-Inc/ctrld/releases/tag/v1.5.8 . Do not silently swap the pinned runtime: verify targeted applicability, additional local log/privacy behavior, hashes and host-native acceptance independently. The newer upstream exists but is not an automatic blocker for the present IPv4-only scoped pilot. No extra unrelated release gate is added. + +**Real physical baseline:** Emad Windows registered v1.5.4; GUI/Service binary SHA match exact public asset source, DirectInternetMethodSvc and Tailscale running. Emad Linux User 1.5.4/Root 1.5.2, owned NFQUEUE bound to old `wlp2s0` while current default route uses `enp1s0`; Tailscale active; Google HTTPS 204 and YouTube TLS curl 35. MMZ Linux User 1.5.4/Root 1.5.2 with no pending upgrade slots. All real product installations remain unchanged by this source/QA work. + +**Critical dependency chain:** R559 Root/User independent guard, real production wrapper and absolute-calendar persistence across timer restart are native-hosted **PASS**, but the new guard is **NOT ARMED ON ANY USER PRODUCTION HOST**. Root privilege on MMZ still requires its own legitimate administrator authentication; previous physical guard attempt was denied by host safety policy and must not be bypassed. Real RC1 protected install, GUI Start→DoH/HTTPS→Stop/Recovery, Tailscale/physical-DNS invariants, reboot behavior, and an accepted **coordinated two-sided persistent Root/User commit** are OPEN. RootTransaction.commit and UserTransaction.commit currently have separate irreversible phase/backup operations, and hosted rollback PASS does not prove interruption-safe final acceptance/cleanup. Do not merge, create tag, publish v1.5.5, or claim whole-project FINAL until native physical acceptance plus safe promotion/rollback evidence. Public v1.5.4 Stable remains unchanged. Ask only for indispensable guarded privilege/action when appropriate and continue independent tasks. + +**Authority map:** this current `PROJECT_BRAIN.md` notice is the source-branch singleton Index/Brain/State locator, with unique GitHub PR/CI and physical QA receipts as evidence links. The historical R0 lineage below and `BRAIN_R*.zip` snapshots remain provenance, not competing active authorities. Earlier Candidate/Final/signing-HOLD statements are superseded where conflicting. The owner’s DoD remains one-install in-place, preserved settings, verified rollback, active Linux/Windows runtime and a single trustworthy public release line. --- diff --git a/build_linux_package.py b/build_linux_package.py index c6812adc..97c7a179 100644 --- a/build_linux_package.py +++ b/build_linux_package.py @@ -2,17 +2,23 @@ from __future__ import annotations import hashlib,json,pathlib,zipfile,tempfile,os R=pathlib.Path(__file__).resolve().parent -OUT=R/"delivery"/"DirectInternetMethod_1.5.4_Linux_x86_64.zip" -ROOT="DirectInternetMethod_1.5.4_Linux_x86_64" +OUT=R/"delivery"/"DirectInternetMethod_1.5.7_Linux_x86_64.zip" +ROOT="DirectInternetMethod_1.5.7_Linux_x86_64" FILES=[ - ("docs/README_PACKAGE_154.md","README.md"), + ("docs/README_PACKAGE_157.md","README.md"), ("linux/install.sh","install.sh"), ("linux/uninstall.sh","uninstall.sh"), ("linux/system/install_system.sh","system/install_system.sh"), ("linux/system/control.sh","system/control.sh"), ("linux/system/selective_dns.py","system/selective_dns.py"), + ("linux/system/encrypted_selective_dns.py","system/encrypted_selective_dns.py"), + ("linux/system/encrypted_selective_policy.py","system/encrypted_selective_policy.py"), + ("linux/system/protected_upgrade_transaction.py","system/protected_upgrade_transaction.py"), + ("linux/system/upgrade_guard.py","system/upgrade_guard.py"), + ("linux/system/upgrade_system.sh","system/upgrade_system.sh"), ("linux/system/uninstall_system.sh","system/uninstall_system.sh"), ("linux/app/direct_internet_method.py","app/direct_internet_method.py"), + ("linux/app/socket_fallback.py","app/socket_fallback.py"), ("linux/app/router_gateway.py","app/router_gateway.py"), ("linux/app/router_gateway_core.py","app/router_gateway_core.py"), ("linux/app/user_update_transaction.py","app/user_update_transaction.py"), @@ -36,15 +42,15 @@ def payload_bytes(src:str,dst:str)->bytes: data=data.replace(b"\r\n",b"\n").replace(b"\r",b"\n") return data # Frozen originally shipped documentation; root README may describe current publication. -if sha_bytes(payload_bytes("docs/README_PACKAGE_154.md","README.md"))!="4E17DB910024D0D5841857928C45AD0EC2C5D65D30B3311FBE1864F1885AC84F": - raise SystemExit("V154_PACKAGE_README_SHA_CHANGED") +if sha_bytes(payload_bytes("docs/README_PACKAGE_157.md","README.md"))!="9F7266E65D31DE84EE62A541FF35F9621E5009BF097C256D75FD2DFC407F21FF": + raise SystemExit("R519_PACKAGE_README_SHA_CHANGED") rows=[] for src,dst in FILES: p=R/src if not p.is_file(): raise SystemExit("MISSING:"+src) data=payload_bytes(src,dst) rows.append({"file":dst,"bytes":len(data),"sha256":sha_bytes(data)}) -manifest={"schema":1,"product":"Direct Internet Method","version":"1.5.4","platform":"linux-x86_64","files":rows} +manifest={"schema":1,"product":"Direct Internet Method","version":"1.5.7","platform":"linux-x86_64","files":rows} OUT.parent.mkdir(parents=True,exist_ok=True) fd,tmp_name=tempfile.mkstemp(prefix="dim-build-",suffix=".zip",dir=OUT.parent) os.close(fd) @@ -53,7 +59,7 @@ def payload_bytes(src:str,dst:str)->bytes: with zipfile.ZipFile(tmp,"w",compression=zipfile.ZIP_STORED) as z: for src,dst in sorted(FILES,key=lambda x:x[1]): data=payload_bytes(src,dst) - zi=zipfile.ZipInfo(f"{ROOT}/{dst}",date_time=(2026,10,1,0,0,0)) + zi=zipfile.ZipInfo(f"{ROOT}/{dst}",date_time=(2026,10,14,0,0,0)) zi.create_system=3 zi.external_attr=((0o755 if dst.endswith((".sh",".py")) or dst in ("runtime/ctrld","runtime/nfqws") else 0o644)&0xFFFF)<<16 zi.compress_type=zipfile.ZIP_STORED @@ -63,7 +69,7 @@ def payload_bytes(src:str,dst:str)->bytes: ("SHA256SUMS.txt","".join(f'{x["sha256"]} {x["file"]}\n' for x in rows)) ]: data=(json.dumps(obj,ensure_ascii=False,indent=2)+"\n").encode() if isinstance(obj,dict) else obj.encode() - zi=zipfile.ZipInfo(f"{ROOT}/{name}",date_time=(2026,10,1,0,0,0)) + zi=zipfile.ZipInfo(f"{ROOT}/{name}",date_time=(2026,10,14,0,0,0)) zi.create_system=3 zi.external_attr=(0o644&0xFFFF)<<16;zi.compress_type=zipfile.ZIP_STORED z.writestr(zi,data) diff --git a/docs/ENCRYPTED_DNS_R516.md b/docs/ENCRYPTED_DNS_R516.md new file mode 100644 index 00000000..0faa4f1c --- /dev/null +++ b/docs/ENCRYPTED_DNS_R516.md @@ -0,0 +1,95 @@ +# R516 — encrypted selective DNS candidate (NOT RELEASED) + +**Project:** Direct Internet Method; source authority is exact GitHub main commit +`26ef644117ea474b487186ce5e6e61e92cd02dd4`. Existing public +Stable remains **v1.5.4**. The last cumulative Project Brain accepted is R514 +SHA-256 `DF4D5EFD2F412D344160745491BC5F812DCD03AF8D89B3B21FC5644D9C229300`. + +## Reason and evidence + +The installed Linux `linux/system/selective_dns.py` has an explicitly +opted-in but **unencrypted** UDP upstream `194.225.152.10` routed via +`dnsmasq`. It does not meet the project's encrypted-DNS requirement +under observed DNS interception. + +R511 and R513 used the already bundled `ctrld v1.5.7` with verified TLS +DoH `https://76.76.10.11/p0` on bounded per-link DNS pilots. R516 added +a narrower localhost-only source-policy test (non-root, MMZ Linux on +2026-10-14) on `127.0.0.1:53534` with `systemd-run --user` and a 90-second +`RuntimeMaxSec`; system DNS, Tailscale and the Direct Method app were not +changed. Exact runtime TOML SHA-256: +`46F63805FE53B5CEDC6B7D4DF242BBF13A6CA8229BB9072058BE8DE304697954`. + +Observed R516 localhost DNS: +- `www.youtube.com`: public IP addresses `142.251.150.4` through + `142.251.157.4`, no RFC1918 addresses; +- `www.googlevideo.com`: public `142.251.141.36`; +- `github.com`: public `140.82.121.3`, same as router; +- `example.com`: public Cloudflare IPs; +- direct query against original `192.168.8.1` returned forged/private + `10.10.34.36` for YouTube. +The temporary user unit exited or was stopped and the listener was absent, +with DNS and default route/Tailscale unchanged. + +Independent repeat on Emad Linux (router `192.168.20.1`, `127.0.0.1:53535`) +generated **byte-exact** source TOML SHA-256 +`3CC90962571AC80220B1A947C901709C64CD0DA0BD63EE5BEB6D1EC8781615DC`. +The DoH path gave legitimate public YouTube and Googlevideo addresses, +and `github.com` matched the physical router. **`example.com` timed out +because this non-target request intentionally fell back to Emad's unreliable +router DNS.** Thus the targeted model demonstrably protects listed domains; +it does NOT repair arbitrary domains, which require an explicit broader opt-in. +The user-bounded test service stopped, with physical DNS, Tailscale and +Direct Method unchanged. On Emad, the separate Seman L2TP connection remained +user-controlled and was not used to infer safe combined product mode. + +Both routers' rendered Policy bytes are pinned in the **46/46 offline +negative-first tests**, and all invalid resolver addresses and unsafe ports +fail closed. MMZ direct router YouTube answers were private during the test; +Emad's physical router responses vary over time, so one public answer from +that router does not establish reliable default DNS. + +The pure source policy generator in +`linux/system/encrypted_selective_policy.py` emits **byte-exact** TOML +for the validated MMZ router and temporary listener port. It has negative-first +rootless contracts in `tests/encrypted_selective_dns_policy_contract.py`. +No installer, root helper, UI, upgrade flow or published binary is wired +to this policy in R516. + +## Acceptance and open gates + +**PASS:** rootless isolated policy, exact runtime sample, encrypted target +DoH with real public A, original router fallback for non-target names and +bounded cleanup without product/system DNS/Tailscale changes. + +**OPEN:** supervised integration into one protected updater and GUI; +opt-in token migration from *unencrypted* to *encrypted* (never silently +reinterpret consent); native per-link route-only recovery/backstop with +systemd-resolved; tests on Ubuntu 26 under actual installed user/service; +one-install update/rollback and hosted exact-SHA CI; selected-site HTTPS +access (MMZ YouTube still curl35); Seman concurrent VPN policy and +service/region HTTP403. + +The generator must not be silently substituted into v1.5.4's old +`dnsmasq` controller, because the controller's assumptions (runtime +binary, state schema, consent, rollback) differ. **SUPERSEDED—DO NOT RUN** +includes older temporary R510/R512/R513/R514 and independently completed +R515 scripts; preserve previous Brain and historical raw files. + +## Next exact action + +In a separate transaction, design and negative-test an opt-in encrypted +controller that owns a disposable per-link resolver, enforces route-only +domains and exposes an independent bounded rollback before touching DNS. +Then modify the protected installer and GUI together in a versioned release +candidate; require a clean in-place upgrade path, native V&V, CI and +explicit promotion gates. Do not publish merely because the pure policy +and DNS lookups PASS. + +Sources: +- Control D ctrld v1.5.7 configuration reference, policy precedence + (rules before networks): https://github.com/Control-D-Inc/ctrld/blob/v1.5.7/docs/config.md +- Ubuntu systemd-resolved D-Bus SetLinkDNSEx/SetLinkDomains/RevertLink: + https://manpages.ubuntu.com/manpages/resolute/man5/org.freedesktop.resolve1.5.html +- Public v1.5.4 release: + https://github.com/GOD13emad/DirectInternetMethod/releases/tag/v1.5.4 diff --git a/docs/README_PACKAGE_155_RC1.md b/docs/README_PACKAGE_155_RC1.md new file mode 100644 index 00000000..724bad53 --- /dev/null +++ b/docs/README_PACKAGE_155_RC1.md @@ -0,0 +1,41 @@ +# Direct Internet Method 1.5.5-rc1 — Linux candidate (NOT RELEASED) + +This archive is a versioned development candidate for upgrading the existing +single-install Linux app from public **v1.5.4**. It is not a public Stable +release and must not be offered as an automatic update. + +## Scope +- Existing v1.5.4 settings, custom hosts, Router Gateway provider and + Tailscale split mode must remain intact. +- Explicit NEW consent is required for encrypted selective DNS. Previous + unencrypted third-party UDP opt-in does **not** count as consent. +- Pinned ctrld v1.5.7 DoH upstream routes only documented target domains + through an owned per-link resolver. Non-target DNS stays with the user's + physical DNS server, which may still time out or be intercepted. +- The root backend upgrade from installed protected v1.5.2 is a single-slot + accepted-SHA transaction with an independent Rollback; it is **not** + authorized for promotion until installed-native QA and hosted CI. +- Running a new root backend without an accepted user/backend pair is + forbidden. The candidate's installer must fail closed if either side is + foreign, active, or has an uncommitted conflicting transaction. + +## Known limitations / open acceptance gates +The R516 targeted DNS pilot succeeded on two different LANs, and the R517 +controller passed source-only and simulated rollback tests; these do not +demonstrate a successful upgrade, protection after reboot, a working +per-link resolver under live root, or access to every website. +YouTube HTTPS on the MMZ mobile ISP reset TLS even after native balanced +DPI, with a separate Strong test's root-only receipts unavailable. Seman +L2TP combined with Tailscale is not an accepted mode. Some services may +still return a region/account-specific HTTP 403. + +Do not delete backups, auto-promote, disable Tailscale/Seman, reset system +DNS, reboot or shutdown as part of installation. +A native acceptance record must prove exact SHA, data preservation, service +Start/Stop/Recovery, rollback, Tailscale and physical DNS invariants before +release. + +Source authority: GOD13emad/DirectInternetMethod, main commit +26ef644117ea474b487186ce5e6e61e92cd02dd4, versioned R519 branch. +Public release authority remains v1.5.4 (tag +51009372e3c2d069d7016b97773f35ee5e1e0631). diff --git a/docs/README_PACKAGE_156.md b/docs/README_PACKAGE_156.md new file mode 100644 index 00000000..5efc23bd --- /dev/null +++ b/docs/README_PACKAGE_156.md @@ -0,0 +1,54 @@ +# Direct Internet Method v1.5.6 — Linux release-preparation candidate (NOT PUBLISHED) + +This package is a **private validation candidate**, not a second public product +line. The current public Stable remains v1.5.4 until a separate authorized +GitHub release establishes exact asset identities and accepted test receipts. + +## Supported single-install upgrade paths + +- Public Linux User 1.5.4 with protected Root backend 1.5.2. +- Previously owner-accepted private Linux User 1.5.5-rc1 with protected Root + backend 1.5.5-rc1. +- No mixed, unknown, modified, symlinked, or partially installed Root/User pair + is silently upgraded. Existing custom Router Gateway data, settings, + Tailscale, DNS/physical route, and one original installation must survive. +- Windows private installations have FileVersion 1.5.5.0. Public v1.5.5 is + **not** a forward numeric upgrade for their existing updater; v1.5.6 is the + first normal three-component version that can update them directly. + +## Security and recovery + +Privileged protected-file reads accept only literal approved filenames with +descriptor-based no-symlink traversal, pinned SHA-256 payloads, regular-file +identity, and verified original ownership. A per-transaction random acceptance +nonce prevents replay of previous Root acceptance receipts. The new +version-specific trusted privileged bootstrap must be installed only through +normal native administrator authorization after verifying its reviewed digest. + +The Root+User maintenance transaction has one private rollback slot; a separate +Root-owned watchdog is armed before protected mutation and must be settled with +real Start, Stop, Recovery, original DNS/Tailscale checks and user settings +hashes before release. A preliminary staged/apply or hosted CI PASS alone does +not authorize physical deployment or publication. Do not disable host security +policies, reboot, modify default routes, reset DNS, or automatically install +a VPN or global tunnel. + +## Known acceptance limits + +Some physical networks reset YouTube TLS depending on SNI even when +public Google IPs are reachable. Site acceptance must be verified on the +intended network; no universal-connectivity claim is made. The current +physical network is not a verified universal connectivity target. Historical +plain `curl` HTTP403 and isolated headless browser timeout do **not** prove +ChatGPT account sign-in. Optional encrypted selective-DNS needs explicit, +separate user consent, and its controller remains restricted to safe native +rollback and Tailscale coexistence. + +Current exact source starting point: primary PR #63 commit +`b38aa94b67fb102dd24359184abaa324cd29a3ad`, plus separately scoped +R803 forward-upgrade migration work. The new candidate's final Git SHA, +package digest, hosted Linux/Windows acceptance and release decision remain +UNVERIFIED until recorded on the exact generated artifacts. + +Public v1.5.4 release and protected `main` remain unchanged. This document is +a package-level statement of intended behavior, not a claim of publication. diff --git a/docs/README_PACKAGE_157.md b/docs/README_PACKAGE_157.md new file mode 100644 index 00000000..5679a603 --- /dev/null +++ b/docs/README_PACKAGE_157.md @@ -0,0 +1,56 @@ +# Direct Internet Method v1.5.7 — Linux release-preparation candidate (NOT PUBLISHED) + +This package is a **private validation candidate**, not a second public product +line. The current public Stable remains v1.5.4 until a separate authorized +GitHub release establishes exact asset identities and accepted test receipts. + +## Supported single-install upgrade paths + +- Public Linux User 1.5.4 with protected Root backend 1.5.2. +- Previously owner-accepted private Linux User 1.5.5-rc1 with protected Root + backend 1.5.5-rc1. +- No mixed, unknown, modified, symlinked, or partially installed Root/User pair + is silently upgraded. Existing custom Router Gateway data, settings, + Tailscale, DNS/physical route, and one original installation must survive. +- Windows private installations exist with FileVersion 1.5.5.0 and 1.5.6.0. +Publishing v1.5.6 will NOT offer an update to existing private v1.5.6. +Strictly newer v1.5.7 is required after full security and native acceptance. + +## Security and recovery + +Privileged protected-file reads accept only literal approved filenames with +descriptor-based no-symlink traversal, pinned SHA-256 payloads, regular-file +identity, and verified original ownership. A per-transaction random acceptance +nonce prevents replay of previous Root acceptance receipts. The new +version-specific trusted privileged bootstrap must be installed only through +normal native administrator authorization after verifying its reviewed digest. + +The Root+User maintenance transaction has one private rollback slot; a separate +Root-owned watchdog is armed before protected mutation and must be settled with +real Start, Stop, Recovery, original DNS/Tailscale checks and user settings +hashes before release. A preliminary staged/apply or hosted CI PASS alone does +not authorize physical deployment or publication. Do not disable host security +policies, reboot, modify default routes, reset DNS, or automatically install +a VPN or global tunnel. + +## Known acceptance limits + +Some physical networks reset YouTube TLS depending on SNI even when +public Google IPs are reachable. Site acceptance must be verified on the +intended network; no universal-connectivity claim is made. The current +physical network is not a verified universal connectivity target. Historical +plain `curl` HTTP403 and isolated headless browser timeout do **not** prove +ChatGPT account sign-in. Optional encrypted selective-DNS needs explicit, +separate user consent, and its controller remains restricted to safe native +rollback and Tailscale coexistence. + +Current exact source starting point: primary PR #63 commit +`b38aa94b67fb102dd24359184abaa324cd29a3ad`, plus separately scoped +R803 forward-upgrade migration work. The new candidate's final Git SHA, +package digest, hosted Linux/Windows acceptance and release decision remain +UNVERIFIED until recorded on the exact generated artifacts. + +Public v1.5.4 release and protected `main` remain unchanged. This document is +a package-level statement of intended behavior, not a claim of publication. + +R845 engineering draft: source preflight ONLY; native installation, CI and release are NOT ACCEPTED. diff --git a/docs/SECURITY_TRUSTED_ENTRY_R781.md b/docs/SECURITY_TRUSTED_ENTRY_R781.md new file mode 100644 index 00000000..3f3f984a --- /dev/null +++ b/docs/SECURITY_TRUSTED_ENTRY_R781.md @@ -0,0 +1,42 @@ +# R781 — trusted Linux Root entrypoint (unreleased, review-only) + +## Purpose and authority + +The existing `pkexec /bin/bash "$HERE/system/upgrade_system.sh"` launched shell code directly from the owner's extracted package. Native Polkit confirmation authenticates the invoking user but does not attest this script. R781 replaces that initial privileged entry with a fixed administrator-provisioned program: + +`/usr/local/libexec/directinternetmethod/privileged_entry.py` + +This directory is deliberately **outside** the primary `/usr/lib/directinternetmethod` backend, so pre-provisioning does not accidentally make a truly fresh installation look like a partial backend. It is one trusted component of the same product, **not** a second full installation, a privileged wildcard broker, or permission to disable authentication. + +The source of this bootstrap is `tools/security/privileged_entry.py` in the exact reviewed commit. Its SHA-256 in the R781 test candidate is: + +`C8AE0759FDF3A5EE514B5457D05AF1BFEF0BB266E03244C7EF2F3F6EDB8994D1` + +It freezes only the **single accepted** `v1.5.5-rc1` package, using a compiled-in manifest SHA-256: + +`E3D6186F8BC775FA7861DD506AA1EB978F6296BDC98D686CD3731C79680FEB42` + +The built Linux ZIP is **UNRELEASED**: SHA-256 `0C4D22F98C7C4ECEA2CDE1C0232B3C3928C022779BA8A644CD3DC46FF13CE934` (8,424,218 bytes, 28 payload members). + +## Exact sequence and guardrails + +1. Independently review the exact source commit, its GitHub history and privilege boundary; obtain the trusted bootstrap from that immutable reviewed source. This is **not yet approved for installation**. +2. A legitimate interactive OS administrator, not this unprivileged installer, approves provisioning a new **Root-owned** file. Verify the source's SHA-256 independently; refuse existing foreign/symlink target. Create the dedicated root-owned ancestor with group/world nonwritable permissions, copy as root mode `0500`, then recompute and compare the installed file's SHA-256 before running any upgrade. Do not execute the package as Root to provision it. +3. User-side `linux/install.sh` verifies the trusted path's owner/group/mode/type/link count. If missing, fail closed, roll back its staged **user** transaction, and clearly request the separate OS-admin action. Never fall back to user-owned Bash. +4. Polkit authorizes `/usr/bin/python3 -I` on the fixed root-owned script with explicit `--action upgrade|install`, `--source`. The script demands actual `PKEXEC_UID`, verifies itself and all pathname ancestors as root-owned, and rejects non-root/caller identity mismatch. +5. Before ANY contained script runs under Root, the bootstrap verifies a fixed manifest hash, exactly 28 canonical allowlisted payloads, checks file types, sizes, symlinks/hardlinks, reads source bytes using a single pinned descriptor with no symlink traversal, and validates each member SHA. It freezes the entire accepted tree under a temporary `/run` directory owned by Root, mode `0700`. +6. Run only the copied, SHA-attested package script under a minimal sanitized environment. The existing independent, root-owned watchdog still protects protected `stage/apply`. The bootstrap removes only its isolated ephemeral copy. Any failure must leave user-owned configuration intact and must not hide an incomplete privileged transaction. + +## Security boundaries / remaining checks + +This SHA-pinned release-specific approach intentionally requires **another explicit administrator review and trusted-anchor update** for a future package with a different manifest. The project does not require arbitrary publisher digital signatures or silently trust an unverified GitHub release. + +R781 rootless tests only exercise preflight, source freezing, rejection and code provenance. They do **not** prove protected install, timer/rollback, UAC/Polkit consent, live TLS routing or product-wide security. Native ephemeral hosted Root tests, independent review of PR #62/#63, expert adjudication of all CodeQL High findings and owner-authorized physical acceptance are still separate gates. The current new digest of the trusted entry has NOT been installed on either physical Linux host. Read-only native SHA checks on 2026-10-19 found previous root-owned 0500 entry SHA256 `DD878D594830A3C1A85283E3399BF96B42B9FBC02AF68D993DE5507D605577D8` on both; replacing it requires fresh administrator consent after reviewed security acceptance. + +## Revocation and recovery + +The installed file remains an explicit OS-admin-owned asset; no self-updater may overwrite it. If its installed checksum/ownership drifts or the corresponding package manifest changes, reject the privileged attempt. An administrator may disable/revoke this isolated entrypoint only after checking for active upgrade/rollback guard state and preserving the accepted installed backend and user settings. Do not remove any persistent watchdog or pending rollback evidence as part of bootstrap cache cleanup. + +## Provenance + +Original review-only source parent: `c31783167ce22be2c30e379d2a99650056601f5a` (PR #63). New test: `tests/r781_privileged_entry_contract.py`. Scope: source/prototype until native hosted and physical, approved privileged acceptance is demonstrated. diff --git a/linux/app/direct_internet_method.py b/linux/app/direct_internet_method.py index f7d797e4..f6df2e12 100644 --- a/linux/app/direct_internet_method.py +++ b/linux/app/direct_internet_method.py @@ -7,9 +7,11 @@ import pathlib import re import shutil +import stat import subprocess import tempfile import threading +from concurrent.futures import ThreadPoolExecutor import time import urllib.parse import urllib.request @@ -20,9 +22,10 @@ gi.require_version("Adw","1") from gi.repository import Gtk, Adw, GLib, Gdk from router_gateway import RouterGatewayWindow +from socket_fallback import LocalSocketFallback APP_ID="io.github.god13emad.DirectInternetMethod" -VERSION="1.5.4" +VERSION="1.5.7" GLib.set_prgname("DirectInternetMethod") GLib.set_application_name("Direct Internet Method") try: @@ -46,7 +49,9 @@ SCOPE_FILE=APP_HOME/"scope.txt" SELECTIVE_DNS_OPT_IN=APP_HOME/"selective-dns-optin.txt" SELECTIVE_DNS_TOKEN="I_ACCEPT_UNENCRYPTED_SELECTIVE_DNS_V1" -COEXIST_HELPER_SHA256="e52fcaeb9c60d9e43d4a5b75462455ad8c5ee4d82fee679a0cc4a3520d8abea5" +ENCRYPTED_DNS_OPT_IN=APP_HOME/"encrypted-dns-optin.txt" +ENCRYPTED_DNS_TOKEN="I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1" +COEXIST_HELPER_SHA256="82051dce83480a9c1c291ab9f13acff44b359655387dc253cb6db00eaa27795a" LATEST_API="https://api.github.com/repos/GOD13emad/DirectInternetMethod/releases/latest" def selective_dns_optin_state(): @@ -82,6 +87,47 @@ def selective_dns_optin_save(enabled): SELECTIVE_DNS_OPT_IN.unlink() +def encrypted_dns_optin_state(): + """Distinct explicit consent; NEVER convert old unencrypted consent.""" + try: + if ENCRYPTED_DNS_OPT_IN.is_symlink(): + return "INVALID" + if not ENCRYPTED_DNS_OPT_IN.exists(): + return "OFF" + st=ENCRYPTED_DNS_OPT_IN.stat() + if not ENCRYPTED_DNS_OPT_IN.is_file() or st.st_uid!=os.getuid() or st.st_mode & 0o077: + return "INVALID" + if selective_dns_optin_state()!="OFF": + return "INVALID" + return ("ON" if ENCRYPTED_DNS_OPT_IN.read_text(encoding="utf-8").strip()== + ENCRYPTED_DNS_TOKEN else "INVALID") + except Exception: + return "INVALID" + + +def encrypted_dns_optin_save(enabled): + if ENCRYPTED_DNS_OPT_IN.is_symlink(): + raise ValueError("Encrypted DNS consent symlink is unsafe") + if enabled: + if selective_dns_optin_state()!="OFF": + raise ValueError("Unencrypted DNS consent must first be disabled") + ENCRYPTED_DNS_OPT_IN.parent.mkdir(parents=True,exist_ok=True) + fd,temp=tempfile.mkstemp(prefix=".dim-encrypted-consent-",dir=str(ENCRYPTED_DNS_OPT_IN.parent)) + try: + with os.fdopen(fd,"w",encoding="utf-8") as f: + f.write(ENCRYPTED_DNS_TOKEN+"\n") + f.flush();os.fsync(f.fileno()) + os.chmod(temp,0o600) + os.replace(temp,ENCRYPTED_DNS_OPT_IN) + finally: + if os.path.lexists(temp):os.unlink(temp) + elif ENCRYPTED_DNS_OPT_IN.exists(): + st=ENCRYPTED_DNS_OPT_IN.stat() + if st.st_uid!=os.getuid() or not ENCRYPTED_DNS_OPT_IN.is_file(): + raise ValueError("Foreign encrypted DNS consent cannot be deleted") + ENCRYPTED_DNS_OPT_IN.unlink() + + def ui_test_log(message): p=os.environ.get("DIM_UI_TEST_LOG") if p: @@ -138,7 +184,7 @@ def sync_adult_catalog(force=False): last_error=None for source_url in ADULT_CATALOG_URLS: try: - req=urllib.request.Request(source_url,headers={"User-Agent":"DirectInternetMethod/1.5.4"}) + req=urllib.request.Request(source_url,headers={"User-Agent":"DirectInternetMethod/1.5.7"}) with urllib.request.urlopen(req,timeout=20) as resp: raw=resp.read(4*1024*1024+1) if len(raw)<100000 or len(raw)>4*1024*1024: @@ -292,7 +338,7 @@ def _coexistence_backend_ready(): not helper.is_file() or not meta.is_file()): return False installed=json.loads(meta.read_text(encoding="utf-8")) - return (installed.get("version") in ("1.5.2","1.5.3","1.5.4") and + return (installed.get("version") == "1.5.7" and installed.get("uid")==os.getuid() and hashlib.sha256(helper.read_bytes()).hexdigest()==COEXIST_HELPER_SHA256) except Exception: @@ -352,6 +398,24 @@ def load_state(): if not _coexistence_backend_ready(): return {"mode":"BACKEND_UPDATE_REQUIRED", "detail":"Tailscale split mode is safe, but the installed privileged backend is older or unverified. Start is disabled until the verified backend is installed with rollback; Tailscale and DNS are unchanged."} + # Render the SAME explicit encrypted-DNS consent semantics that the + # protected controller requires. Never advertise "DNS off" when + # DoH is opted in, nor enable Start with an invalid/conflicting + # token that would fail after privileged network actions begin. + encrypted=encrypted_dns_optin_state() + if encrypted=="INVALID": + return {"mode":"CONFIG_REQUIRED", + "detail":"Encrypted DNS consent is invalid, unsafe or conflicts with unencrypted DNS consent. Open Strategy to resolve it before Start; DNS and MagicDNS remain unchanged."} + if encrypted=="ON": + try: + method=STRATEGY_FILE.read_text(encoding="utf-8").strip().lower() + except Exception: + method="invalid" + if method!="balanced": + return {"mode":"CONFIG_REQUIRED", + "detail":"Encrypted targeted DNS requires an explicit Balanced strategy and Targeted scope. Reopen Strategy and Save before Start; no network settings changed."} + return {"mode":"OFF", + "detail":"Tailscale split network active. Encrypted targeted DNS (Control D DoH) is explicitly OPTED IN for selected sites. Start uses Balanced DPI with guarded per-link DoH; physical DNS and MagicDNS stay protected. HTTPS reachability is not guaranteed."} opt=selective_dns_optin_state() if opt=="INVALID": return {"mode":"CONFIG_REQUIRED","detail":"Experimental split-DNS preference is unsafe or unrecognized. Reopen Strategy and explicitly set it."} @@ -364,7 +428,7 @@ def load_state(): return {"mode":"CONFIG_REQUIRED", "detail":"Experimental split DNS requires Targeted + Balanced. Plain, unencrypted third-party UDP DNS is used only for selected sites; disable it or select Balanced in Strategy."} return {"mode":"OFF","detail":"Tailscale split network active. Experimental selective DNS is OPTED IN: third-party UDP upstream is UNENCRYPTED; MagicDNS stays separate. Start runs Balanced DPI and crash-guarded local DNS."} - return {"mode":"OFF","detail":"Tailscale split network active. Start uses physical-interface DPI; system DNS/MagicDNS remain unchanged. Experimental selective DNS is OFF."} + return {"mode":"OFF","detail":"Tailscale split network active. Start uses physical-interface DPI; system DNS/MagicDNS remain unchanged. Optional encrypted and unencrypted selective DNS are both OFF."} return {"mode":"OFF","detail":"No active Direct Internet Method state."} uid=os.getuid() preserved=s.get("dnsMode")=="system-preserved" and bool(s.get("tailscaleCoexistence")) @@ -373,8 +437,30 @@ def load_state(): _systemd_unit_matches(s.get("ctrldUnit"),s.get("ctrldPid"),f"directinternetmethod-ctrld-{uid}.service")) nfqws_ok=_systemd_unit_matches(s.get("nfqwsUnit"),s.get("nfqwsPid"),f"directinternetmethod-nfqws-{uid}.service") if ctrld_ok and nfqws_ok: + # NFQUEUE nft output rules are bound to the physical interface + # recorded at Start. A changed/default route silently bypasses all + # filtering even while the nfqws PID and systemd unit remain ACTIVE. + # Read current routing only; never switch a user's network here. + recorded_iface=str(s.get("physicalInterface") or "") + current_iface=_top_default_iface() + if not recorded_iface or not current_iface or current_iface!=recorded_iface: + return {"mode":"STALE", + "detail":f"Network path changed: owned NFQUEUE rules use {recorded_iface or 'missing'}, " + f"but the current default route uses {current_iface or 'missing'}. " + "Stop or Recovery, then Start after the route stabilizes; " + "Tailscale and DNS were not altered."} if preserved: - if s.get("selectiveDns"): + # Authoritative ACTIVE state is written by the protected helper, + # not inferred from (possibly since-revoked) user preference files. + if s.get("selectiveDnsEncrypted") and s.get("selectiveDns"): + return {"mode":"CONFLICT", + "detail":"Both mutually exclusive selective DNS modes are marked active. Stop and use Recovery before any new Start; physical DNS and MagicDNS must be preserved."} + if s.get("selectiveDnsEncrypted"): + detail=(f"Tailscale coexistence: {s.get('physicalInterface','?')} " + "Encrypted targeted DoH ACTIVE for selected sites (Control D HTTPS upstream); " + "physical DNS and MagicDNS preserved; Balanced DPI running. " + "HTTPS target reachability requires real probe verification.") + elif s.get("selectiveDns"): detail=f"Tailscale coexistence: {s.get('physicalInterface','?')} Local experimental selective DNS ACTIVE; third-party upstream UDP is UNENCRYPTED, MagicDNS remains separate; Balanced DPI running" else: detail=f"Tailscale coexistence: {s.get('physicalInterface','?')} DNS/MagicDNS preserved Methods: HTTP + TLS/SNI + QUIC (DPI only)" @@ -403,6 +489,12 @@ def verify_live(): return {"ok":False,"error":"STATE_UNREADABLE_AFTER_START"} if not physical: return {"ok":False,"error":"PHYSICAL_INTERFACE_MISSING"} + current_iface=_top_default_iface() + if not current_iface or current_iface!=physical: + return {"ok":False,"error":"PHYSICAL_INTERFACE_ROUTE_DRIFT", + "recordedInterface":physical,"currentInterface":current_iface} + if s.get("selectiveDns") and s.get("selectiveDnsEncrypted"): + return {"ok":False,"error":"CONFLICTING_SELECTIVE_DNS_STATE"} if _external_tunnel_active(): return {"ok":False,"error":"EXTERNAL_TUNNEL_APPEARED_AFTER_START"} def curl(url): @@ -413,25 +505,36 @@ def curl(url): return {"exit":p.returncode,"meta":p.stdout.strip(),"error":p.stderr.strip()} d=subprocess.run(["getent","ahostsv4","www.youtube.com"],text=True,capture_output=True) h=curl("http://www.youtube.com/") - y=curl("https://www.youtube.com/" if s.get("selectiveDns") else "https://www.youtube.com/generate_204") + # R674-R675: in DPI-only mode /generate_204 can pass while the + # real YouTube page is blocked. Require BOTH in that mode. + # R538: selected DNS may legitimately return 404 on generate_204 + # despite a working real homepage. Preserve its accepted page-only gate. + selected_dns=bool(s.get("selectiveDns") or s.get("selectiveDnsEncrypted")) + y=curl("https://www.youtube.com/") + y204=None if selected_dns else curl("https://www.youtube.com/generate_204") o=curl("https://api.openai.com/v1/models") g=curl("https://github.com/") m=curl("https://gemini.google.com/") ok=(not _external_tunnel_active() and d.returncode==0 and h["exit"]==0 and h["meta"].startswith(("200|","301|","302|","303|","307|","308|")) - and y["exit"]==0 and y["meta"].startswith(("200|","204|")) + and y["exit"]==0 and y["meta"].startswith(("200|")) + and (selected_dns or (y204 is not None and y204["exit"]==0 and y204["meta"].startswith(("204|")))) and o["exit"]==0 and o["meta"].startswith(("401|","403|")) and g["exit"]==0 and g["meta"].startswith(("200|"))) dns_preserved=s.get("dnsMode")=="system-preserved" - methods=([] if dns_preserved else ["encrypted-dns-doh"])+["http-host-split-tcp80","tls-sni-desync-tcp443","quic-desync-udp443"] - return {"ok":ok,"physicalInterface":physical,"dnsMode":s.get("dnsMode","direct-doh"),"directMethods":methods,"youtubeHttp80":h,"youtube":y,"openai":o,"github":g,"gemini":m} + methods=(["encrypted-selective-doh"] if dns_preserved and s.get("selectiveDnsEncrypted") + else [] if dns_preserved else ["encrypted-dns-doh"])+[ + "http-host-split-tcp80","tls-sni-desync-tcp443","quic-desync-udp443"] + return {"ok":ok,"physicalInterface":physical,"dnsMode":s.get("dnsMode","direct-doh"),"directMethods":methods,"youtubeHttp80":h,"youtube":y,"youtube204":y204,"openai":o,"github":g,"gemini":m} def _version_tuple(text): - s=text.strip().lstrip("vV") - try: - return tuple(int(x) for x in s.split(".")[:3]) - except Exception: - return (0,0,0) + """Stable must sort newer than prerelease with same three-digit version.""" + s=str(text).strip().lstrip("vV") + hit=re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-(rc)(\d+))?",s) + if not hit: + return (0,0,0,-1,0) + x,y,z,kind,number=hit.groups() + return (int(x),int(y),int(z), 0 if kind else 1, int(number or 0)) def check_update_sync(): req=urllib.request.Request(LATEST_API,headers={"User-Agent":f"DirectInternetMethod/{VERSION}","Accept":"application/vnd.github+json"}) @@ -467,56 +570,141 @@ def _download(url,path): shutil.copyfileobj(src,dst) def download_and_install_update(info): + # R739: one private, automatically disposed cache slot per update attempt. + # Never overwrite stale/user-owned downloads, nor retain a second installer. + release_version=str(info.get("version") or "") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+",release_version): + raise RuntimeError("Unsafe Linux update release version.") + archive_name=f"DirectInternetMethod_{release_version}_Linux_x86_64.zip" + asset=info.get("asset") or {} + sums=info.get("sums") or {} + if (asset.get("name")!=archive_name or sums.get("name")!="SHA256SUMS.txt"): + raise RuntimeError("Unexpected Linux update asset name.") + for item in (asset,sums): + if not re.fullmatch(r"sha256:[0-9A-Fa-f]{64}",str(item.get("digest") or "")): + raise RuntimeError("Linux update asset digest is invalid.") cache=pathlib.Path.home()/".cache/DirectInternetMethod/updates" - cache.mkdir(parents=True,exist_ok=True) - sums_path=cache/"SHA256SUMS.txt" - zip_path=cache/info["asset"]["name"] - _download(info["sums"]["url"],sums_path) - sums_actual=hashlib.sha256(sums_path.read_bytes()).hexdigest().upper() - sums_api=str(info["sums"]["digest"]).split(":",1)[1].upper() - if sums_actual!=sums_api: - sums_path.unlink(missing_ok=True) - raise RuntimeError("Downloaded SHA256SUMS.txt does not match GitHub asset digest.") - matches=[] - for raw in sums_path.read_text(encoding="utf-8").splitlines(): - parts=raw.strip().split() - if len(parts)>=2 and parts[-1].lstrip("*")==info["asset"]["name"]: - matches.append(parts[0].upper()) - if len(matches)!=1 or not re.fullmatch(r"[0-9A-F]{64}",matches[0]): - raise RuntimeError("SHA256SUMS.txt must contain exactly one valid checksum for the Linux update.") - expected=matches[0] - api_expected=str(info["asset"]["digest"]).split(":",1)[1].upper() - if expected!=api_expected: - raise RuntimeError("Linux update checksum disagrees with GitHub asset digest.") - _download(info["asset"]["url"],zip_path) - actual=hashlib.sha256(zip_path.read_bytes()).hexdigest().upper() - if actual!=expected or actual!=api_expected: - zip_path.unlink(missing_ok=True) - raise RuntimeError("Downloaded Linux update failed three-way SHA-256 verification.") - - temp=pathlib.Path(tempfile.mkdtemp(prefix="dim-update-",dir=str(cache))) - with zipfile.ZipFile(zip_path) as z: - base=temp.resolve() - for member in z.infolist(): - target=(temp/member.filename).resolve() - if base not in target.parents and target!=base: - raise RuntimeError("Unsafe update archive path.") - z.extractall(temp) - installers=list(temp.rglob("install.sh")) - if len(installers)!=1: - raise RuntimeError("Update archive does not contain exactly one installer.") - p=subprocess.run(["bash",str(installers[0])],text=True,capture_output=True,timeout=180) - if p.returncode!=0: - raise RuntimeError((p.stderr or p.stdout or f"installer exit {p.returncode}").strip()) - return p.stdout.strip() + if (cache.is_symlink() or cache.parent.is_symlink() + or cache.parent.parent.is_symlink()): + raise RuntimeError("Update cache path contains a symlink.") + created_parent=not cache.parent.exists() + created_cache=not cache.exists() + cache.mkdir(parents=True,exist_ok=True,mode=0o700) + if (not cache.is_dir() or cache.is_symlink() or + (hasattr(os,"getuid") and cache.stat().st_uid!=os.getuid())): + raise RuntimeError("Update cache is not owned by the current user.") + try: + with tempfile.TemporaryDirectory(prefix="dim-update-",dir=str(cache)) as work: + temp=pathlib.Path(work) + sums_path=temp/"SHA256SUMS.txt" + zip_path=temp/archive_name + _download(sums["url"],sums_path) + actual_sums=hashlib.sha256(sums_path.read_bytes()).hexdigest().upper() + if actual_sums!=sums["digest"].split(":",1)[1].upper(): + raise RuntimeError("Downloaded SHA256SUMS.txt does not match GitHub asset digest.") + matches=[] + for raw in sums_path.read_text(encoding="utf-8").splitlines(): + parts=raw.strip().split() + if len(parts)>=2 and parts[-1].lstrip("*")==archive_name: + matches.append(parts[0].upper()) + if len(matches)!=1 or not re.fullmatch(r"[0-9A-F]{64}",matches[0]): + raise RuntimeError("SHA256SUMS.txt must contain exactly one Linux update checksum.") + expected=matches[0] + if expected!=asset["digest"].split(":",1)[1].upper(): + raise RuntimeError("Linux update checksum disagrees with GitHub asset digest.") + _download(asset["url"],zip_path) + if hashlib.sha256(zip_path.read_bytes()).hexdigest().upper()!=expected: + raise RuntimeError("Downloaded Linux update failed three-way SHA-256 verification.") + + prefix=archive_name.removesuffix(".zip")+"/" + with zipfile.ZipFile(zip_path) as z: + members=z.infolist() + if len(members)!=30 or len({m.filename for m in members})!=len(members): + raise RuntimeError("Linux update archive has an unexpected file inventory.") + names={} + total=0 + for member in members: + name=member.filename + parts=pathlib.PurePosixPath(name).parts + if (not name.startswith(prefix) or "\\" in name or + ".." in parts or member.is_dir() or + pathlib.PurePosixPath(name).as_posix()!=name or + stat.S_ISLNK(member.external_attr>>16) or + member.file_size>64000000): + raise RuntimeError("Unsafe Linux update archive entry.") + total+=member.file_size + if total>256000000: + raise RuntimeError("Linux update archive is oversized.") + names[name]=member + manifest_name=prefix+"PACKAGE_MANIFEST.json" + checksum_name=prefix+"SHA256SUMS.txt" + try: + manifest=json.loads(z.read(manifest_name)) + inside_sums=z.read(checksum_name).decode("utf-8") + except (KeyError,ValueError,UnicodeError) as exc: + raise RuntimeError("Linux update internal manifest is invalid.") from exc + if (manifest.get("schema")!=1 or + manifest.get("product")!="Direct Internet Method" or + manifest.get("version")!=release_version or + manifest.get("platform")!="linux-x86_64" or + not isinstance(manifest.get("files"),list) or + len(manifest["files"])!=28): + raise RuntimeError("Linux update package identity does not match release.") + referenced=set() + expected_lines=[] + for entry in manifest["files"]: + if not isinstance(entry,dict) or set(entry)!={"file","bytes","sha256"}: + raise RuntimeError("Linux update manifest member is invalid.") + rel=entry["file"] + if (not isinstance(rel,str) or not rel or "\\" in rel or + pathlib.PurePosixPath(rel).is_absolute() or + ".." in pathlib.PurePosixPath(rel).parts or + pathlib.PurePosixPath(rel).as_posix()!=rel or + rel in referenced): + raise RuntimeError("Linux update manifest contains an unsafe path.") + referenced.add(rel) + name=prefix+rel + if (name not in names or type(entry["bytes"]) is not int or + entry["bytes"]<0 or not isinstance(entry["sha256"],str) or + not re.fullmatch(r"[0-9A-F]{64}",entry["sha256"])): + raise RuntimeError("Linux update manifest digest is invalid.") + data=z.read(name) + if (len(data)!=entry["bytes"] or + hashlib.sha256(data).hexdigest().upper()!=entry["sha256"]): + raise RuntimeError("Linux update package payload digest mismatch.") + expected_lines.append(f'{entry["sha256"]} {rel}\n') + if (set(names)!={prefix+rel for rel in referenced}|{manifest_name,checksum_name} + or inside_sums!="".join(expected_lines) or + "install.sh" not in referenced): + raise RuntimeError("Linux update package manifest/file inventory mismatch.") + z.extractall(temp) + installer=temp/prefix/"install.sh" + if not installer.is_file() or installer.is_symlink(): + raise RuntimeError("Linux update installer path is invalid.") + p=subprocess.run(["bash",str(installer)],text=True,capture_output=True,timeout=180) + if p.returncode!=0: + raise RuntimeError((p.stderr or p.stdout or + f"installer exit {p.returncode}").strip()) + return p.stdout.strip() + finally: + # Only remove empty directories *created by this attempt*. Never touch + # preexisting cache files or any unrelated user data. + if created_cache: + try:cache.rmdir() + except OSError:pass + if created_parent: + try:cache.parent.rmdir() + except OSError:pass class Window(Adw.ApplicationWindow): def __init__(self,app): super().__init__(application=app,title="Direct Internet Method") - self.set_default_size(900,590) - self.set_size_request(780,540) + self.set_default_size(900,685) + self.set_size_request(780,615) self.set_resizable(True) self.available_update=None + # R747: local socket alternative exists only for this GUI lifetime. + self.local_socket_bridge=None self.adult_check_enabled=load_adult_site_check() if self.adult_check_enabled: try: save_adult_site_check(True) @@ -570,10 +758,35 @@ def __init__(self,app): self.adult_switch=Gtk.Switch(active=self.adult_check_enabled);live_header.append(self.adult_switch) self.adult_switch.connect("notify::active",self.on_adult_toggle) live.append(live_header) + self.core_live=Gtk.Label(label="Core HTTPS: Available when Direct Method is active",xalign=0) + self.core_live.add_css_class("dim-label");live.append(self.core_live) self.gemini_live=Gtk.Label(label="Gemini: —",xalign=0);self.gemini_live.add_css_class("dim-label");live.append(self.gemini_live) self.adult_live=Gtk.Label(label="Adult coverage: "+("Checking…" if self.adult_check_enabled else "Disabled"),xalign=0);self.adult_live.add_css_class("dim-label");live.append(self.adult_live) box.append(live) + # R747: independent, explicit, session-only SOCKS5 opt-in. + # Never edits the user's Firefox/Brave/system proxy configuration. + local_card=Gtk.Box(orientation=Gtk.Orientation.VERTICAL,spacing=7) + local_card.add_css_class("card") + local_heading=Gtk.Label(label="Optional selected-site local SOCKS5 (manual app opt-in)",xalign=0) + local_heading.add_css_class("heading") + local_card.append(local_heading) + self.local_socket_status=Gtk.Label( + label="OFF — only approved public YouTube-related sites and three service controls; " + "not system-wide access.",xalign=0,wrap=True) + self.local_socket_status.add_css_class("dim-label") + local_card.append(self.local_socket_status) + local_buttons=Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,spacing=8) + self.local_socket_start_button=Gtk.Button(label="Start local SOCKS5 (session only)") + self.local_socket_stop_button=Gtk.Button(label="Stop local SOCKS5") + self.local_socket_stop_button.set_sensitive(False) + self.local_socket_start_button.connect("clicked",self.on_local_socket_start) + self.local_socket_stop_button.connect("clicked",self.on_local_socket_stop) + local_buttons.append(self.local_socket_start_button) + local_buttons.append(self.local_socket_stop_button) + local_card.append(local_buttons) + box.append(local_card) + buttons=Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,spacing=8);buttons.set_homogeneous(True) self.buttons={} for text_,action,css in [ @@ -590,22 +803,93 @@ def __init__(self,app): keys=Gtk.EventControllerKey();keys.connect("key-pressed",self.on_key);self.add_controller(keys) self.refresh() + # Observe route/process changes without doing periodic web requests. + self._status_timer_id=GLib.timeout_add_seconds(15,self.periodic_state_tick) + self.connect("close-request",self.stop_status_timer) + self.connect("close-request",self.close_local_socket) if self.adult_check_enabled: threading.Thread(target=self.adult_sync_worker,args=(False,),daemon=True).start() threading.Thread(target=self.update_check_worker,args=(False,),daemon=True).start() + def on_local_socket_start(self,*_args): + # The exact user click is the only enabling action. No persistent + # configuration, no Root service and no changes to browser/DNS/proxy. + if self.local_socket_bridge is not None: + return + try: + if STATE.is_symlink() or STATE.exists(): + raise RuntimeError("Stop the Direct Method engine first.") + if _external_tunnel_active(): + raise RuntimeError("An unsupported external tunnel is active.") + candidate=LocalSocketFallback() + try: + port=candidate.start() + except BaseException: + candidate.stop() + raise + self.local_socket_bridge=candidate + except Exception as exc: + self.local_socket_status.set_text("Local SOCKS5 OFF — "+str(exc)) + return + self.local_socket_start_button.set_sensitive(False) + self.local_socket_stop_button.set_sensitive(True) + self.local_socket_status.set_text( + f"ON — SOCKS5 127.0.0.1:{port}. Configure ONLY your chosen app " + "with remote DNS (socks5h). Only allowlisted HTTPS destinations; " + "no system/global proxy. Your app validates TLS certificates. " + "Stop here or close this GUI to disable." + ) + + def on_local_socket_stop(self,*_args): + self.close_local_socket() + self.local_socket_status.set_text( + "OFF — no localhost SOCKS5 process, no system proxy changes." + ) + + def close_local_socket(self,*_args): + bridge=self.local_socket_bridge + self.local_socket_bridge=None + if bridge is not None: + bridge.stop() + self.local_socket_start_button.set_sensitive(True) + self.local_socket_stop_button.set_sensitive(False) + return False + def toggle_maximize(self,*_args): if self.is_maximized(): self.unmaximize() else: self.maximize() - def refresh(self): - s=load_state();mode=s["mode"] + def stop_status_timer(self,*_args): + source_id=getattr(self,"_status_timer_id",0) + if source_id: + GLib.source_remove(source_id) + self._status_timer_id=0 + return False + + def periodic_state_tick(self): + # A read-only UI observation; no route/DNS/tunnel/service actions and + # no HTTP probes. The explicit Refresh button owns live website checks. + try: + current=load_state() + except Exception: + current={"mode":"STALE", + "detail":"Automatic status verification failed. Use Refresh or Recovery."} + visible=self.status.get_text().removeprefix("Status: ") + if current["mode"]!=visible and not (current["mode"]=="ACTIVE" and visible=="PARTIAL"): + self.refresh(state=current,start_checks=False) + elif visible!="PARTIAL" and current["detail"]!=self.detail.get_text(): + self.detail.set_text(current["detail"]) + return True + + def refresh(self,state=None,start_checks=True): + s=state if state is not None else load_state();mode=s["mode"] self.status.set_text("Status: "+mode);self.detail.set_text(s["detail"]) for css in ("success","warning","error","dim-label"): self.status.remove_css_class(css) - self.status.add_css_class("success" if mode=="ACTIVE" else "warning" if mode in ("BLOCKED","CONFIG_REQUIRED","BACKEND_UPDATE_REQUIRED","CONFLICT") else "error" if mode=="STALE" else "dim-label") + # R735_GUI_NO_UNVERIFIED_GREEN: ACTIVE proves engine ownership, not site HTTPS. + self.status.add_css_class("warning" if mode in ("ACTIVE","BLOCKED","CONFIG_REQUIRED","BACKEND_UPDATE_REQUIRED","CONFLICT") else "error" if mode=="STALE" else "dim-label") start_ok=(mode=="OFF") stop_ok=(mode in ("ACTIVE","CONFLICT","STALE")) recovery_ok=(mode in ("CONFLICT","STALE")) @@ -620,11 +904,17 @@ def refresh(self): if recovery_ok:self.buttons["recovery"].add_css_class("destructive-action") self.live_check_generation+=1 generation=self.live_check_generation - if mode=="ACTIVE": + if mode=="ACTIVE" and start_checks: + if hasattr(self,"core_live"):self.core_live.set_text("Core HTTPS: Checking YouTube, OpenAI, GitHub…") self.gemini_live.set_text("Gemini: Checking…") self.adult_live.set_text("Adult coverage: Checking…" if self.adult_check_enabled else "Adult coverage: Disabled") threading.Thread(target=self.live_checks_worker,args=(generation,),daemon=True).start() + elif mode=="ACTIVE": + if hasattr(self,"core_live"):self.core_live.set_text("Core HTTPS: Press Refresh to verify") + self.gemini_live.set_text("Gemini: Press Refresh for a live check") + self.adult_live.set_text("Adult coverage: Press Refresh for a live check" if self.adult_check_enabled else "Adult coverage: Disabled") else: + if hasattr(self,"core_live"):self.core_live.set_text("Core HTTPS: Available when Direct Method is active") self.gemini_live.set_text("Gemini: Available when Direct Method is active") self.adult_live.set_text("Adult coverage: Disabled" if not self.adult_check_enabled else "Adult coverage: Available when Direct Method is active") @@ -632,11 +922,31 @@ def live_checks_worker(self,generation): try: state=json.loads(STATE.read_text(encoding="utf-8")) physical=str(state.get("physicalInterface") or "") + selected_dns=bool(state.get("selectiveDns") or state.get("selectiveDnsEncrypted")) except Exception: physical="" + selected_dns=False if not physical: + GLib.idle_add(self.finish_core_checks,generation,{"error":"PHYSICAL_INTERFACE_MISSING"}) GLib.idle_add(self.finish_live_checks,generation,{"ok":False,"reached":False},[{"ok":False,"reached":False}] if self.adult_check_enabled else None) return + # Explicit Refresh only; no periodic web polling, no DNS/route mutation. + checks={ + "youtube":("https://www.youtube.com/",{"200"}), + "openai":("https://api.openai.com/v1/models",{"200","401","403"}), + "github":("https://github.com/",{"200"}), + } + if not selected_dns: + checks["youtube204"]=("https://www.youtube.com/generate_204",{"204"}) + try: + with ThreadPoolExecutor(max_workers=4) as pool: + futures={name:pool.submit(probe_url,url,physical,allowed) + for name,(url,allowed) in checks.items()} + core={name:future.result() for name,future in futures.items()} + core["selected_dns"]=selected_dns + except Exception: + core={"error":"CORE_PROBE_FAILED"} + GLib.idle_add(self.finish_core_checks,generation,core) gemini=probe_url("https://gemini.google.com/",physical,{"200","301","302","303","307","308"}) adult=None if self.adult_check_enabled: @@ -649,6 +959,33 @@ def live_checks_worker(self,generation): ] GLib.idle_add(self.finish_live_checks,generation,gemini,adult) + def finish_core_checks(self,generation,core): + if generation!=self.live_check_generation: + return False + try: + if load_state().get("mode")!="ACTIVE": + return False + except Exception: + return False + # Selected DNS acceptance intentionally requires the real page only. + # DPI-only acceptance requires BOTH page200 and generate_204=204. + selected=bool(core.get("selected_dns")) + healthy=(all(isinstance(core.get(site),dict) and core[site].get("ok") is True + for site in ("youtube","openai","github")) + and (selected or (isinstance(core.get("youtube204"),dict) + and core["youtube204"].get("ok") is True))) + self.status.set_text("Status: ACTIVE" if healthy else "Status: PARTIAL") + self.status.remove_css_class("success") + self.status.remove_css_class("warning") + self.status.add_css_class("success" if healthy else "warning") + if hasattr(self,"core_live"): + if healthy: + self.core_live.set_text("Core HTTPS: healthy · YouTube page, OpenAI, GitHub" + + (" · selected DNS" if selected else " · YouTube 204")) + else: + self.core_live.set_text("Core HTTPS: PARTIAL · at least one service unreachable; Stop remains available") + return False + def finish_live_checks(self,generation,gemini,adult): if generation!=self.live_check_generation: return False @@ -744,6 +1081,11 @@ def open_strategy(self): selective_toggle=Gtk.CheckButton(label="Opt in: experimental selected-sites DNS (unencrypted third-party UDP)") selective_toggle.set_active(selective_dns_optin_state()=="ON") outer.append(selective_toggle) + encrypted_toggle=Gtk.CheckButton(label="Opt in: encrypted targeted DNS (DoH to Control D; experimental)") + encrypted_toggle.set_active(encrypted_dns_optin_state()=="ON") + outer.append(encrypted_toggle) + enc_warn=Gtk.Label(label="Separate encrypted-DNS opt-in. Requires Targeted + Balanced and Tailscale split mode, without Seman or another VPN. Routes only supported domains over DoH. Does not guarantee all sites or remove service-side restrictions. The old unencrypted DNS consent does NOT authorize this mode.",xalign=0,wrap=True) + enc_warn.add_css_class("dim-label");outer.append(enc_warn) warn=Gtk.Label(label="Privacy warning: experimental DNS sends selected site names to 194.225.152.10 over UNENCRYPTED UDP; this is not DoH. Only Targeted + Balanced with Tailscale split mode is supported. Disabled by default. Route, Google DNS and MagicDNS must stay protected.",xalign=0,wrap=True) warn.add_css_class("dim-label");outer.append(warn) hint=Gtk.Label(label="Targeted mode affects only built-in and Custom Sites. All Sites mode can help unknown blocked domains, but may reduce compatibility or speed on some sites. Neither mode creates a VPN, proxy, or default-route tunnel.",xalign=0,wrap=True) @@ -756,6 +1098,15 @@ def do_save(*_): try: value=("balanced","compatibility","strong")[int(combo.get_selected())] enable_dns=bool(selective_toggle.get_active()) + enable_encrypted=bool(encrypted_toggle.get_active()) + if enable_dns and enable_encrypted: + raise ValueError("Choose exactly one DNS option. The two consent modes cannot coexist.") + if encrypted_dns_optin_state()=="INVALID": + raise ValueError("Existing encrypted DNS consent is invalid or unsafe; inspect it before saving.") + if selective_dns_optin_state()=="INVALID": + raise ValueError("Existing unencrypted DNS consent is invalid; inspect it before saving.") + if enable_encrypted and (value!="balanced" or scope_toggle.get_active()): + raise ValueError("Encrypted DNS requires Targeted + Balanced and Tailscale split mode.") if enable_dns and (value!="balanced" or scope_toggle.get_active()): raise ValueError("Experimental unencrypted split DNS requires Balanced and Targeted. Select those options first.") STRATEGY_FILE.parent.mkdir(parents=True,exist_ok=True) @@ -768,8 +1119,12 @@ def do_save(*_): st=SCOPE_FILE.with_suffix(".tmp") st.write_text(scope_value+"\n",encoding="utf-8") os.replace(st,SCOPE_FILE) + # Explicitly revoke old permission before granting new one. + # Never interpret the unencrypted consent token as encrypted approval. selective_dns_optin_save(enable_dns) - privacy=(" experimental UNENCRYPTED selected DNS is opted in" if enable_dns else " experimental DNS off") + encrypted_dns_optin_save(enable_encrypted) + privacy=(" encrypted selected DNS opt-in" if enable_encrypted else + " UNENCRYPTED selected DNS opt-in" if enable_dns else " experimental DNS off") self.result.set_text(f"Strategy saved: {value}; scope: {scope_value};{privacy}. Stop/Start to apply.") w.close();self.refresh() except Exception as e: @@ -779,6 +1134,9 @@ def do_save(*_): def on_action(self,_button,action): ui_test_log("ACTION:"+action) + if action=="start" and self.local_socket_bridge is not None: + self.result.set_text("Stop the optional local SOCKS5 before starting system DPI.") + return if action=="refresh": self.refresh();self.result.set_text("");return if action=="router": diff --git a/linux/app/direct_method_helper.sh b/linux/app/direct_method_helper.sh index eee738ef..e49af695 100644 --- a/linux/app/direct_method_helper.sh +++ b/linux/app/direct_method_helper.sh @@ -24,6 +24,9 @@ SCOPE_FILE="$STATE_BASE/scope.txt" SELECTIVE_DNS_OPT_IN="$STATE_BASE/selective-dns-optin.txt" SELECTIVE_DNS="/usr/lib/directinternetmethod/selective_dns.py" SELECTIVE_DNS_ENABLED=0 +ENCRYPTED_DNS_OPT_IN="$STATE_BASE/encrypted-dns-optin.txt" +ENCRYPTED_DNS="/usr/lib/directinternetmethod/encrypted_selective_dns.py" +ENCRYPTED_DNS_ENABLED=0 CREATED_STATE=0 CTRLD="$BIN/ctrld" NFQWS="$BIN/nfqws" @@ -154,7 +157,7 @@ import re, sys table, iface, mode = sys.argv[1:] lines = [line.strip() for line in sys.stdin if line.strip()] coexist = mode == "system-preserved" -expected_count = 9 if coexist else 8 +expected_count = 10 if coexist else 9 if len(lines) != expected_count: sys.exit(1) if lines[:2] != [f"table inet {table} {{", "chain output {"]: @@ -163,11 +166,13 @@ if not re.fullmatch(r"type filter hook output priority (?:mangle|-150); policy a sys.exit(1) if lines[-2:] != ["}", "}"]: sys.exit(1) -offset = 3 +if not re.fullmatch(r"meta mark & 0x0*40000000 == 0x0*40000000 return", lines[3]): + sys.exit(1) +offset = 4 if coexist: - if not re.fullmatch(r"meta mark & 0x0*ff0000 == 0x0*80000 return", lines[3]): + if not re.fullmatch(r"meta mark & 0x0*ff0000 == 0x0*80000 return", lines[4]): sys.exit(1) - offset = 4 + offset = 5 queue = r"ct original packets 1-6 queue (?:flags bypass to 200|num 200(?: flags)? bypass)" dev = r"oifname (?:" + re.escape(iface) + r"|" + re.escape(chr(34) + iface + chr(34)) + r")" for index, (proto, port) in enumerate((("tcp", 80), ("tcp", 443), ("udp", 443))): @@ -305,7 +310,7 @@ if not re.fullmatch(r"type filter hook output priority (?:mangle|-150); policy a sys.exit(1) dev=r"oifname (?:"+re.escape(iface)+r"|" + re.escape(chr(34)+iface+chr(34))+r")" queue=r"ct original packets 1-6 queue (?:flags bypass to 200|num 200(?: flags)? bypass)" -patterns=[] +patterns=[r"meta mark & 0x0*40000000 == 0x0*40000000 return"] if coexist=="1": patterns.append(r"meta mark & 0x0*ff0000 == 0x0*80000 return") for proto,port in (("tcp",80),("tcp",443),("udp",443)): @@ -324,9 +329,7 @@ cleanup_transient(){ # Restore original physical DNS FIRST, even if NFT ownership cannot # subsequently be established. Never leave ordinary Internet pointing # at a defunct local DNS service after a failed Start. - if [ -f /run/directinternetmethod-selective/session.json ]; then - python3 "$SELECTIVE_DNS" restore || return 88 - fi + restore_selective_dns || return 88 if [ "$CREATED_TABLE" -eq 1 ]; then if nft_table_presence; then if nft_table_transient_owned "$PHY" "$TAILSCALE_COEXIST"; then @@ -364,6 +367,7 @@ cleanup_transient(){ cleanup_state_owned(){ [ -f "$STATE" ] || return 0 + restore_selective_dns || return 88 local cp np phy nft_rc cp="$(state_value ctrldPid)" np="$(state_value nfqwsPid)" @@ -395,7 +399,31 @@ cleanup_state_owned(){ rm -f "$RUN_HOSTS" "$STATE" } +encrypted_dns_ready(){ + [ -x "$ENCRYPTED_DNS" ] && [ -f /run/directinternetmethod-encrypted/session.json ] || return 1 + local payload + payload="$(python3 "$ENCRYPTED_DNS" status 2>/dev/null)" || return 1 + printf '%s\n' "$payload" | python3 -c 'import json,sys +try: + d=json.load(sys.stdin) + sys.exit(0 if d.get("ok") is True and d.get("state")=="ACTIVE" and + all(d.get(k)=="active" for k in ("monitor","ctrld","backstop")) else 1) +except Exception:sys.exit(1)' +} restore_selective_dns(){ + if [ -f /run/directinternetmethod-encrypted/session.json ]; then + [ -x "$ENCRYPTED_DNS" ] || { echo '{"ok":false,"error":"ENCRYPTED_CONTROLLER_MISSING"}' >&2; return 88; } + python3 "$ENCRYPTED_DNS" restore || { + echo '{"ok":false,"error":"ENCRYPTED_DNS_RESTORE_FAILED"}' >&2 + return 88 + } + fi + if [ -e /run/directinternetmethod-encrypted ] || + [ -e /run/directinternetmethod-encrypted-ctrld.toml ] || + ip link show dimesd0 >/dev/null 2>&1; then + echo '{"ok":false,"error":"ENCRYPTED_DNS_UNOWNED_RESIDUE"}' >&2 + return 88 + fi if [ -e /run/directinternetmethod-selective/session.json ]; then [ -x "$SELECTIVE_DNS" ] || { echo '{"ok":false,"error":"SELECTIVE_DNS_CONTROLLER_MISSING"}' >&2; return 88; } python3 "$SELECTIVE_DNS" restore || { @@ -412,6 +440,10 @@ verify_clean(){ [ ! -e /run/directinternetmethod-selective/session.json ] || return 1 [ ! -e /run/directinternetmethod-selective ] || return 1 [ ! -e /run/directinternetmethod-selective.conf ] || return 1 + [ ! -e /run/directinternetmethod-encrypted/session.json ] || return 1 + [ ! -e /run/directinternetmethod-encrypted ] || return 1 + [ ! -e /run/directinternetmethod-encrypted-ctrld.toml ] || return 1 + ! ip link show dimesd0 >/dev/null 2>&1 || return 1 ! ip link show "$DNS_IF" >/dev/null 2>&1 || return 1 if nft_table_presence; then return 1 @@ -441,6 +473,9 @@ status(){ else [ "$ca" = true ] && [ "$link" = true ] && dns_ok=true fi + if [ -f "$STATE" ] && [ "$(state_value selectiveDnsEncrypted)" = "True" ]; then + encrypted_dns_ready || dns_ok=false + fi local phy="" [ -f "$STATE" ] && phy="$(state_value physicalInterface)" nft_table_owned "$phy" && table=true || true @@ -532,6 +567,7 @@ case "$ACTION" in echo '{"ok":false,"error":"FOREIGN_OR_STALE_RESOURCE_WITHOUT_STATE"}' exit 81 fi + restore_selective_dns kill_all_owned_by_exe "$NFQWS" kill_all_owned_by_exe "$CTRLD" sleep .3 @@ -548,12 +584,30 @@ case "$ACTION" in trap rollback_on_exit EXIT if [ -f "$STATE" ]; then + # Do not claim ALREADY_ACTIVE when networking changed under a live + # owned nfqueue session. The nft OUTPUT rules match *only* the PHY + # saved at Start; a changed physical default route bypasses the engine. + # This is read-only, leaves the existing owner's resources in place, + # and requires an explicit Stop/Recovery and new Start to rebind. + PREVIOUS_PHY="$(state_value physicalInterface)" + CURRENT_PHY="$(physical_iface)" + if [ -z "$PREVIOUS_PHY" ] || [ -z "$CURRENT_PHY" ] || + [ "$PREVIOUS_PHY" != "$CURRENT_PHY" ] || + [ "$(top_iface)" != "$CURRENT_PHY" ]; then + START_COMMITTED=1 + trap - EXIT + echo '{"ok":false,"error":"PHYSICAL_INTERFACE_ROUTE_DRIFT","action":"STOP_THEN_START_AFTER_ROUTE_STABILIZES"}' + exit 79 + fi CPID="$(state_value ctrldPid)" NPID="$(state_value nfqwsPid)" PREVIOUS_DNS_MODE="$(state_value dnsMode)" DNS_HEALTHY=0 if [ "$PREVIOUS_DNS_MODE" = "system-preserved" ]; then [ "$CPID" = "0" ] && ! ip link show "$DNS_IF" >/dev/null 2>&1 && DNS_HEALTHY=1 + if [ "$(state_value selectiveDnsEncrypted)" = "True" ]; then + encrypted_dns_ready || DNS_HEALTHY=0 + fi else pid_owned "$CPID" "$CTRLD" && dns_link_owned && DNS_HEALTHY=1 fi @@ -578,6 +632,7 @@ case "$ACTION" in echo '{"ok":false,"error":"FOREIGN_OR_STALE_RESOURCE_WITHOUT_STATE"}' exit 69 fi + restore_selective_dns || exit 88 [ -x "$CTRLD" ] && [ -x "$NFQWS" ] && [ -s "$HOSTS" ] || { echo '{"ok":false,"error":"RUNTIME_NOT_PROVISIONED"}'; exit 70; } @@ -684,6 +739,15 @@ PY SELECTIVE_DNS_ENABLED=1 fi + if [ -e "$ENCRYPTED_DNS_OPT_IN" ] || [ -L "$ENCRYPTED_DNS_OPT_IN" ]; then + [ "$TAILSCALE_COEXIST" -eq 1 ] && [ "$STRATEGY" = "balanced" ] && + [ "$SCOPE" = "targeted" ] && [ -x "$ENCRYPTED_DNS" ] || + { echo '{"ok":false,"error":"ENCRYPTED_DNS_REQUIRES_TARGETED_BALANCED_TAILSCALE_AND_BACKEND"}'; exit 88; } + [ ! -e "$SELECTIVE_DNS_OPT_IN" ] && [ ! -L "$SELECTIVE_DNS_OPT_IN" ] || + { echo '{"ok":false,"error":"ENCRYPTED_AND_UNENCRYPTED_OPTIN_CONFLICT"}'; exit 88; } + ENCRYPTED_DNS_ENABLED=1 + fi + if [ "$DNS_MODE" = "direct-doh" ]; then ip link add "$DNS_IF" type dummy CREATED_LINK=1 @@ -741,10 +805,16 @@ EOF nft add table inet "$TABLE" CREATED_TABLE=1 nft "add chain inet $TABLE output { type filter hook output priority mangle; policy accept; }" + # nfqws v72.13 marks injected packets 0x40000000. Ignore those before + # queues to prevent requeue loops, independent of Tailscale safeguards. + nft "add rule inet $TABLE output meta mark & 0x40000000 == 0x40000000 return" if [ "$TAILSCALE_COEXIST" -eq 1 ]; then # Tailscale LinuxBypassMark 0x80000/0xff0000 protects its own DERP/control traffic. nft "add rule inet $TABLE output meta mark & 0xff0000 == 0x80000 return" fi + # R742: nfqws internal conntrack needs the original SYN to initialize + # direction/sequence. Preserve the 1-6 original-packet bound, interface, + # fail-open NFQUEUE, injected mark and Tailscale bypass above. nft add rule inet "$TABLE" output oifname "$PHY" tcp dport 80 ct original packets 1-6 queue num "$QNUM" bypass nft add rule inet "$TABLE" output oifname "$PHY" tcp dport 443 ct original packets 1-6 queue num "$QNUM" bypass nft add rule inet "$TABLE" output oifname "$PHY" udp dport 443 ct original packets 1-6 queue num "$QNUM" bypass @@ -756,6 +826,13 @@ EOF NFQWS_ARGS+=( --filter-tcp=80 "${HOST_ARGS[@]}" --dpi-desync=multisplit --dpi-desync-split-pos=method+2 ) + # A narrowly scoped YouTube ClientHello first-segment profile; + # leave general compatibility and Tailscale bypass unchanged. + NFQWS_ARGS+=( + --new --filter-tcp=443 + --hostlist-domains=youtube.com,youtu.be,googlevideo.com + --dpi-desync=multisplit --dpi-desync-split-pos=1 + ) if [ "$ADULT_ON" = "1" ]; then NFQWS_ARGS+=(--new --filter-tcp=443 --hostlist="$STRONG_OVERRIDE" --dpi-desync=multisplit --dpi-desync-split-pos=sniext+1) fi @@ -811,8 +888,16 @@ EOF } fi + if [ "$ENCRYPTED_DNS_ENABLED" -eq 1 ]; then + USER_HOME="$(getent passwd "$USER_UID" | cut -d: -f6)" + [ "$USER_HOME/.local/share/DirectInternetMethod" = "$STATE_BASE" ] || + { echo '{"ok":false,"error":"ENCRYPTED_DNS_HOME_MISMATCH"}'; exit 88; } + python3 "$ENCRYPTED_DNS" activate --uid "$USER_UID" --interface "$PHY" --home "$USER_HOME" || + { echo '{"ok":false,"error":"ENCRYPTED_DNS_ACTIVATION_FAILED"}'; exit 88; } + fi + [ "$DNS_MODE" != "system-preserved" ] || CPID=0 - python3 - "$STATE" "$CPID" "$NPID" "$PHY" "$USER_UID" "$USER_GID" "$CTRLD_UNIT" "$NFQWS_UNIT" "$STRATEGY" "$SCOPE" "$ADULT_ON" "$ADULT_COUNT" "$DNS_MODE" "$TAILSCALE_COEXIST" "$SELECTIVE_DNS_ENABLED" <<'PY' + python3 - "$STATE" "$CPID" "$NPID" "$PHY" "$USER_UID" "$USER_GID" "$CTRLD_UNIT" "$NFQWS_UNIT" "$STRATEGY" "$SCOPE" "$ADULT_ON" "$ADULT_COUNT" "$DNS_MODE" "$TAILSCALE_COEXIST" "$SELECTIVE_DNS_ENABLED" "$ENCRYPTED_DNS_ENABLED" <<'PY' import json,os,sys,time p=sys.argv[1] d={ @@ -823,6 +908,7 @@ d={ "dnsMode":sys.argv[13], "tailscaleCoexistence":bool(int(sys.argv[14])), "selectiveDns":bool(int(sys.argv[15])), + "selectiveDnsEncrypted":bool(int(sys.argv[16])), "strategy":sys.argv[9], "scope":sys.argv[10], "adultCoverage":bool(int(sys.argv[11])), @@ -844,6 +930,10 @@ os.chown(p,int(sys.argv[5]),int(sys.argv[6])) PY CREATED_STATE=1 + if [ "$ENCRYPTED_DNS_ENABLED" -eq 1 ]; then + python3 "$ENCRYPTED_DNS" commit || + { echo '{"ok":false,"error":"ENCRYPTED_DNS_COMMIT_FAILED"}'; exit 88; } + fi if [ "$SELECTIVE_DNS_ENABLED" -eq 1 ]; then python3 "$SELECTIVE_DNS" commit || { echo '{"ok":false,"error":"SELECTIVE_DNS_COMMIT_FAILED"}' diff --git a/linux/app/socket_fallback.py b/linux/app/socket_fallback.py new file mode 100644 index 00000000..859f68de --- /dev/null +++ b/linux/app/socket_fallback.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Opt-in, per-GUI-session, bounded localhost SOCKS5 fallback for selected sites. + +R747: no third-party tpws binary, no Root, no transparent network interception, +no changes to system/browser proxies, DNS, routes or Tailscale. This is NOT +the normal Direct Method Start/Stop/Recovery backend and not global internet. + +Only CONNECT/domain/443 to a small static public-site allowlist is allowed. +YouTube-family ClientHello is split in two write() calls after the first byte; +other allowed hosts are passed through intact. TLS is never decrypted; +the client MUST still validate the destination certificate. +""" +from __future__ import annotations + +import ipaddress +import pathlib +import re +import select +import socket +import socketserver +import subprocess +import threading +import time +from dataclasses import dataclass + +# Deliberately no wildcard goog(le).com or private/LAN traffic. +SEGMENT_SUFFIXES = ( + "youtube.com", "youtu.be", "googlevideo.com", "ytimg.com", + "youtube-nocookie.com", +) +CONTROL_HOSTS = frozenset(("github.com", "api.openai.com", "gemini.google.com")) +MAX_CLIENT_HELLO = 18432 +MAX_SESSIONS = 12 +MAX_IDLE_SECONDS = 300 + + +@dataclass(frozen=True) +class PhysicalRoute: + iface: str + source_ip: str + + +def current_physical_route() -> PhysicalRoute: + """Select a unique lowest-metric physical IPv4 route and verify OS policy. + + A connected Wi-Fi and Ethernet often create two default routes. This is + normal, not an ownership conflict. A tied best metric, a tunnel winning, + or an effective route different from the selected physical link is unsafe. + No packets are sent by the ip route get lookup. + """ + ip_cmd = ("/usr/sbin/ip" if pathlib.Path("/usr/sbin/ip").is_file() + else "/usr/bin/ip") + proc = subprocess.run( + (ip_cmd, "-o", "-4", "route", "show", "default"), + text=True, capture_output=True, timeout=3, check=False, + ) + if proc.returncode: + raise RuntimeError("SOCKET_ROUTE_LOOKUP_FAILED") + candidates = [] + for line in proc.stdout.splitlines(): + fields = line.split() + if not fields: + continue + if (fields[0] != "default" or fields.count("dev") != 1 or + fields.count("src") != 1 or fields.count("metric") > 1): + raise RuntimeError("SOCKET_PHYSICAL_DEFAULT_ROUTE_UNVERIFIED") + try: + iface = fields[fields.index("dev") + 1] + src = fields[fields.index("src") + 1] + metric = (int(fields[fields.index("metric") + 1]) + if "metric" in fields else 0) + if metric < 0: + raise ValueError("negative metric") + except (IndexError, ValueError) as exc: + raise RuntimeError("SOCKET_PHYSICAL_DEFAULT_ROUTE_UNVERIFIED") from exc + candidates.append((metric, iface, src)) + if not candidates: + raise RuntimeError("SOCKET_PHYSICAL_DEFAULT_ROUTE_UNAVAILABLE") + best_metric = min(m for m, _, _ in candidates) + winners = [(iface, src) for m, iface, src in candidates if m == best_metric] + if len(winners) != 1: + raise RuntimeError("SOCKET_PHYSICAL_DEFAULT_ROUTE_AMBIGUOUS") + iface, src = winners[0] + if not re.fullmatch(r"[A-Za-z0-9_.-]{1,32}", iface): + raise RuntimeError("SOCKET_BAD_INTERFACE_NAME") + if iface.startswith(("tailscale", "wg", "tun", "tap", "zt", "warp", "dim")): + raise RuntimeError("SOCKET_VPN_OR_TUNNEL_AS_DEFAULT_ROUTE") + try: + ip = ipaddress.IPv4Address(src) + except ValueError as exc: + raise RuntimeError("SOCKET_BAD_PHYSICAL_SOURCE_IPV4") from exc + if ip.is_loopback or ip.is_multicast or ip.is_unspecified or ip.is_link_local: + raise RuntimeError("SOCKET_INVALID_PHYSICAL_IP") + + # Main-table metrics alone are insufficient under policy routing/Tailscale + # exit nodes. Check a public destination's actual unmarked kernel lookup. + effective = subprocess.run( + (ip_cmd, "-4", "route", "get", "1.1.1.1"), + text=True, capture_output=True, timeout=3, check=False, + ) + if effective.returncode: + raise RuntimeError("SOCKET_EFFECTIVE_POLICY_ROUTE_UNAVAILABLE") + actual = [row.split() for row in effective.stdout.splitlines() + if "dev" in row.split()] + if len(actual) != 1: + raise RuntimeError("SOCKET_EFFECTIVE_POLICY_ROUTE_AMBIGUOUS") + fields = actual[0] + try: + if (fields.count("dev") != 1 or fields.count("src") != 1 or + fields[fields.index("dev") + 1] != iface or + fields[fields.index("src") + 1] != str(ip)): + raise RuntimeError("SOCKET_EFFECTIVE_POLICY_ROUTE_MISMATCH") + except IndexError as exc: + raise RuntimeError("SOCKET_EFFECTIVE_POLICY_ROUTE_UNVERIFIED") from exc + return PhysicalRoute(iface, str(ip)) + + + +def verify_target_physical_route(ip: str, route: PhysicalRoute) -> bool: + """Fail closed on policy routes that steer a specific destination away. + + Checking 1.1.1.1 alone cannot detect Tailscale/VPN split routes to the + actual resolved target. ip route get DEST from SOURCE ipproto tcp dport + 443 is a read-only kernel lookup, not a mutation or packet transmission. + """ + try: + target = ipaddress.IPv4Address(ip) + if not target.is_global or not isinstance(route, PhysicalRoute): + return False + if (not re.fullmatch(r"[A-Za-z0-9_.-]{1,32}", route.iface) or + route.iface.startswith(("tailscale", "wg", "tun", "tap", "zt", "warp", "dim"))): + return False + source = ipaddress.IPv4Address(route.source_ip) + if source.is_unspecified or source.is_multicast: + return False + command = ("/usr/sbin/ip" if pathlib.Path("/usr/sbin/ip").is_file() + else "/usr/bin/ip") + check = subprocess.run( + (command, "-o", "-4", "route", "get", str(target), "from", + str(source), "ipproto", "tcp", "dport", "443"), + text=True, capture_output=True, timeout=3, check=False, + ) + if check.returncode != 0: + return False + # Linux emits an optional second "cache" row with no "dev". + candidates = [line.split() for line in check.stdout.splitlines() + if "dev" in line.split()] + if len(candidates) != 1: + return False + fields = candidates[0] + if fields[0] != str(target) or fields.count("dev") != 1: + return False + if fields[fields.index("dev") + 1] != route.iface: + return False + # With from SOURCE iproute2 may print from SOURCE rather than src. + sources = [fields[i + 1] for i, token in enumerate(fields[:-1]) + if token in ("from", "src")] + return bool(sources) and all(value == str(source) for value in sources) + except (OSError, ValueError, IndexError, subprocess.TimeoutExpired): + return False + + +def normalize_allowed_host(host: str) -> tuple[str, bool]: + """(host, split) or fail-closed. A substring of an allowed host is NOT OK.""" + if not isinstance(host, str) or not 1 <= len(host) <= 253: + raise ValueError("SOCKET_HOST_LENGTH_INVALID") + try: + host.encode("ascii", "strict") + except UnicodeError as exc: + raise ValueError("SOCKET_HOST_NON_ASCII") from exc + lower = host.lower() + if lower != host.strip().lower() or lower.endswith("."): + raise ValueError("SOCKET_HOST_CANONICALIZATION_REJECTED") + labels = lower.split(".") + if (len(labels) < 2 or + not all(1 <= len(x) <= 63 and + re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", x) + for x in labels)): + raise ValueError("SOCKET_HOST_LABELS_INVALID") + is_video = any(lower == suffix or lower.endswith("." + suffix) + for suffix in SEGMENT_SUFFIXES) + if not is_video and lower not in CONTROL_HOSTS: + raise ValueError("SOCKET_HOST_NOT_IN_EXPLICIT_ALLOWLIST") + return lower, is_video + + +def validate_public_addresses(addresses: list[str]) -> list[str]: + """DNS rebinding/private-network hard stop, before attempting any TCP.""" + if not addresses or len(addresses) > 32: + raise ValueError("SOCKET_DNS_NO_BOUNDED_IPV4_RESULTS") + unique = [] + for raw in addresses: + try: + ip = ipaddress.IPv4Address(raw) + except ValueError as exc: + raise ValueError("SOCKET_DNS_INVALID_IPV4") from exc + if (not ip.is_global or ip.is_multicast or ip.is_reserved or + ip.is_unspecified or ip.is_link_local): + raise ValueError("SOCKET_DNS_PRIVATE_OR_RESERVED_IPV4_REJECTED") + if str(ip) not in unique: + unique.append(str(ip)) + return unique + + +def resolve_public_ipv4(host: str) -> list[str]: + answers = socket.getaddrinfo(host, 443, socket.AF_INET, + socket.SOCK_STREAM, socket.IPPROTO_TCP) + return validate_public_addresses([row[4][0] for row in answers]) + + +def tls_hello_sni(record: bytes) -> str: + """Parse exactly the initial complete TLS ClientHello record, not TLS keys.""" + if (len(record) < 52 or len(record) > MAX_CLIENT_HELLO or record[0] != 22 or + record[1] != 3 or record[2] not in (0, 1, 2, 3, 4) or + int.from_bytes(record[3:5], "big") != len(record) - 5): + raise ValueError("SOCKET_TLS_RECORD_INVALID") + body = memoryview(record)[5:] + if body[0] != 1 or int.from_bytes(body[1:4], "big") > len(body) - 4: + raise ValueError("SOCKET_TLS_CLIENTHELLO_INCOMPLETE_OR_INVALID") + # Reject multi-handshake records and fragmented ClientHello instead of + # altering an unknown stream or forwarding it without user-intended split. + if int.from_bytes(body[1:4], "big") != len(body) - 4: + raise ValueError("SOCKET_TLS_CLIENTHELLO_RECORD_NOT_EXCLUSIVE") + p = 4 + 2 + 32 + def take(size: int) -> bytes: + nonlocal p + if size < 0 or p + size > len(body): + raise ValueError("SOCKET_TLS_HELLO_TRUNCATED") + raw = bytes(body[p:p + size]) + p += size + return raw + take(take(1)[0]) + take(int.from_bytes(take(2), "big")) # cipher suites + take(take(1)[0]) # compression methods + extensions_len = int.from_bytes(take(2), "big") + end = p + extensions_len + if end != len(body): + raise ValueError("SOCKET_TLS_EXTENSION_LENGTH_INVALID") + found = None + while p < end: + kind = int.from_bytes(take(2), "big") + length = int.from_bytes(take(2), "big") + value = take(length) + if kind != 0: + continue + if found is not None or len(value) < 5: + raise ValueError("SOCKET_TLS_DUPLICATE_OR_BAD_SNI") + count = int.from_bytes(value[:2], "big") + if count != len(value) - 2 or value[2] != 0: + raise ValueError("SOCKET_TLS_SNI_LIST_INVALID") + n = int.from_bytes(value[3:5], "big") + if n == 0 or n != len(value) - 5: + raise ValueError("SOCKET_TLS_SNI_NAME_LENGTH_INVALID") + try: + found = value[5:].decode("ascii", "strict") + except UnicodeError as exc: + raise ValueError("SOCKET_TLS_SNI_NON_ASCII") from exc + if found is None: + raise ValueError("SOCKET_TLS_SNI_REQUIRED") + return normalize_allowed_host(found)[0] + + +def send_initial_tls(upstream, record: bytes, *, split: bool) -> None: + """Only the first TLS record is intentionally segmented; no sleeps.""" + if not record or record[0] != 22: + raise ValueError("SOCKET_INITIAL_TLS_RECORD_INVALID") + if split: + upstream.sendall(record[:1]) + upstream.sendall(record[1:]) + else: + upstream.sendall(record) + + +def _read_exact(sock: socket.socket, count: int) -> bytes: + if not 0 <= count <= MAX_CLIENT_HELLO: + raise ValueError("SOCKET_FRAME_LENGTH_INVALID") + output = bytearray() + while len(output) < count: + data = sock.recv(count - len(output)) + if not data: + raise ConnectionError("SOCKET_UNEXPECTED_CLIENT_EOF") + output.extend(data) + return bytes(output) + + +def _reply(client: socket.socket, code: int) -> None: + # RFC 1928, bind address/port = 0 (no misleading external IP disclosure). + client.sendall(b"\x05" + bytes([code]) + + b"\x00\x01\x00\x00\x00\x00\x00\x00") + + +def dial_public_ipv4(ip: str, port: int, source_ip: str) -> socket.socket: + """Connect to a previously validated public address using physical source.""" + remote = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + remote.settimeout(4) + remote.bind((source_ip, 0)) + remote.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + remote.connect((ip, port)) + return remote + except BaseException: + remote.close() + raise + + +def _handle_request(bridge: "LocalSocketFallback", client: socket.socket) -> None: + client.settimeout(8) + header = _read_exact(client, 2) + if header[0] != 5 or not 1 <= header[1] <= 8: + return + methods = _read_exact(client, header[1]) + if b"\x00" not in methods: + client.sendall(b"\x05\xff") + return + client.sendall(b"\x05\x00") + ver, operation, reserved, atyp = _read_exact(client, 4) + if (ver, operation, reserved, atyp) != (5, 1, 0, 3): + _reply(client, 7 if operation != 1 else 8) + return + hostname_length = _read_exact(client, 1)[0] + if not 1 <= hostname_length <= 253: + _reply(client, 8) + return + try: + raw = _read_exact(client, hostname_length).decode("ascii", "strict") + host, split = normalize_allowed_host(raw) + except (ValueError, UnicodeError): + _reply(client, 2) + return + port = int.from_bytes(_read_exact(client, 2), "big") + if port != 443: + _reply(client, 2) + return + try: + # A new connection must not quietly migrate to another interface, + # VPN default route or physical source after this GUI session started. + if bridge._route_getter() != bridge.route: + raise RuntimeError("SOCKET_PHYSICAL_ROUTE_CHANGED") + addresses = validate_public_addresses(bridge._resolver(host)) + # Validate every DNS answer before dialing ANY destination. A single + # VPN-specific route makes this session fail closed, not a fallback. + if not all(bridge._destination_route_getter(ip, bridge.route) is True + for ip in addresses): + raise RuntimeError("SOCKET_DESTINATION_POLICY_ROUTE_MISMATCH") + except (OSError, ValueError, RuntimeError): + _reply(client, 2) + return + remote = None + for ip in addresses: + if bridge._stopping.is_set(): + return + try: + remote = bridge._dialer(ip, port, bridge.route.source_ip) + break + except OSError: + continue + if remote is None: + _reply(client, 4) + return + bridge._track(remote) + try: + _reply(client, 0) + first = _read_exact(client, 5) + length = int.from_bytes(first[3:5], "big") + if length < 48 or length + 5 > MAX_CLIENT_HELLO: + raise ValueError("SOCKET_TLS_FIRST_RECORD_LENGTH_UNSAFE") + record = first + _read_exact(client, length) + if tls_hello_sni(record) != host: + raise ValueError("SOCKET_TLS_SNI_CONNECT_HOST_MISMATCH") + send_initial_tls(remote, record, split=split) + client.settimeout(15) + remote.settimeout(15) + last_activity = time.monotonic() + next_route_check = last_activity + 15 + while not bridge._stopping.is_set(): + now = time.monotonic() + if now - last_activity > MAX_IDLE_SECONDS: + return + if now >= next_route_check: + if bridge._route_getter() != bridge.route: + return + next_route_check = now + 15 + ready, _, _ = select.select((client, remote), (), (), 0.3) + for source in ready: + payload = source.recv(32768) + if not payload: + return + (remote if source is client else client).sendall(payload) + last_activity = time.monotonic() + finally: + bridge._untrack(remote) + try: + remote.shutdown(socket.SHUT_RDWR) + except OSError: + pass + remote.close() + + +class _LoopbackServer(socketserver.ThreadingTCPServer): + daemon_threads = True + block_on_close = False + request_queue_size = MAX_SESSIONS + allow_reuse_address = False + + def verify_request(self, request, client_address): + return client_address[0] == "127.0.0.1" + + +class _Handler(socketserver.BaseRequestHandler): + def handle(self) -> None: + bridge = self.server.bridge + if not bridge._slots.acquire(blocking=False): + try: + _reply(self.request, 1) + except OSError: + pass + return + bridge._track(self.request) + try: + _handle_request(bridge, self.request) + except (OSError, ValueError, ConnectionError, TimeoutError): + # Never log URL, hostname, DNS, private browsing data, or bytes. + pass + finally: + bridge._untrack(self.request) + bridge._slots.release() + + +class LocalSocketFallback: + """Start only on explicit opt-in; stop on explicit button or GUI close.""" + def __init__(self, *, route_getter=current_physical_route, + resolver=resolve_public_ipv4, dialer=dial_public_ipv4, + destination_route_getter=verify_target_physical_route): + self._route_getter = route_getter + self._resolver = resolver + self._dialer = dialer + self._destination_route_getter = destination_route_getter + self._slots = threading.BoundedSemaphore(MAX_SESSIONS) + self._sockets: set[socket.socket] = set() + self._lock = threading.Lock() + self._stopping = threading.Event() + self._server = None + self._thread = None + self.route = None + + @property + def port(self) -> int | None: + return (self._server.server_address[1] if self._server else None) + + def start(self) -> int: + if self._server: + raise RuntimeError("SOCKET_SESSION_ALREADY_RUNNING") + self.route = self._route_getter() + if not isinstance(self.route, PhysicalRoute): + raise RuntimeError("SOCKET_PHYSICAL_ROUTE_UNVERIFIED") + self._stopping.clear() + server = _LoopbackServer(("127.0.0.1", 0), _Handler, + bind_and_activate=True) + if server.server_address[0] != "127.0.0.1" or server.server_address[1] < 1024: + server.server_close() + raise RuntimeError("SOCKET_NOT_PRIVATE_DYNAMIC_LOOPBACK_PORT") + server.bridge = self + self._server = server + thread = threading.Thread(target=server.serve_forever, + kwargs={"poll_interval": 0.1}, + name="DIM-Local-Optin-SOCKS", + daemon=True) + self._thread = thread + thread.start() + return self.port + + def _track(self, sock: socket.socket) -> None: + with self._lock: + self._sockets.add(sock) + + def _untrack(self, sock: socket.socket) -> None: + with self._lock: + self._sockets.discard(sock) + + def stop(self) -> None: + self._stopping.set() + server, self._server = self._server, None + if server is None: + return + server.shutdown() + server.server_close() + with self._lock: + sockets = tuple(self._sockets) + for sock in sockets: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + sock.close() + except OSError: + pass + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2) + self._thread = None diff --git a/linux/app/user_uninstall_preserve.py b/linux/app/user_uninstall_preserve.py index c881bf4f..4a3680f5 100644 --- a/linux/app/user_uninstall_preserve.py +++ b/linux/app/user_uninstall_preserve.py @@ -16,7 +16,8 @@ import sys CODE_NAMES = frozenset({ - "direct_internet_method.py", "router_gateway.py", "router_gateway_core.py", + "direct_internet_method.py", "socket_fallback.py", + "router_gateway.py", "router_gateway_core.py", "direct-internet-method.svg", "uninstall.sh", "user_uninstall_preserve.py", "user_update_transaction.py", }) @@ -50,7 +51,7 @@ def load_owned(): meta = app / "INSTALL.json" ensure(meta.is_file() and not meta.is_symlink(), "UNINSTALL_INSTALL_METADATA_MISSING") doc = json.loads(meta.read_text(encoding="utf8")) - ensure(doc.get("schema") == 2 and doc.get("version") == "1.5.4", + ensure(doc.get("schema") == 2 and doc.get("version") == "1.5.7", "UNINSTALL_MANIFEST_OWNER_VERSION_UNVERIFIED") codes = doc.get("managedCodeSha256") launchers = doc.get("managedLaunchersSha256") diff --git a/linux/app/user_update_transaction.py b/linux/app/user_update_transaction.py index b82e5d04..c55e751c 100644 --- a/linux/app/user_update_transaction.py +++ b/linux/app/user_update_transaction.py @@ -23,6 +23,7 @@ FILES = ( ("app/direct_internet_method.py", "direct_internet_method.py", 0o755), + ("app/socket_fallback.py", "socket_fallback.py", 0o644), ("app/router_gateway.py", "router_gateway.py", 0o755), ("app/router_gateway_core.py", "router_gateway_core.py", 0o644), ("app/direct-internet-method.svg", "direct-internet-method.svg", 0o644), @@ -60,6 +61,7 @@ MUTABLE = ( "settings.json", "strategy.txt", "scope.txt", "custom-hosts.txt", "adult-enabled.txt", "adult-hosts.txt", "adult-hosts.meta.json", + "encrypted-dns-optin.txt", "selective-dns-optin.txt", ) @@ -135,7 +137,7 @@ def source_files(root: pathlib.Path) -> dict: """ guard(root.is_absolute() and root.is_dir() and not root.is_symlink() and not root.parent.is_symlink(), "SOURCE_PACKAGE_INVALID") - archive_layout = root.name == "DirectInternetMethod_1.5.4_Linux_x86_64" + archive_layout = root.name == "DirectInternetMethod_1.5.7_Linux_x86_64" source_layout = root.name == "linux" guard(archive_layout or source_layout, "SOURCE_PACKAGE_LAYOUT_NOT_LINUX_OR_EXTRACTED_ZIP") if archive_layout: @@ -150,10 +152,10 @@ def source_files(root: pathlib.Path) -> dict: raise RuntimeError("EXTRACTED_PACKAGE_MANIFEST_INVALID_JSON") from ex guard(isinstance(package_doc, dict) and package_doc.get("schema") == 1 and package_doc.get("product") == "Direct Internet Method" and - package_doc.get("version") == "1.5.4" and + package_doc.get("version") == "1.5.7" and package_doc.get("platform") == "linux-x86_64" and isinstance(package_doc.get("files"), list) and - len(package_doc["files"]) == 22, "EXTRACTED_PACKAGE_IDENTITY_WRONG") + len(package_doc["files"]) == 28, "EXTRACTED_PACKAGE_IDENTITY_WRONG") seen = set() digest_lines = [] for item in package_doc["files"]: @@ -275,7 +277,7 @@ def stage(src: pathlib.Path): try: (pending / "old").mkdir(mode=0o700) manifest = { - "schema": 1, "version": "1.5.4", + "schema": 1, "version": "1.5.7", "managed": { rel: {"sha": row["sha"], "mode": row["mode"]} for rel, row in payload.items() @@ -310,7 +312,7 @@ def load_pending(): not manifest_file.is_symlink(), "PENDING_TRANSACTION_NOT_VALID") guard(pending.stat().st_uid == os.getuid(), "PENDING_TRANSACTION_NOT_OWNED") data = json.loads(manifest_file.read_text(encoding="utf8")) - guard(data.get("schema") == 1 and data.get("version") == "1.5.4", "PENDING_TRANSACTION_VERSION_WRONG") + guard(data.get("schema") == 1 and data.get("version") == "1.5.7", "PENDING_TRANSACTION_VERSION_WRONG") allowed = {METADATA, *(rel for _, rel, _ in FILES), PROVIDERS} guard(set(data["old"]) == set(data["managed"]) | {METADATA} and set(data["old"]) <= allowed, "PENDING_FILESET_FOREIGN") @@ -376,7 +378,7 @@ def check_metadata(app, data): path = app / METADATA guard(path.is_file() and not path.is_symlink(), "INSTALLED_METADATA_MISSING") doc = json.loads(path.read_text(encoding="utf8")) - guard(doc.get("version") == "1.5.4" and doc.get("schema") == 2, "INSTALLED_VERSION_METADATA_NOT_VALID") + guard(doc.get("version") == "1.5.7" and doc.get("schema") == 2, "INSTALLED_VERSION_METADATA_NOT_VALID") guard(doc.get("managedProviderSha256") == data["providerSha"], "PROVIDER_MANAGED_OWNERSHIP_METADATA_CHANGED") expected = {rel: row["sha"] for rel, row in data["managed"].items() if rel != PROVIDERS} @@ -419,7 +421,7 @@ def rollback(): if rel == METADATA and observed not in (info["sha"], None): try: doc = json.loads(dest.read_text(encoding="utf8")) - guard(doc.get("schema") == 2 and doc.get("version") == "1.5.4", + guard(doc.get("schema") == 2 and doc.get("version") == "1.5.7", "FOREIGN_INSTALL_METADATA_PRESERVED") except (UnicodeError, ValueError) as ex: raise RuntimeError("UNKNOWN_INSTALL_METADATA_ON_ROLLBACK") from ex @@ -513,11 +515,11 @@ def recover_metadata_stage(expected_sha256: str): } guard(set(doc) == expected_keys and doc["schema"] == 2 and doc["product"] == "Direct Internet Method" and - doc["version"] == "1.5.4" and doc["platform"] == "linux-x86_64" and + doc["version"] == "1.5.7" and doc["platform"] == "linux-x86_64" and doc["networkMutationOnInstall"] is False and doc["normalRunRequiresAdmin"] is False and doc["systemBackend"] == "/usr/lib/directinternetmethod" and - doc["protectedBackendVersion"] in ("1.5.1", "1.5.2", "1.5.3", "1.5.4") and + doc["protectedBackendVersion"] == "1.5.7" and doc["protectedBackendByteCompatible"] is True and doc["onlineUpdate"] == "GitHub latest release + SHA256SUMS verification" and doc["updateMode"] == "IN_PLACE_SINGLE_INSTALL_DATA_PRESERVED", diff --git a/linux/install.sh b/linux/install.sh index 9356fe35..8f070b3d 100644 --- a/linux/install.sh +++ b/linux/install.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail umask 077 +# The source archive must remain read-only/clean during an in-place update. +# Python imports run from this directory; never accumulate __pycache__ files. +export PYTHONDONTWRITEBYTECODE=1 HERE="$(cd "$(dirname "$0")" && pwd)" APP="$HOME/.local/share/DirectInternetMethod" BIN="$HOME/.local/bin" @@ -8,6 +11,9 @@ DESKTOP="$HOME/.local/share/applications" ICONS="$HOME/.local/share/icons/hicolor/scalable/apps" USER_TRANSACTION="$HERE/app/user_update_transaction.py" TRANSACTION_ACTIVE=0 +ROOT_UPGRADE_STAGED=0 +# R779: a rollback must never execute user-writable package code as Root. +ROOT_TRANSACTION="/var/lib/directinternetmethod/upgrade-guard/root_tx.py" rollback_failed_install() { local code="$1" trap - ERR HUP INT TERM @@ -17,6 +23,11 @@ rollback_failed_install() { echo "Inspect $HOME/.local/state/DirectInternetMethod/update-pending" >&2 fi fi + if [ "$ROOT_UPGRADE_STAGED" -eq 1 ] && [ -f /var/lib/directinternetmethod/upgrade-pending/manifest.json ]; then + if ! pkexec /usr/bin/python3 "$ROOT_TRANSACTION" rollback; then + echo 'DEGRADED: protected backend rollback failed; preserve exact root slot for review.' >&2 + fi + fi exit "$code" } @@ -64,7 +75,7 @@ backend_matches_current() { python3 - /var/lib/directinternetmethod/install.json "$uid" <<'PY' >/dev/null 2>&1 || return 1 import json,sys d=json.load(open(sys.argv[1],encoding="utf-8")) -raise SystemExit(0 if d.get("version") in ("1.5.1","1.5.2","1.5.3","1.5.4") and str(d.get("uid"))==sys.argv[2] else 1) +raise SystemExit(0 if d.get("version")=="1.5.7" and str(d.get("uid"))==sys.argv[2] else 1) PY [ "$(cat /etc/directinternetmethod.uid 2>/dev/null || true)" = "$uid" ] || return 1 for action in start stop recovery; do @@ -76,6 +87,8 @@ PY done <&2 + if ! trusted_bootstrap_ready; then + echo "R781_TRUSTED_BOOTSTRAP_NOT_PROVISIONED: OS administrator must approve an independently verified, Root-owned bootstrap. No package code ran as Root." >&2 + rollback_failed_install 87 + fi + if [ -f /var/lib/directinternetmethod/install.json ] && + [ -d /usr/lib/directinternetmethod ]; then + ROOT_UPGRADE_STAGED=1 + pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action upgrade --source "$HERE" + elif [ ! -e /var/lib/directinternetmethod/install.json ] && + [ ! -e /usr/lib/directinternetmethod ]; then + pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action install --source "$HERE" + else + echo 'Foreign/partial root backend; no mutation authorized.' >&2 rollback_failed_install 25 fi - # Only a genuinely fresh root-backend install may request PolicyKit. - pkexec /bin/bash "$HERE/system/install_system.sh" "$HERE" "$(id -u)" fi backend_matches_current || { echo "Protected backend verification failed." >&2; rollback_failed_install 22; } # Atomically replace version-managed code in the ONE installed app directory. @@ -199,11 +229,11 @@ for name, expected in expected_launchers.items(): launchers[name]=hashlib.sha256(expected).hexdigest() transaction=json.loads((pathlib.Path.home()/".local/state/DirectInternetMethod/update-pending/manifest.json").read_text(encoding="utf8")) protected=json.loads(pathlib.Path("/var/lib/directinternetmethod/install.json").read_text(encoding="utf-8")) -assert protected.get("version") in ("1.5.1","1.5.2","1.5.3","1.5.4") +assert protected.get("version") == "1.5.7" metadata=json.dumps({ "schema":2, "product":"Direct Internet Method", - "version":"1.5.4", + "version":"1.5.7", "platform":"linux-x86_64", "installedUtc":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), "networkMutationOnInstall":False, @@ -229,11 +259,18 @@ spec.loader.exec_module(transaction_helper) transaction_helper.atomic_bytes(pathlib.Path(sys.argv[1]),metadata.encode("utf-8"),0o600) PY -# Commit only after managed code and mutable data hashes are reverified. -# On success, the one temporary rollback slot is removed; no dated copies remain. -python3 "$USER_TRANSACTION" --commit -TRANSACTION_ACTIVE=0 -trap - ERR HUP INT TERM -echo "Updated Direct Internet Method 1.5.4 in place; user data preserved." +# Unreleased candidate: protected Root must complete REAL native acceptance. +# Retain one bounded user/root rollback slot, not a permanent duplicate install. +if [ "$ROOT_UPGRADE_STAGED" -eq 1 ]; then + python3 "$USER_TRANSACTION" --status + TRANSACTION_ACTIVE=0 + trap - ERR HUP INT TERM + echo 'R519_CANDIDATE_PENDING_NATIVE_ROOT_USER_COMMIT: in-place files updated; not Released.' +else + python3 "$USER_TRANSACTION" --commit + TRANSACTION_ACTIVE=0 + trap - ERR HUP INT TERM + echo 'R519_CANDIDATE_FRESH_USER_CODE_COMMITTED: native release acceptance OPEN.' +fi echo "Normal Start / Stop / Recovery do not require an admin password." echo "Install/update/uninstall of the protected backend may authenticate once." diff --git a/linux/system/encrypted_selective_dns.py b/linux/system/encrypted_selective_dns.py new file mode 100644 index 00000000..5da3df2d --- /dev/null +++ b/linux/system/encrypted_selective_dns.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +"""R517 staged root-owned managed encrypted selective DNS controller. + +NOT INSTALLED OR RELEASED. This source shares only the native DirectMethod +action lock and uses an independent explicit opt-in (never old UDP consent). +Owned dummy per-link DNS and monitored ctrld v1.5.7 are fail-closed; an +independent repeating systemd backstop is armed before any resolver mutation. +""" +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import ipaddress +import importlib.util +import json +import os +import pathlib +import pwd +import re +import subprocess +import sys +import stat +import tempfile +import time +import uuid +from contextlib import contextmanager + +ROOT = pathlib.Path("/usr/lib/directinternetmethod") +STATE_DIR = pathlib.Path("/run/directinternetmethod-encrypted") +STATE = STATE_DIR / "session.json" +CONFIG = pathlib.Path("/run/directinternetmethod-encrypted-ctrld.toml") +ACTION_LOCK = pathlib.Path("/run/directinternetmethod-action.lock") +CTRLD = ROOT / "runtime/usr/bin/ctrld" +SOURCE = ROOT / "encrypted_selective_dns.py" +POLICY = ROOT / "encrypted_selective_policy.py" +VERSIONED_CTRLD_SHA = "CA64579A2BC866CF72F3E46663E551162AFEFF0D448E899A01817A095E45B071" +VERSIONED_NFQWS_SHA = "F34615964D7321650197CD69D3F7CBFDAABE8118B5A0D57C6E41DCCDFF658999" +NFQWS = ROOT / "runtime/usr/bin/nfqws" +IFACE = "dimesd0" +IP = "172.31.254.59" +CIDR = IP + "/32" +PORT = 53534 +TARGETS = ("youtube.com", "googlevideo.com", "ytimg.com", "youtu.be") +CONSENT = "I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1" +OLD_OPTIN = "selective-dns-optin.txt" +NEW_OPTIN = "encrypted-dns-optin.txt" +GRACE_SEC = 120 +BACKSTOP_SEC = 45 +MONITOR_SEC = 4 +UNIT_RE = re.compile(r"^dim-edsn-(?:dns|watch)-[0-9]{1,10}-[0-9a-f]{10}\.service$") +TIMER_RE = re.compile(r"^dim-edsn-backstop-[0-9]{1,10}-[0-9a-f]{10}\.timer$") +PHY_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,15}$") +STATE_PHASES = frozenset(("ARMING", "ACTIVE", "STOPPING", "DEGRADED")) + + +def need(condition: bool, why: str) -> None: + if not condition: + raise RuntimeError("FAIL_CLOSED_" + why) + + +# Root action helpers must never resolve their executable from the caller's PATH. +# All call sites select one of these fixed, OS-owned executables. +TRUSTED_SYSTEM_BINARIES = { + "resolvectl": "/usr/bin/resolvectl", + "tailscale": "/usr/bin/tailscale", + "nmcli": "/usr/bin/nmcli", + "ip": "/usr/bin/ip", + "ss": "/usr/bin/ss", + "systemctl": "/usr/bin/systemctl", + "busctl": "/usr/bin/busctl", + "dig": "/usr/bin/dig", + "systemd-run": "/usr/bin/systemd-run", +} + + +def call(*args: str, seconds: int = 10, ok: bool = True) -> subprocess.CompletedProcess: + need(bool(args) and type(args[0]) is str and args[0] in TRUSTED_SYSTEM_BINARIES, + "UNTRUSTED_COMMAND") + executable = pathlib.Path(TRUSTED_SYSTEM_BINARIES[args[0]]) + need(executable.is_file() and not executable.is_symlink(), "TRUSTED_TOOL_MISSING") + mode = executable.stat() + need(mode.st_uid == 0 and mode.st_mode & 0o022 == 0, "TRUSTED_TOOL_OWNER") + p = subprocess.run((str(executable), *args[1:]), capture_output=True, text=True, + timeout=seconds) + if ok and p.returncode: + raise RuntimeError("CALL_FAILED_" + repr(args[:3]) + "_" + str(p.returncode) + + "_" + p.stderr[-100:]) + return p + + +def root() -> None: + need(os.geteuid() == 0, "ROOT_REQUIRED") + + +def sha(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest().upper() + + +def file_sha(p: pathlib.Path) -> str: + return sha(p.read_bytes()) + + +def save(path: pathlib.Path, data: bytes, mode: int = 0o600) -> None: + need(path.parent.is_dir() and not path.parent.is_symlink(), "OUTPUT_PARENT") + f,temp = tempfile.mkstemp(prefix=".dim-encrypted-", dir=str(path.parent)) + try: + with os.fdopen(f, "wb") as h: + h.write(data); h.flush(); os.fsync(h.fileno()) + os.chmod(temp, mode) + os.replace(temp, path) + finally: + if os.path.lexists(temp): + os.unlink(temp) + + +def write_state(state: dict) -> None: + need(STATE_DIR.is_dir() and not STATE_DIR.is_symlink(), "ROOT_STATE_DIR") + need(STATE_DIR.stat().st_uid == 0 and STATE_DIR.stat().st_mode & 0o777 == 0o700, + "ROOT_DIR_MODE") + save(STATE, (json.dumps(state, sort_keys=True, indent=2) + "\n").encode()) + + +def validate_state(s: dict) -> dict: + need(type(s) is dict and s.get("schema") == 1, "STATE_SCHEMA") + need(s.get("phase") in STATE_PHASES, "STATE_PHASE") + uid = s.get("uid") + session = s.get("session") + need(type(uid) is int and 0 < uid < 2**31, "STATE_UID") + need(isinstance(session, str) and re.fullmatch("[0-9a-f]{10}", session), + "STATE_SESSION") + need(s.get("iface") == IFACE and s.get("ownedIp") == CIDR and s.get("port") == PORT, + "STATE_LINK_BINDING") + need(type(s.get("dummyIndex")) in (int, type(None)) and + (s["dummyIndex"] is None or s["dummyIndex"] > 1), "STATE_DUMMY_INDEX") + need(PHY_RE.fullmatch(str(s.get("physical", ""))), "STATE_PHY") + need(s.get("ctrldUnit") == f"dim-edsn-dns-{uid}-{session}.service", "STATE_CTRLD") + need(s.get("monitorUnit") == f"dim-edsn-watch-{uid}-{session}.service", "STATE_MONITOR") + need(s.get("backstopTimer") == f"dim-edsn-backstop-{uid}-{session}.timer", + "STATE_BACKSTOP") + need(UNIT_RE.fullmatch(s["ctrldUnit"]) and UNIT_RE.fullmatch(s["monitorUnit"]) and + TIMER_RE.fullmatch(s["backstopTimer"]), "STATE_UNIT_GRAMMAR") + need(s.get("ctrldSha") == VERSIONED_CTRLD_SHA, "STATE_BINARY_SHA") + need(s.get("configPath") == str(CONFIG), "STATE_CONF_PATH") + need(s.get("configSha") and re.fullmatch("[0-9A-F]{64}",s["configSha"]), + "STATE_CONFIG_SHA") + need(s.get("userHome", "").startswith("/home/") and + pathlib.Path(s["userHome"]).is_absolute() and + ".." not in pathlib.Path(s["userHome"]).parts,"STATE_USER_HOME") + need(s.get("physicalBaseline") and + isinstance(s["physicalBaseline"], dict) and + s["physicalBaseline"].get("dns") and + len(s["physicalBaseline"]["dns"])==1, "STATE_PHYSICAL_BASELINE") + need(re.fullmatch("[0-9A-F]{64}",str(s.get("tailscaleDNS",""))), + "STATE_TAILSCALE_SHA") + return s + + +def read_state() -> dict | None: + if not STATE.exists(): + return None + need(STATE.is_file() and not STATE.is_symlink(), "STATE_UNSAFE_TYPE") + st = STATE.stat() + need(st.st_uid == 0 and st.st_mode & 0o777 == 0o600, "STATE_UNSAFE_OWNER") + return validate_state(json.loads(STATE.read_text())) + + +def require_consent(uid: int, home: str) -> None: + # This is a privileged opt-in check. Reading a legitimate leaf through + # a user-controlled symlink parent can otherwise select an unrelated + # token/profile. Traverse *all* directories by descriptor, no-follow. + need(type(uid) is int and 0 < uid < 2**31, "UID") + need(type(home) is str and home.startswith("/home/"), "HOME_OWNER") + p = pathlib.Path(home) + need(p.is_absolute() and ".." not in p.parts and + len(p.parts) >= 3, "HOME_OWNER") + directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY | + os.O_NOFOLLOW | os.O_CLOEXEC) + try: + home_parts = p.parts[1:] + names = home_parts + (".local", "share", "DirectInternetMethod") + for index, name in enumerate(names): + need(name not in (".", "..", "") and "/" not in name, + "CONSENT_PARENT_COMPONENT") + try: + child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | + os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=directory) + except OSError as exc: + raise RuntimeError("FAIL_CLOSED_CONSENT_PARENT_COMPONENT") from exc + os.close(directory) + directory = child + info = os.fstat(directory) + if index == len(home_parts) - 1: + need(info.st_uid == uid, "HOME_OWNER") + if index >= len(home_parts): + need(info.st_uid == uid, "CONSENT_PARENT_OWNER") + + try: + os.stat(OLD_OPTIN, dir_fd=directory, follow_symlinks=False) + except FileNotFoundError: + pass + except OSError as exc: + raise RuntimeError("FAIL_CLOSED_LEGACY_UNENCRYPTED_OPTIN_CONFLICT") from exc + else: + need(False, "LEGACY_UNENCRYPTED_OPTIN_CONFLICT") + + def token(name: str, missing: str, invalid: str, length: int, private: bool=False) -> str: + need("/" not in name and name not in (".", "..", ""), + "BAD_CONSENT_TOKEN_NAME") + try: + f = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | + os.O_CLOEXEC, dir_fd=directory) + except OSError as exc: + raise RuntimeError("FAIL_CLOSED_" + missing) from exc + try: + meta = os.fstat(f) + need(stat.S_ISREG(meta.st_mode) and meta.st_uid == uid and + 0 <= meta.st_size <= length and + (not private or meta.st_mode & 0o077 == 0), invalid) + contents = os.read(f, length + 1) + need(len(contents) <= length, invalid) + return contents.decode("utf8").strip() + finally: + os.close(f) + + need(token(NEW_OPTIN, "EXPLICIT_ENCRYPTED_CONSENT_MISSING", + "CONSENT_NOT_EXACT_OR_PRIVATE",129, private=True) == CONSENT, + "CONSENT_NOT_EXACT_OR_PRIVATE") + for name, value in (("strategy.txt", "balanced"), + ("scope.txt", "targeted")): + need(token(name, "INVALID_CONSENT_PROFILE_" + name, + "INVALID_CONSENT_PROFILE_" + name,32) == value, + "INVALID_CONSENT_PROFILE_" + name) + finally: + os.close(directory) + + +def link_state(iface: str) -> dict: + need(PHY_RE.fullmatch(iface), "BAD_IFACE") + def field(cmd: str) -> list[str]: + text=call("resolvectl",cmd,iface).stdout.strip() + m=re.fullmatch(r"Link \d+ \(" + re.escape(iface) + r"\):(?: (.*))?",text) + need(bool(m),"LINK_FIELDS_UNPARSEABLE_"+cmd) + return (m.group(1) or "").split() + route=field("default-route") + need(len(route)==1 and route[0] in ("yes","no"),"DNS_DEFAULT_ROUTE_INVALID") + return {"dns":field("dns"),"domains":field("domain"),"default":route[0]=="yes"} + + +def tailscale_fingerprint() -> str: + data=(call("resolvectl","dns","tailscale0").stdout+ + call("resolvectl","domain","tailscale0").stdout).encode() + return sha(data) + + +def network_preflight(phy: str) -> dict: + need(PHY_RE.fullmatch(phy) and phy not in ("lo", "tailscale0", IFACE), + "PHYSICAL_LINK_UNSAFE") + result=json.loads(call("tailscale","status","--json").stdout) + need(result.get("BackendState")=="Running" and not result.get("ExitNodeStatus"), + "TAILSCALE_SPLIT_REQUIRED") + modes=call("nmcli","-t","-f","TYPE,DEVICE","connection","show","--active").stdout.splitlines() + for mode in modes: + need(mode and ":" in mode,"NETWORK_MANAGER_AMBIGUOUS") + typ,dev=mode.split(":",1) + need(typ not in ("vpn","wireguard"),"EXTRA_VPN_CONFLICT") + if typ=="tun": + need(dev=="tailscale0","EXTRA_TUNNEL_CONFLICT") + route=call("ip","-4","route","show","default").stdout.strip() + need("dev "+phy in route and not ("dev tun" in route or "dev wg" in route), + "WAN_ROUTE_NOT_PHYSICAL") + for family in ("-4","-6"): + table=call("ip",family,"route","show","table","52").stdout.splitlines() + need(not any(line.strip().startswith(("default","0.0.0.0/1","128.0.0.0/1", + "::/1","8000::/1")) for line in table), + "TAILSCALE_EXIT_ROUTE") + physical=link_state(phy) + need(physical["default"] and len(physical["dns"])==1, "NEED_SINGLE_PHYSICAL_DNS") + # The pure protected policy validates literal RFC1918 router addresses. + need(not STATE_DIR.exists() and not CONFIG.exists() and not CONFIG.is_symlink(), + "PREEXISTING_ENCRYPTED_DNS") + need(not pathlib.Path("/sys/class/net", IFACE).exists(),"DUMMY_LINK_COLLISION") + need(not call("ip","-4","route","show","exact",CIDR).stdout.strip(), + "RESERVED_IPV4_COLLISION") + need(":"+str(PORT) not in call("ss","-lunpt",ok=False).stdout, + "DNS_LISTENER_PORT_OCCUPIED") + need(CTRLD.is_file() and file_sha(CTRLD)==VERSIONED_CTRLD_SHA,"CTRLD_VERSION_SHA") + need(POLICY.is_file() and SOURCE.is_file(),"PROTECTED_CONTROLLER_INCOMPLETE") + return physical + + +def runtime_tunnel_guard(physical: str) -> None: + """Read-only fail-closed guard for VPN/Tailscale changes after activation.""" + need(bool(PHY_RE.fullmatch(physical)) and physical not in ("lo","tailscale0",IFACE), + "RUNTIME_PHYSICAL_IFACE") + ts=json.loads(call("tailscale","status","--json").stdout) + need(ts.get("BackendState")=="Running" and not ts.get("ExitNodeStatus"), + "RUNTIME_TAILSCALE_EXIT_OR_DOWN") + modes=call("nmcli","-t","-f","TYPE,DEVICE","connection","show","--active").stdout.splitlines() + seen_tailscale=False + seen_physical=False + for item in modes: + need(":" in item,"RUNTIME_NMCLI_AMBIGUOUS") + kind,dev=item.split(":",1) + need(kind not in ("vpn","wireguard"),"RUNTIME_FOREIGN_VPN_CONFLICT") + if kind=="tun": + need(dev=="tailscale0","RUNTIME_FOREIGN_TUNNEL_CONFLICT") + seen_tailscale=True + if dev==physical: + need(kind not in ("tun","vpn","wireguard"),"RUNTIME_PHY_RECLASSIFIED") + seen_physical=True + need(seen_physical and seen_tailscale,"RUNTIME_EXPECTED_INTERFACES_MISSING") + route=call("ip","-4","route","show","default").stdout.strip() + need("dev "+physical in route and "dev tun" not in route and "dev wg" not in route, + "RUNTIME_DEFAULT_WAN_CHANGED") + for family in ("-4","-6"): + entries=call("ip",family,"route","show","table","52").stdout.splitlines() + need(not any(x.strip().startswith(("default","0.0.0.0/1","128.0.0.0/1", + "::/1","8000::/1")) for x in entries), + "RUNTIME_TAILSCALE_EXIT_ROUTE") + + +def policy_bytes(router: str) -> bytes: + spec=importlib.util.spec_from_file_location("dim_protected_policy",POLICY) + need(spec is not None and spec.loader is not None,"POLICY_IMPORT_SPEC") + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + data=mod.build_owned_interface_policy(router) + need(b'endpoint = "https://76.76.10.11/p0"' in data and + b'ip = "172.31.254.59"' in data and + b'allow_wan_clients = false' in data, "POLICY_BINDING_SHAPE") + return data + + +def unit_state(name: str, timer: bool = False) -> str: + need(bool((TIMER_RE if timer else UNIT_RE).fullmatch(name)),"UNIT_NAME_UNOWNED") + return call("systemctl","show",name,"-p","ActiveState","--value",ok=False).stdout.strip() + + +def dummy_identity(s: dict) -> int | None: + p=pathlib.Path("/sys/class/net")/IFACE + if not p.exists():return None + info=json.loads(call("ip","-j","-d","link","show","dev",IFACE).stdout) + need(len(info)==1 and info[0].get("linkinfo",{}).get("info_kind")=="dummy", + "FOREIGN_INTERFACE_KIND") + index=int(p.joinpath("ifindex").read_text().strip()) + need(index>1 and index==s.get("dummyIndex"),"DUMMY_INDEX_REUSED") + addresses=call("ip","-o","-4","addr","show","dev",IFACE).stdout + need("inet "+CIDR+" " in addresses,"OWNED_LINK_ADDRESS_CHANGED") + return index + + +def link_dns(idx: int) -> None: + address=[str(x) for x in ipaddress.IPv4Address(IP).packed] + call("busctl","--timeout=6s","call","org.freedesktop.resolve1", + "/org/freedesktop/resolve1","org.freedesktop.resolve1.Manager", + "SetLinkDNSEx","ia(iayqs)",str(idx),"1","2","4",*address,str(PORT),"") + args=("busctl","--timeout=6s","call","org.freedesktop.resolve1", + "/org/freedesktop/resolve1","org.freedesktop.resolve1.Manager", + "SetLinkDomains","ia(sb)",str(idx),str(len(TARGETS))) + domainargs=[x for name in TARGETS for x in (name,"true")] + call(*args,*domainargs) + call("busctl","--timeout=6s","call","org.freedesktop.resolve1", + "/org/freedesktop/resolve1","org.freedesktop.resolve1.Manager", + "SetLinkDefaultRoute","ib",str(idx),"false") + + +def dns_is_owned() -> bool: + d=link_state(IFACE) + return (d["dns"]==[f"{IP}:{PORT}"] and + d["default"] is False and + set(d["domains"])=={"~"+name for name in TARGETS}) + + +def public_dns(domain: str) -> bool: + p=call("dig","@"+IP,"-p",str(PORT),domain,"A","+tries=1","+time=3","+short", + seconds=6,ok=False) + ips=[] + for line in p.stdout.splitlines(): + try:ips.append(ipaddress.IPv4Address(line.strip())) + except (ipaddress.AddressValueError,ValueError):pass + return bool(ips) and p.returncode==0 and all(x.is_global for x in ips) + + +def rollback(reason: str = "MANUAL", stop_monitor: bool = True) -> dict: + root() + s=read_state() + if s is None: + need(not CONFIG.exists(),"UNOWNED_OR_ORPHAN_CONF") + need(not pathlib.Path("/sys/class/net",IFACE).exists(),"UNOWNED_ORPHAN_LINK") + return {"ok":True,"state":"NO_SESSION"} + idx=dummy_identity(s) + if idx is not None: + existing=link_state(IFACE) + # Never revert foreign resolver settings or overwrite user rules. + need((not existing["dns"] and not existing["domains"]) or + dns_is_owned(), "FOREIGN_LINK_DNS_PRESENT") + call("busctl","--timeout=6s","call","org.freedesktop.resolve1", + "/org/freedesktop/resolve1","org.freedesktop.resolve1.Manager", + "RevertLink","i",str(idx)) + if unit_state(s["ctrldUnit"])=="active": + raw=call("systemctl","show",s["ctrldUnit"],"-p","MainPID","--value").stdout.strip() + pid=int(raw) + need(pid>1 and os.path.realpath(f"/proc/{pid}/exe")==os.path.realpath(CTRLD), + "OWNED_CTRLD_PROCESS_MISMATCH") + call("systemctl","stop",s["ctrldUnit"],seconds=16) + need(unit_state(s["ctrldUnit"])!="active","OWNED_CTRLD_NOT_STOPPED") + if idx is not None: + need(dummy_identity(s)==idx,"LINK_REPLACED_DURING_ROLLBACK") + call("ip","link","del","dev",IFACE) + need(not pathlib.Path("/sys/class/net",IFACE).exists(),"DUMMY_STILL_EXISTS") + if stop_monitor and unit_state(s["monitorUnit"])=="active": + call("systemctl","stop",s["monitorUnit"],seconds=15) + if unit_state(s["backstopTimer"],True)=="active": + call("systemctl","stop",s["backstopTimer"],seconds=15) + need(unit_state(s["backstopTimer"],True)!="active","TIMER_STILL_ACTIVE") + need(CONFIG.is_file() and not CONFIG.is_symlink() and + CONFIG.stat().st_uid==0 and sha(CONFIG.read_bytes())==s["configSha"], + "CONFIG_FOREIGN_PRESERVED") + CONFIG.unlink() + STATE.unlink() + if STATE_DIR.is_dir() and not any(STATE_DIR.iterdir()): STATE_DIR.rmdir() + return {"ok":True,"state":"RESTORED","reason":reason} + + +def activate(uid: int, iface: str, home: str) -> dict: + root() + require_consent(uid,home) + baseline=network_preflight(iface) + fingerprint=tailscale_fingerprint() + policy=policy_bytes(baseline["dns"][0]) + STATE_DIR.mkdir(mode=0o700) + os.chmod(STATE_DIR,0o700) + token=uuid.uuid4().hex[:10] + s={ + "schema":1,"phase":"ARMING","uid":uid,"session":token, + "userHome":home,"physical":iface,"physicalBaseline":baseline, + "tailscaleDNS":fingerprint,"ctrldSha":VERSIONED_CTRLD_SHA, + "configPath":str(CONFIG),"configSha":sha(policy), + "iface":IFACE,"ownedIp":CIDR,"port":PORT,"dummyIndex":None, + "started":time.time(), + "ctrldUnit":f"dim-edsn-dns-{uid}-{token}.service", + "monitorUnit":f"dim-edsn-watch-{uid}-{token}.service", + "backstopTimer":f"dim-edsn-backstop-{uid}-{token}.timer" + } + try: + write_state(s) + save(CONFIG,policy,0o644) + call("systemd-run","--quiet","--unit="+s["monitorUnit"][:-8], + "--service-type=exec","--property=Restart=on-failure", + "--property=RestartSec=3s","/usr/bin/python3",str(SOURCE),"monitor") + need(unit_state(s["monitorUnit"])=="active","MONITOR_NOT_RUNNING") + call("systemd-run","--quiet","--unit="+s["backstopTimer"][:-6], + "--on-active="+str(BACKSTOP_SEC)+"s", + "--on-unit-active="+str(BACKSTOP_SEC)+"s", + "--timer-property=AccuracySec=1s", + "/usr/bin/python3",str(SOURCE),"backstop") + need(unit_state(s["backstopTimer"],True)=="active","INDEPENDENT_BACKSTOP_NOT_ARMED") + call("ip","link","add",IFACE,"type","dummy") + call("ip","addr","add",CIDR,"dev",IFACE) + call("ip","link","set","dev",IFACE,"up") + s["dummyIndex"]=int(pathlib.Path("/sys/class/net",IFACE,"ifindex").read_text()) + write_state(s) + need(dummy_identity(s)==s["dummyIndex"],"NEW_DUMMY_IDENTITY") + user=pwd.getpwuid(uid).pw_name + call("systemd-run","--quiet","--unit="+s["ctrldUnit"][:-8], + "--service-type=exec","--property=Restart=on-failure", + "--property=RestartSec=3s","--property=User="+user, + "--property=NoNewPrivileges=yes",str(CTRLD),"run","--config",str(CONFIG)) + need(unit_state(s["ctrldUnit"])=="active","CTRLD_NOT_ACTIVE") + for target in ("www.youtube.com","redirector.googlevideo.com"): + need(public_dns(target),"TARGET_DOH_PUBLIC_"+target) + runtime_tunnel_guard(iface) + link_dns(s["dummyIndex"]) + need(dns_is_owned(),"DBUS_DNS_OR_DOMAINS_NOT_OWNED") + need(link_state(iface)==baseline,"PHYSICAL_LINK_DNS_CHANGED") + need(tailscale_fingerprint()==fingerprint,"MAGICDNS_CHANGED") + need(json.loads(call("tailscale","status","--json").stdout).get("BackendState")=="Running", + "TAILSCALE_DOWN") + need(public_dns("www.youtube.com"),"DOH_AFTER_NATIVE_DNS_MISSING") + return {"ok":True,"state":"ARMED","session":token,"encryptedTargetDns":True} + except BaseException: + try:rollback("ACTIVATION_FAILED") + except BaseException:pass + raise + + +def native_engine_attested(state: dict) -> bool: + """Cross-check the user-readable native state against *actual* root service. + + A writable state.json is never sufficient authority for protected Commit. + """ + uid=state["uid"] + app=(pathlib.Path(state["userHome"])/ + ".local/share/DirectInternetMethod/directmethod/state.json") + need(app.is_file() and not app.is_symlink(),"NATIVE_STATE_MISSING_OR_SYMLINK") + st=app.stat() + need(st.st_uid==uid and st.st_size>40 and st.st_size<16384 and + st.st_mode & 0o077==0,"NATIVE_STATE_OWNERSHIP") + doc=json.loads(app.read_text(encoding="utf8")) + unit=f"directinternetmethod-nfqws-{uid}.service" + need(doc.get("schema")==3 and doc.get("status")=="ACTIVE" and + doc.get("tailscaleCoexistence") is True and + doc.get("dnsMode")=="system-preserved" and + doc.get("selectiveDnsEncrypted") is True and + doc.get("selectiveDns") is False and + doc.get("physicalInterface")==state["physical"] and + doc.get("scope")=="targeted" and doc.get("strategy")=="balanced" and + doc.get("nfqwsUnit")==unit,"NATIVE_STATE_FIELDS") + pid=doc.get("nfqwsPid") + need(type(pid) is int and pid>1 and + NFQWS.is_file() and file_sha(NFQWS)==VERSIONED_NFQWS_SHA, + "NATIVE_NFQWS_BINARY_SHA") + need(call("systemctl","show",unit,"-p","ActiveState","--value").stdout.strip()=="active", + "NATIVE_NFQWS_UNIT_INACTIVE") + number=call("systemctl","show",unit,"-p","MainPID","--value").stdout.strip() + need(number.isdigit() and int(number)==pid,"NATIVE_NFQWS_PID_MISMATCH") + need(os.path.realpath(f"/proc/{pid}/exe")==os.path.realpath(NFQWS), + "NATIVE_NFQWS_PROCESS_FOREIGN") + return True + + +def commit() -> dict: + root() + s=read_state() + need(s is not None and s["phase"]=="ARMING","COMMIT_WITHOUT_ARMING") + need(native_engine_attested(s),"NATIVE_ENGINE_UNCOMMITTED") + need(unit_state(s["monitorUnit"])=="active" and + unit_state(s["ctrldUnit"])=="active" and + unit_state(s["backstopTimer"],True)=="active","OWNED_UNITS_LOST") + need(dummy_identity(s)==s["dummyIndex"] and dns_is_owned(), + "PER_LINK_SECURE_DNS_DRIFT") + s["phase"]="ACTIVE" + s["committed"]=time.time() + write_state(s) + return {"ok":True,"state":"ACTIVE","encryptedTargetDns":True} + + +@contextmanager +def observe_action_lock(): + fd=os.open(ACTION_LOCK,os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600) + try: + try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB) + except BlockingIOError:yield False + else:yield True + finally:os.close(fd) + + +def monitor() -> None: + root() + while True: + s=read_state() + if s is None:return + if s["phase"] in ("STOPPING","DEGRADED"): + time.sleep(MONITOR_SEC);continue + failed=None + with observe_action_lock() as available: + if available: + if s["phase"]=="ARMING": + if time.time()-s["started"]>GRACE_SEC: + failed="START_GRACE_EXPIRED" + elif s["phase"]=="ACTIVE": + try: + runtime_tunnel_guard(s["physical"]) + need(dummy_identity(s)==s["dummyIndex"] and dns_is_owned(), + "DNS_DRIFT") + need(unit_state(s["ctrldUnit"])=="active","CTRLD_DIED") + need(tailscale_fingerprint()==s["tailscaleDNS"],"MAGICDNS_DRIFT") + need(link_state(s["physical"])==s["physicalBaseline"], + "PHYSICAL_DNS_DRIFT") + need(json.loads(call("tailscale","status","--json").stdout) + .get("BackendState")=="Running","TAILSCALE_DIED") + except Exception as err:failed=str(err) + if failed:rollback("MONITOR:"+failed,stop_monitor=False) + if failed: + call("systemctl","start","directinternetmethod-recovery.service", + seconds=35,ok=False) + return + time.sleep(MONITOR_SEC) + + +def backstop() -> dict: + root() + recover=None + with observe_action_lock() as available: + if not available:return {"ok":True,"state":"ACTION_BUSY_NEXT_TICK"} + s=read_state() + if s is None:return {"ok":True,"state":"INACTIVE"} + if s["phase"]=="ARMING" and time.time()-s["started"]<=GRACE_SEC: + return {"ok":True,"state":"START_PENDING"} + if s["phase"]=="ACTIVE": + try: + runtime_tunnel_guard(s["physical"]) + need(unit_state(s["monitorUnit"])=="active","MONITOR_DIED") + need(unit_state(s["ctrldUnit"])=="active","CTRLD_DIED") + need(dummy_identity(s)==s["dummyIndex"] and dns_is_owned(), + "LINK_DNS_DRIFT") + need(link_state(s["physical"])==s["physicalBaseline"], + "WAN_DNS_DRIFT") + need(tailscale_fingerprint()==s["tailscaleDNS"],"TAILSCALE_DNS_DRIFT") + return {"ok":True,"state":"HEALTHY"} + except Exception as e:recover=str(e) + else:recover="INCOMPLETE_PHASE_"+s["phase"] + rollback("INDEPENDENT_BACKSTOP:"+recover) + call("systemctl","start","directinternetmethod-recovery.service", + seconds=35,ok=False) + return {"ok":True,"state":"BACKSTOP_RECOVERED","reason":recover} + + +def status() -> dict: + s=read_state() + if s is None:return {"ok":True,"state":"INACTIVE"} + return {"ok":True,"state":s["phase"],"iface":s["iface"], + "encrypted":True,"monitor":unit_state(s["monitorUnit"]), + "ctrld":unit_state(s["ctrldUnit"]), + "backstop":unit_state(s["backstopTimer"],True)} + + +def main() -> int: + ap=argparse.ArgumentParser() + ap.add_argument("action",choices=("activate","commit","restore","monitor","backstop","status")) + ap.add_argument("--uid",type=int) + ap.add_argument("--interface") + ap.add_argument("--home") + args=ap.parse_args() + try: + if args.action=="activate":result=activate(args.uid,args.interface,args.home) + elif args.action=="commit":result=commit() + elif args.action=="restore":result=rollback() + elif args.action=="monitor":monitor();result={"ok":True,"state":"MONITOR_DONE"} + elif args.action=="backstop":result=backstop() + else:result=status() + print(json.dumps(result,sort_keys=True)) + return 0 + except Exception as exc: + print(json.dumps({"ok":False,"error":str(exc)[:280]}),file=sys.stderr) + return 83 + + +if __name__=="__main__": + sys.exit(main()) diff --git a/linux/system/encrypted_selective_policy.py b/linux/system/encrypted_selective_policy.py new file mode 100644 index 00000000..cff09d87 --- /dev/null +++ b/linux/system/encrypted_selective_policy.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Pure v1.5.7 ctrld targeted DoH policy generator (R516 candidate, NOT deployed). + +Produces a loopback-only listener; exact targeted names use pinned HTTPS DoH, +all other names retain the pre-existing RFC1918 physical DNS as legacy upstream. +No network operations, files, service changes, install or global resolver edits. +Any future runtime controller MUST add native ownership/rollback and V&V first. +""" +from __future__ import annotations + +import ipaddress + +TARGET_DOMAINS = ("youtube.com", "googlevideo.com", "ytimg.com", "youtu.be") +TLS_DOH_ENDPOINT = "https://76.76.10.11/p0" +TLS_DOH_IP = "76.76.10.11" + + +def _physical_dns(address: str) -> str: + if type(address) is not str or not address or address.strip() != address: + raise ValueError("PHYSICAL_DNS_INVALID_TYPE_OR_SPACE") + try: + addr = ipaddress.IPv4Address(address) + except (ipaddress.AddressValueError, ValueError) as exc: + raise ValueError("PHYSICAL_DNS_NOT_LITERAL_IPV4") from exc + # Accept only RFC1918 LAN resolvers: never silently configure public, + # reserved, loopback or link-local fallback as part of targeted mode. + lan = ( + ipaddress.IPv4Network("10.0.0.0/8"), + ipaddress.IPv4Network("172.16.0.0/12"), + ipaddress.IPv4Network("192.168.0.0/16"), + ) + if not any(addr in network for network in lan): + raise ValueError("PHYSICAL_DNS_NOT_RFC1918_LAN") + return str(addr) + + +def build_secure_targeted_dns_config(physical_dns: str, listener_port: int = 10535) -> bytes: + """Return deterministic ctrld v1.5.7 TOML with documented rule precedence. + + This pure function does not authorize use as system DNS. + """ + router = _physical_dns(physical_dns) + if type(listener_port) is not int or not 1024 <= listener_port <= 65535: + raise ValueError("PORT_UNSAFE_OR_PRIVILEGED") + # Validated on independent MMZ network as 127.0.0.1:53534. Config is + # rendered byte-for-byte in that form for the validated router/port pair. + return f"""[service] +log_level = "error" +cache_enable = false +leak_on_upstream_failure = false +discover_mdns = false +discover_arp = false +discover_dhcp = false +discover_ptr = false +discover_hosts = false + +[network.0] +cidrs = ["127.0.0.1/32"] +name = "LoopbackOnly" + +[upstream.0] +bootstrap_ip = "76.76.10.11" +endpoint = "https://76.76.10.11/p0" +name = "EncryptedPinnedControlD" +timeout = 5000 +type = "doh" +ip_stack = "v4" + +[upstream.1] +endpoint = "{router}" +name = "ExistingPhysicalDNS" +timeout = 3000 +type = "legacy" +ip_stack = "v4" + +[listener.0] +ip = "127.0.0.1" +port = {listener_port} +restricted = false +allow_wan_clients = false + +[listener.0.policy] +name = "EncryptedTargetedDoH_OriginalFallback" +rules = [ + {{"youtube.com" = ["upstream.0"]}}, + {{"*.youtube.com" = ["upstream.0"]}}, + {{"googlevideo.com" = ["upstream.0"]}}, + {{"*.googlevideo.com" = ["upstream.0"]}}, + {{"ytimg.com" = ["upstream.0"]}}, + {{"*.ytimg.com" = ["upstream.0"]}}, + {{"youtu.be" = ["upstream.0"]}}, + {{"*.youtu.be" = ["upstream.0"]}}, +] +networks = [ + {{"network.0" = ["upstream.1"]}}, +] +""".encode("ascii") + + +# Protected native per-link mode. The exact kernel link and IP are controller-owned. +# Never accept user-supplied arbitrary bind addresses or subnets. +OWNED_DNS_INTERFACE = "dimesd0" +OWNED_DNS_IPV4 = "172.31.254.59" +OWNED_DNS_PORT = 53534 + + +def build_owned_interface_policy(physical_dns: str) -> bytes: + """Render the tested targeted DoH policy for one owned /32 dummy interface. + + It is not a runtime migration or permission to alter a resolver. A native + controller must prove ownership and arm an independent rollback first. + """ + raw = build_secure_targeted_dns_config(physical_dns, OWNED_DNS_PORT) + old_network = b'cidrs = ["127.0.0.1/32"]' + old_listener = b'ip = "127.0.0.1"' + if raw.count(old_network) != 1 or raw.count(old_listener) != 1: + raise ValueError("SOURCE_POLICY_SHAPE_CHANGED") + result = raw.replace( + old_network, b'cidrs = ["172.31.254.59/32"]' + ).replace( + old_listener, b'ip = "172.31.254.59"' + ) + if b'0.0.0.0' in result or b'192.168.8.1' in result and physical_dns != "192.168.8.1": + raise ValueError("POLICY_BINDING_OR_ROUTER_DRIFT") + return result diff --git a/linux/system/install_system.sh b/linux/system/install_system.sh index a3ffce5a..3d8713a9 100644 --- a/linux/system/install_system.sh +++ b/linux/system/install_system.sh @@ -19,9 +19,26 @@ ROOT=/usr/lib/directinternetmethod echo "Stop/Recovery selective DNS before protected installation." >&2 exit 88 } +# R517 protected backend may only be staged with all network owners OFF. +[ ! -e "$HOME_DIR/.local/share/DirectInternetMethod/directmethod/state.json" ] || { + echo "Stop Direct Internet Method before protected backend update." >&2 + exit 88 +} +for owned in /run/directinternetmethod-encrypted /run/directinternetmethod-encrypted-ctrld.toml /sys/class/net/dimesd0; do + [ ! -e "$owned" ] && [ ! -L "$owned" ] || { + echo "Stop/Recovery encrypted selective DNS before protected backend update." >&2 + exit 88 + } +done +for source in "$SRC/system/encrypted_selective_dns.py" "$SRC/system/encrypted_selective_policy.py"; do + [ -f "$source" ] && [ ! -L "$source" ] && [ -s "$source" ] || + { echo "Incomplete candidate encrypted backend; refusing protected installation." >&2; exit 88; } +done install -d -m 0755 "$ROOT" "$ROOT/runtime" "$ROOT/runtime/usr" "$ROOT/runtime/usr/bin" "$ROOT/licenses" install -m 0755 "$SRC/app/direct_method_helper.sh" "$ROOT/direct_method_helper.sh" install -m 0755 "$SRC/system/selective_dns.py" "$ROOT/selective_dns.py" +install -m 0755 "$SRC/system/encrypted_selective_dns.py" "$ROOT/encrypted_selective_dns.py" +install -m 0644 "$SRC/system/encrypted_selective_policy.py" "$ROOT/encrypted_selective_policy.py" install -m 0755 "$SRC/system/control.sh" "$ROOT/control.sh" install -m 0755 "$SRC/system/uninstall_system.sh" "$ROOT/uninstall_system.sh" install -m 0644 "$SRC/app/hosts.txt" "$ROOT/direct_hosts.txt" @@ -84,12 +101,14 @@ systemctl daemon-reload test -x "$ROOT/control.sh" test -x "$ROOT/direct_method_helper.sh" test -x "$ROOT/selective_dns.py" +test -x "$ROOT/encrypted_selective_dns.py" +test -f "$ROOT/encrypted_selective_policy.py" for action in start stop recovery; do test -f "/etc/systemd/system/directinternetmethod-$action.service"; done test -f /etc/polkit-1/rules.d/49-directinternetmethod.rules test "$(cat /etc/directinternetmethod.uid)" = "$USER_UID" install -d -m 0755 /var/lib/directinternetmethod cat > /var/lib/directinternetmethod/install.json <=2,"UNSAFE_ABSOLUTE_FILE_PATH") + # O_NONBLOCK prevents a user-owned FIFO from blocking Root before fstat. + flags=os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC|os.O_NONBLOCK + parent=os.open("/",flags|os.O_DIRECTORY) + opened=None + try: + for part in path.parts[1:-1]: + try: + child=os.open(part,flags|os.O_DIRECTORY,dir_fd=parent) + except OSError as exc: + raise RuntimeError("FAIL_CLOSED_UNSAFE_PARENT_COMPONENT") from exc + os.close(parent) + parent=child + if native_receipt: + entry=os.fstat(parent) + need(entry.st_uid==0 and + (not entry.st_mode & 0o022 or + (bool(entry.st_mode & stat.S_ISVTX) and + stat.S_ISDIR(entry.st_mode))), + "RECEIPT_PARENT_OWNER") + # Restrict the leaf to a single ASCII package/receipt filename. + # No separators, controls, shell metacharacters or dot-prefixed + # hidden names are allowed. The pinned parent descriptor remains the + # actual filesystem boundary, independent of this lexical check. + requested = path.name + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", requested) is None: + raise RuntimeError("FAIL_CLOSED_UNSAFE_FILE_COMPONENT") + # Choose the descriptor leaf from literal trusted names only; the + # untrusted filename never becomes the filesystem-open argument. + leaf = None + for approved in APPROVED_FILE_LEAVES: + if requested == approved: + leaf = approved + break + if leaf is None: + raise RuntimeError("FAIL_CLOSED_UNSAFE_FILE_COMPONENT") + try: + opened=os.open(leaf,flags,dir_fd=parent) + except OSError as exc: + raise RuntimeError("FAIL_CLOSED_UNSAFE_FILE_COMPONENT") from exc + st=os.fstat(opened) + need(stat.S_ISREG(st.st_mode),"UNSAFE_FILE_TYPE_OR_SIZE") + yield opened,st + finally: + if opened is not None:os.close(opened) + os.close(parent) + +def hash_file(path): + h=hashlib.sha256() + with verified_regular_fd(path) as (fd,_): + with os.fdopen(os.dup(fd),"rb") as stream: + for block in iter(lambda:stream.read(1048576),b""): + h.update(block) + return h.hexdigest().upper() +def checked_regular_bytes(path, *, owner=False, mode=None, max_bytes=2**20): + """Check identity, ownership, type and read from ONE pinned regular inode.""" + with verified_regular_fd(path) as (fd, st): + if owner: + need(st.st_uid==0 and st.st_gid==0, + "CHECKED_FILE_FOREIGN_OWNER") + if mode is not None: + need(stat.S_IMODE(st.st_mode)==mode, "METADATA_MODE") + need(0<=st.st_size<=max_bytes, "CHECKED_FILE_TOO_LARGE") + with os.fdopen(os.dup(fd),"rb") as stream: + data=stream.read(max_bytes+1) + need(len(data)<=max_bytes, "CHECKED_FILE_SIZE_DRIFT") + return data + +def canonical(doc): + return (json.dumps(doc,sort_keys=True,indent=2,ensure_ascii=False)+"\n").encode("utf8") +def no_symlink_regular(path,owner=True): + """Check the actual pinned descriptor, not a racy pathname preflight.""" + path=pathlib.Path(path) + try: + with verified_regular_fd(path) as (_,st): + if owner:need(st.st_uid==0 and st.st_gid==0,"OWNER_"+path.name) + return st + except RuntimeError as exc: + # Preserve the existing precise leaf-symlink/missing rejection contract. + # A symlinked parent keeps the distinct UNSAFE_PARENT_COMPONENT error. + if str(exc)=="FAIL_CLOSED_UNSAFE_FILE_COMPONENT": + raise RuntimeError("FAIL_CLOSED_MISSING_OR_SYMLINK_"+path.name) from exc + raise + +def guarded_file_bytes(path, *, native_receipt=False, limit=2**21): + """Bounded source/receipt reader through the SAME pinned descriptor helper. + + User-owned package members are admitted only after exact SHA validation. + Native receipts additionally require safe root-owned ancestry and leaf. + The descriptor never follows a symlink, opens a FIFO blocking, or changes + the identity between stat and read. + """ + with verified_regular_fd(path,native_receipt=native_receipt) as (fd,info): + need(0<=info.st_size<=limit,"UNSAFE_FILE_TYPE_OR_SIZE") + if native_receipt: + need(info.st_uid==0 and info.st_gid==0 and + not info.st_mode & 0o022,"UNTRUSTED_RECEIPT_OWNER") + with os.fdopen(os.dup(fd),"rb") as reader: + content=reader.read(limit+1) + need(len(content)<=limit,"FILE_SIZE_CHANGED_DURING_READ") + return content + + +def flush_dir(directory): + f=os.open(directory,os.O_DIRECTORY) + try:os.fsync(f) + finally:os.close(f) +def atomic_bytes(path,data,mode,owner=True): + directory=path.parent + need(directory.is_dir() and not directory.is_symlink(),"TARGET_DIR_UNSAFE") + fd,name=tempfile.mkstemp(prefix=".dim-r519-",dir=str(directory)) + try: + with os.fdopen(fd,"wb") as f: + f.write(data);f.flush();os.fsync(f.fileno()) + os.chmod(name,mode) + if owner:os.chown(name,0,0) + os.replace(name,path) + flush_dir(directory) + finally: + if os.path.lexists(name):os.unlink(name) + +class RootTransaction: + def __init__(self,root=pathlib.Path("/")): + self.root=pathlib.Path(root) + self.native=self.root==pathlib.Path("/") + self.pending=self.root/SLOT + self.base=self.root/"var/lib/directinternetmethod" + self.lock=self.root/GUARD + self.meta=self.root/METADATA + def p(self,relative): + need(type(relative) is str and not relative.startswith("/") and + ".." not in pathlib.PurePosixPath(relative).parts,"PATH_TRAVERSAL") + return self.root/relative + def original_metadata(self): + d=json.loads(checked_regular_bytes( + self.meta,owner=self.native,mode=0o644,max_bytes=65536).decode("utf8")) + need(type(d)==dict and d.get("schema")==1 and + d.get("version") in SUPPORTED_BASELINE_VERSIONS,"OLD_ROOT_VERSION") + uid=d.get("uid") + need(type(uid) is int and uid>0 and uid<2**31,"OWNER_UID") + need(re.fullmatch(r"[a-z_][a-z0-9_-]*[$]?",str(d.get("user",""))),"OWNER_USER") + home=d.get("home") + need(isinstance(home,str) and home.startswith("/home/") and + ".." not in pathlib.PurePosixPath(home).parts,"OWNER_HOME") + idfile=self.p("etc/directinternetmethod.uid") + uid_marker=checked_regular_bytes(idfile,owner=self.native,max_bytes=128) + need(uid_marker.decode("utf8").strip()==str(uid),"UID_MARKER_CHANGED") + return d + def _baseline(self): + before=self.original_metadata() + need(self.base.is_dir() and not self.base.is_symlink(),"BASE_DIR_UNSAFE") + if self.native: + need(self.base.stat().st_uid==0,"BASE_DIR_NOT_ROOT") + # Fail before protected mutation if any live owned DNS or DPI state. + need(not pathlib.Path("/run/directinternetmethod-encrypted").exists() and + not pathlib.Path("/run/directinternetmethod-encrypted-ctrld.toml").exists() and + not pathlib.Path("/run/directinternetmethod-selective/session.json").exists() and + not pathlib.Path("/sys/class/net/dimesd0").exists(),"ACTIVE_DNS_RESOURCE") + home=pathlib.Path(before["home"])/".local/share/DirectInternetMethod/directmethod/state.json" + need(not home.exists(),"ACTIVE_DIRECT_METHOD") + for rel,want in STATIC_ROOT.items(): + p=self.p(rel) + no_symlink_regular(p,self.native) + need(hash_file(p)==want,"BASELINE_STATIC_SHA_"+pathlib.Path(rel).name) + for name,src,target,old,new,mode in FILES: + p=self.p(target) + want=baseline_old_sha(before["version"],old,new) + if want: + s=no_symlink_regular(p,self.native) + need(stat.S_IMODE(s.st_mode)==mode,"OLD_FILE_MODE_"+name) + need(hash_file(p)==want,"OLD_FILE_SHA_"+name) + else: + need(not p.exists() and not p.is_symlink(),"NEW_TARGET_NOT_EMPTY_"+name) + return before + @contextmanager + def exclusive(self): + """Serialize with protected upgrade AND the real native Start/Stop lock.""" + need(self.base.is_dir() and not self.base.is_symlink(),"BASE_MISSING") + action=self.root/ACTION_LOCK + need(action.parent.is_dir() and not action.parent.is_symlink(), + "NATIVE_ACTION_LOCK_PARENT_UNSAFE") + if self.native: + need(self.base.stat().st_uid==0 and action.parent.stat().st_uid==0, + "ROOT_LOCK_PARENT_UNOWNED") + f=os.open(self.lock,os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600) + try: + fcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB) + a=os.open(action,os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600) + try: + if self.native: + st=os.fstat(a) + need(st.st_uid==0 and stat.S_ISREG(st.st_mode), + "NATIVE_ACTION_LOCK_FOREIGN") + fcntl.flock(a,fcntl.LOCK_EX|fcntl.LOCK_NB) + yield + finally: + os.close(a) + finally: + os.close(f) + def manifest(self): + need(self.pending.is_dir() and not self.pending.is_symlink(),"SLOT_MISSING_OR_SYMLINK") + if self.native: + s=self.pending.stat() + need(s.st_uid==0 and stat.S_IMODE(s.st_mode)==0o700,"SLOT_UNOWNED") + p=self.pending/"manifest.json" + data=json.loads(checked_regular_bytes( + p,owner=self.native,max_bytes=131072).decode("utf8")) + need(data.get("schema")==SCHEMA and data.get("revision")=="R519" and + data.get("fromVersion") in SUPPORTED_BASELINE_VERSIONS and + data.get("toVersion")==TARGET_VERSION,"MANIFEST_VERSION") + need(data.get("phase") in ("STAGED","APPLIED","ROLLED_BACK","DEGRADED","COMMITTED"), + "MANIFEST_PHASE") + # Bind each Root acceptance to this particular staged attempt, not an + # earlier successful receipt for the same deterministic payload. + need(type(data.get("transactionNonce")) is str and + re.fullmatch(r"[0-9a-f]{64}",data["transactionNonce"]) is not None, + "MANIFEST_NONCE") + need(set(data.get("files",{}))=={row[0] for row in FILES},"MANIFEST_FILE_LIST") + return data + def write_manifest(self,data): + atomic_bytes(self.pending/"manifest.json",canonical(data),0o600,self.native) + def stage(self,source): + with self.exclusive(): + need(not self.pending.exists() and not self.pending.is_symlink(),"SLOT_ALREADY_EXISTS") + before=self._baseline() + src=pathlib.Path(source) + # R783: source member bytes are opened and SHA-pinned later through + # guarded_file_bytes() on the SAME verified inode. A separate + # is_dir()/is_symlink() pathname precheck cannot authenticate the + # later descriptor and adds an avoidable time-of-check race. + need(src.is_absolute() and ".." not in src.parts and + len(src.parts)>=2,"PACKAGE_SOURCE") + # Source may be user-owned (pkexec), but its bytes are pinned to + # the independently tested GitHub candidate; only mapped files read. + data={} + for name,rel,target,old,want,mode in FILES: + file=src/rel + no_symlink_regular(file,False) + blob=guarded_file_bytes(file) + need(sha(blob)==want,"PACKAGE_SOURCE_SHA_"+name) + data[name]=blob + # R778: both protected baseline snapshots must be captured through + # the same pinned descriptor used to check identity and permissions. + # Fail before creating a Root pending slot if files changed after + # _baseline(), rather than retaining an untrusted backup. + originals={} + for name,rel,target,old,new,mode in FILES: + want=baseline_old_sha(before["version"],old,new) + if want: + old_blob=checked_regular_bytes( + self.p(target),owner=self.native,mode=mode, + max_bytes=1048576) + need(sha(old_blob)==want,"BASELINE_BACKUP_CHANGED_"+name) + originals[name]=old_blob + orig=checked_regular_bytes( + self.meta,owner=self.native,mode=0o644,max_bytes=65536) + need(json.loads(orig.decode("utf8"))==before, + "BASELINE_METADATA_CHANGED_AFTER_PREFLIGHT") + self.pending.mkdir(mode=0o700) + (self.pending/"old").mkdir(mode=0o700) + (self.pending/"new").mkdir(mode=0o700) + try: + entries={} + for name,rel,target,old,new,mode in FILES: + want=baseline_old_sha(before["version"],old,new) + atomic_bytes(self.pending/"new"/name,data[name],0o600,self.native) + if want: + atomic_bytes(self.pending/"old"/name,originals[name],0o600,self.native) + entries[name]={"target":target,"old":want,"new":new,"mode":mode} + atomic_bytes(self.pending/"old"/"metadata",orig,0o600,self.native) + new_meta=dict(before,version=TARGET_VERSION) + new_blob=canonical(new_meta) + atomic_bytes(self.pending/"new"/"metadata",new_blob,0o600,self.native) + payload={"schema":SCHEMA,"revision":"R519","phase":"STAGED", + "fromVersion":before["version"],"toVersion":TARGET_VERSION, + "files":entries,"oldMetadataSHA":sha(orig),"newMetadataSHA":sha(new_blob), + "uid":before["uid"],"home":before["home"],"user":before["user"], + "transactionNonce":os.urandom(32).hex()} + self.write_manifest(payload) + return {"status":"STAGED","files":len(FILES),"version":TARGET_VERSION} + except BaseException: + # Preserve evidence rather than silently delete a partial slot. + raise + def _validate_slot(self,m): + need(self.pending.is_dir() and not self.pending.is_symlink(),"SLOT_UNSAFE") + for name,rel,target,old,new,mode in FILES: + want=baseline_old_sha(m["fromVersion"],old,new) + x=m["files"][name] + need(x=={"target":target,"old":want,"new":new,"mode":mode},"MANIFEST_FILE_TAMPER_"+name) + nf=self.pending/"new"/name + need(no_symlink_regular(nf,self.native) and hash_file(nf)==new, + "PENDING_NEW_SHA_"+name) + if want: + backup=self.pending/"old"/name + need(no_symlink_regular(backup,self.native) and hash_file(backup)==want, + "PENDING_OLD_SHA_"+name) + for key,dir,want in (("metadata","old",m["oldMetadataSHA"]), + ("metadata","new",m["newMetadataSHA"])): + file=self.pending/dir/key + need(no_symlink_regular(file,self.native) and hash_file(file)==want, + "PENDING_METADATA_"+dir) + need(m["uid"]>0 and m["home"].startswith("/home/"),"IDENTITY_CHANGED") + def _target_states(self,m): + result={} + for name,rel,target,old,new,mode in FILES: + file=self.p(target) + if not file.exists() and not file.is_symlink(): + digest=None + else: + no_symlink_regular(file,self.native) + digest=hash_file(file) + need(digest in (baseline_old_sha(m["fromVersion"],old,new),new),"FOREIGN_TARGET_MODIFICATION_"+name) + result[name]=digest + no_symlink_regular(self.meta,self.native) + meta_sha=hash_file(self.meta) + need(meta_sha in (m["oldMetadataSHA"],m["newMetadataSHA"]), + "FOREIGN_METADATA_CHANGE") + return result,meta_sha + def apply(self,fail_after=None): + with self.exclusive(): + m=self.manifest() + need(m["phase"]=="STAGED","UNSAFE_APPLY_RERUN") + self._validate_slot(m) + before,meta=self._target_states(m) + for name,rel,target,old,new,mode in FILES: + need(before[name]==baseline_old_sha(m["fromVersion"],old,new), + "BASELINE_CHANGED_"+name) + need(meta==m["oldMetadataSHA"],"OLD_META_CHANGED") + changed=0 + try: + for name,rel,target,old,new,mode in FILES: + # A verified RC1 backend is already byte-identical. Only + # metadata advances, without rewriting active program inodes. + if before[name]==new: + continue + atomic_bytes(self.p(target),(self.pending/"new"/name).read_bytes(), + mode,self.native) + changed+=1 + if fail_after is not None and changed>=fail_after: + raise RuntimeError("INJECTED_TEST_FAILURE_AFTER_"+str(changed)) + atomic_bytes(self.meta,(self.pending/"new"/"metadata").read_bytes(), + 0o644,self.native) + states,now=self._target_states(m) + need(all(states[name]==new for name,src,target,old,new,mode in FILES) and + now==m["newMetadataSHA"],"POST_UPGRADE_HASHES") + m["phase"]="APPLIED" + self.write_manifest(m) + return {"status":"APPLIED","changed":changed,"version":TARGET_VERSION} + except BaseException: + # Rollback is owned by this transaction. Preserve slot if + # corrupted/ambiguous; never proceed blind. + try:self._rollback_locked(m) + except BaseException: + m["phase"]="DEGRADED";self.write_manifest(m) + raise + def _rollback_locked(self,m): + self._validate_slot(m) + states,metadata=self._target_states(m) + # Verify ALL targets before a single mutation. + for name,rel,target,old,new,mode in FILES: + old=baseline_old_sha(m["fromVersion"],old,new) + need(states[name] in (old,new),"FOREIGN_TARGET_"+name) + for name,rel,target,old,new,mode in FILES: + old=baseline_old_sha(m["fromVersion"],old,new) + if states[name]==new and old!=new: + p=self.p(target) + if old is None: + need(not p.is_symlink() and hash_file(p)==new,"FOREIGN_NEW_"+name) + p.unlink() + flush_dir(p.parent) + else: + atomic_bytes(p,(self.pending/"old"/name).read_bytes(),mode,self.native) + if metadata==m["newMetadataSHA"]: + atomic_bytes(self.meta,(self.pending/"old"/"metadata").read_bytes(), + 0o644,self.native) + for name,rel,target,old,new,mode in FILES: + p=self.p(target) + old=baseline_old_sha(m["fromVersion"],old,new) + if old:need(hash_file(p)==old,"ROLLBACK_SHA_"+name) + else:need(not p.exists(),"ROLLBACK_NEW_REMAINS_"+name) + need(hash_file(self.meta)==m["oldMetadataSHA"],"ROLLBACK_METADATA_SHA") + m["phase"]="ROLLED_BACK" + self.write_manifest(m) + return {"status":"ROLLED_BACK","baselineRestored":True} + def rollback(self): + with self.exclusive(): + m=self.manifest() + need(m["phase"] in ("STAGED","APPLIED","DEGRADED"),"ROLLBACK_ALREADY_COMMITTED_OR_DONE") + return self._rollback_locked(m) + def commit(self,acceptance): + with self.exclusive(): + m=self.manifest() + need(m["phase"]=="APPLIED","COMMIT_UNACCEPTED_PHASE") + self._validate_slot(m) + states,meta=self._target_states(m) + need(all(states[name]==new for name,rel,target,old,new,mode in FILES), + "COMMIT_BACKEND_CHANGED") + need(meta==m["newMetadataSHA"],"COMMIT_META_CHANGED") + acceptance=pathlib.Path(acceptance) + # R783: avoid pathname file/symlink preflight. guarded_file_bytes + # alone opens NOFOLLOW through pinned dirfds, fstats that exact + # inode, enforces root-owned receipt metadata in native mode and + # reads the bounded bytes. Preserve the prior rejection contract + # for missing or symlinked acceptance files. + try: + receipt_blob=guarded_file_bytes( + acceptance,native_receipt=self.native,limit=65536) + except RuntimeError as exc: + if str(exc)=="FAIL_CLOSED_UNSAFE_FILE_COMPONENT": + raise RuntimeError( + "FAIL_CLOSED_NATIVE_ACCEPTANCE_MISSING") from exc + raise + receipt=json.loads(receipt_blob.decode("utf8")) + need(receipt.get("revision")=="R519" and + receipt.get("status")=="PASS_NATIVE_ROOT_UPGRADE" and + receipt.get("sourceRootSHA")==sha(canonical(m["files"])) and + receipt.get("transactionNonce")==m["transactionNonce"] and + receipt.get("protectedMetadataSHA")==meta and + receipt.get("nativeStartStopRecovery") is True and + receipt.get("tailscalePreserved") is True and + receipt.get("physicalDNSPreserved") is True, + "NATIVE_ACCEPTANCE_NOT_PROVEN") + m["phase"]="COMMITTED" + self.write_manifest(m) + # The acceptance and parent lineage must be exported into Project + # Brain before a separate maintenance cleanup is authorized. + return {"status":"COMMITTED","backupRetainedUntilBrainPromotion":True} + def status(self): + if not self.pending.exists():return {"status":"NO_PENDING_UPGRADE"} + m=self.manifest() + return {"status":m["phase"],"version":m["toVersion"],"uid":m["uid"]} + +def main(): + p=argparse.ArgumentParser() + p.add_argument("action",choices=("stage","apply","rollback","commit","status")) + p.add_argument("--source") + p.add_argument("--acceptance") + args=p.parse_args() + need(os.geteuid()==0,"ROOT_REQUIRED") + t=RootTransaction() # No CLI root-prefix/dry-run/test-mode option. + if args.action=="stage":need(bool(args.source),"SOURCE_REQUIRED");result=t.stage(args.source) + elif args.action=="apply":result=t.apply() + elif args.action=="rollback":result=t.rollback() + elif args.action=="commit":need(bool(args.acceptance),"ACCEPTANCE_REQUIRED");result=t.commit(args.acceptance) + else:result=t.status() + print(json.dumps(result,sort_keys=True)) +if __name__=="__main__": + try:main() + except Exception as e: + print(json.dumps({"ok":False,"error":str(e)[:200]}),file=sys.stderr) + sys.exit(82) diff --git a/linux/system/uninstall_system.sh b/linux/system/uninstall_system.sh index d9d5d69e..012797eb 100644 --- a/linux/system/uninstall_system.sh +++ b/linux/system/uninstall_system.sh @@ -6,6 +6,17 @@ if [ -e /run/directinternetmethod-selective/session.json ]; then /usr/bin/python3 /usr/lib/directinternetmethod/selective_dns.py restore || exit 88 [ ! -e /run/directinternetmethod-selective/session.json ] || exit 88 fi +# Fail closed on encrypted-DNS ownership ambiguity before uninstalling binaries. +if [ -e /run/directinternetmethod-encrypted/session.json ]; then + [ -x /usr/lib/directinternetmethod/encrypted_selective_dns.py ] || exit 88 + /usr/bin/python3 /usr/lib/directinternetmethod/encrypted_selective_dns.py restore || exit 88 +fi +for owned in /run/directinternetmethod-encrypted /run/directinternetmethod-encrypted-ctrld.toml /sys/class/net/dimesd0; do + [ ! -e "$owned" ] && [ ! -L "$owned" ] || { + echo "Encrypted DNS resource remains; do not remove protected backend." >&2 + exit 88 + } +done for action in start stop recovery; do rm -f "/etc/systemd/system/directinternetmethod-$action.service" done diff --git a/linux/system/upgrade_guard.py b/linux/system/upgrade_guard.py new file mode 100644 index 00000000..c6e8e419 --- /dev/null +++ b/linux/system/upgrade_guard.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python3 +"""R552 native Linux Root/User upgrade watchdog candidate, NOT authorized to arm. + +Prepare a pinned root-owned detached systemd rollback guard BEFORE protected +upgrade. Never disable OS authentication, change networking, or act on foreign +owners. The preflight action is unprivileged/read-only. arm/rollback are root +only and are NOT to be used on human hosts before independent hosted validation +and explicit host authorization. No update/install is performed by this file. +""" +from __future__ import annotations +import argparse +import hashlib +import importlib.util +import json +import datetime +import os +from pathlib import Path +import pwd +import re +import shutil +import stat +import subprocess +import sys +import time + +VERSION="1.5.7" +BASELINE="1.5.2" # legacy accepted baseline +OLD_USER="1.5.4" # legacy accepted baseline +ALLOWED_BASELINE_PAIRS=(("1.5.2","1.5.4"),("1.5.5-rc1","1.5.5-rc1"),("1.5.6","1.5.6")) +PIN_ROOT="239A0C21EB4A9564799A97AFE440C120668CEDCD49412E33D0AC78C8866820E7" +PIN_USER="69AE3A76CFCD3BB251546CCD5237F3AF081ED0477323A8F95737C8A4BC87B6F8" +GUARD=Path("/var/lib/directinternetmethod/upgrade-guard") +BASELINE_FILE=GUARD/"baseline.json" +RESULT=GUARD/"rollback-result.json" +WORKER=GUARD/"worker.py" +ROOT_TX=GUARD/"root_tx.py" +USER_TX=GUARD/"user_tx.py" +UNIT="directinternetmethod-upgrade-rollback" +SERVICE=Path("/etc/systemd/system")/(UNIT+".service") +TIMER=Path("/etc/systemd/system")/(UNIT+".timer") +USER_REL=".local/share/DirectInternetMethod" +PENDING_REL=".local/state/DirectInternetMethod/update-pending" +RUNNER_OWNER="runner" + +def need(ok:bool,label:str): + if not ok:raise RuntimeError("R552_GUARD_FAIL_CLOSED_"+label) + +def sha_file(path:Path)->str: + h=hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda:f.read(1024*1024),b""):h.update(chunk) + return h.hexdigest().upper() + +def regular(path:Path,uid=None,mode=None): + need(path.is_file() and not path.is_symlink(),"REGULAR_"+path.name) + s=path.stat() + if uid is not None:need(s.st_uid==uid,"OWNER_"+path.name) + if mode is not None:need(stat.S_IMODE(s.st_mode)==mode,"MODE_"+path.name) + return s + +def canonical(obj):return (json.dumps(obj,indent=2,sort_keys=True)+"\n").encode() + +def pinned_package_bytes(source:Path,relative:str,want:str,limit:int=4*1024*1024)->bytes: + """Pin and SHA-verify package bytes from one opened regular inode. + + Both the user-owned package and its ancestor directories may change between + the initial guard preflight and the Root operation. No path-based reopen, + no symlink parent and no blocking open of FIFO/special source nodes. + """ + path=source/relative + need(path.is_absolute() and ".." not in path.parts and len(path.parts)>1, + "PINNED_PACKAGE_PATH") + flags=os.O_RDONLY|os.O_CLOEXEC|os.O_NOFOLLOW|os.O_NONBLOCK + directory=os.open("/",flags|os.O_DIRECTORY) + opened=None + try: + try: + for name in path.parts[1:-1]: + child=os.open(name,flags|os.O_DIRECTORY,dir_fd=directory) + os.close(directory);directory=child + opened=os.open(path.name,flags,dir_fd=directory) + except OSError as exc: + raise RuntimeError("R552_GUARD_FAIL_CLOSED_PINNED_SOURCE_OPEN") from exc + info=os.fstat(opened) + need(stat.S_ISREG(info.st_mode) and 0<=info.st_size<=limit, + "PINNED_SOURCE_REGULAR_AND_SIZE") + with os.fdopen(os.dup(opened),"rb") as reader: + payload=reader.read(limit+1) + need(len(payload)<=limit and + hashlib.sha256(payload).hexdigest().upper()==want.upper(), + "PINNED_SOURCE_SHA_CHANGED") + return payload + finally: + if opened is not None:os.close(opened) + os.close(directory) + +def command(args,timeout=15): + return subprocess.run(args,text=True,capture_output=True,timeout=timeout,check=False) + +def fsync_dir(path:Path): + fd=os.open(path,os.O_RDONLY|os.O_DIRECTORY) + try:os.fsync(fd) + finally:os.close(fd) + +def write_new(path:Path,data:bytes,mode:int): + need(path.parent.is_dir() and not path.parent.is_symlink(),"WRITER_PARENT") + need(not path.exists() and not path.is_symlink(),"WRITER_EXISTS_"+path.name) + fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,mode) + try: + with os.fdopen(fd,"wb") as f: + os.fchmod(f.fileno(),mode) + f.write(data);f.flush();os.fsync(f.fileno()) + fsync_dir(path.parent) + finally: + pass + regular(path,0,mode) + +def checked_owner(): + p=Path("/var/lib/directinternetmethod/install.json") + regular(p,0,0o644) + obj=json.loads(p.read_text(encoding="utf8")) + need(obj.get("schema")==1 and + obj.get("version") in (v[0] for v in ALLOWED_BASELINE_PAIRS), + "PROTECTED_BASELINE") + uid=obj.get("uid");user=obj.get("user");home=obj.get("home") + need(type(uid) is int and 00,"INVALID_CALENDAR_DEADLINE") + deadline=datetime.datetime.fromtimestamp(deadline_unix,datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + service=("[Unit]\nDescription=Direct Internet Method guarded root+user upgrade rollback\n" + "After=local-fs.target\n" + "[Service]\nType=oneshot\nUser=root\nGroup=root\n" + "ExecStart=/usr/bin/python3 -B "+str(WORKER)+" rollback\n" + "TimeoutStartSec=120\nNoNewPrivileges=false\nUMask=0077\n") + timer=("[Unit]\nDescription=Direct Internet Method guarded upgrade timeout\n" + "[Timer]\nOnActiveSec=480s\nOnCalendar="+deadline+"\nPersistent=true\nAccuracySec=1s\n" + "Unit="+UNIT+".service\n" + "[Install]\nWantedBy=timers.target\n") + return service.encode(),timer.encode() + +def staged_user_manifest(home:Path,uid:int,installed:Path,previous_version:str=OLD_USER): + """Attest the ACTUAL v1.5.5 User stage schema; no invented phase field.""" + pending=home/PENDING_REL + need(pending.is_dir() and not pending.is_symlink(),"STAGED_USER_PENDING_REQUIRED") + need(pending.stat().st_uid==uid and stat.S_IMODE(pending.stat().st_mode)==0o700, + "PENDING_OWNER_OR_MODE") + manifest=pending/"manifest.json" + regular(manifest,uid,0o600) + j=json.loads(manifest.read_text("utf8")) + need(isinstance(j,dict) and j.get("schema")==1 and + j.get("version")==VERSION and j.get("previousVersion")==previous_version, + "USER_STAGE_IDENTITY") + managed=j.get("managed");prior=j.get("old");mutable=j.get("mutable") + # Exact package user-code file set; providers.json is managed only when + # the prior installed provider still matches its recorded managed SHA. + # R784 physical MMZ proved the old (7,8) cardinality wrongly rejected a + # legitimate 9-member stage with a managed provider. Never broaden this + # check to arbitrary ninth/foreign files. + user_files=frozenset(( + "direct_internet_method.py","socket_fallback.py","router_gateway.py", + "router_gateway_core.py","direct-internet-method.svg","uninstall.sh", + "user_uninstall_preserve.py","user_update_transaction.py")) + provider="router_gateway/providers.json" + mutable_files=frozenset(( + "settings.json","strategy.txt","scope.txt","custom-hosts.txt", + "adult-enabled.txt","adult-hosts.txt","adult-hosts.meta.json", + "encrypted-dns-optin.txt","selective-dns-optin.txt")) + managed_provider=j.get("providerManaged") + expected=user_files|({provider} if managed_provider is True else set()) + provider_sha=j.get("providerSha") + need(type(managed_provider) is bool and + isinstance(managed,dict) and set(managed)==expected and + isinstance(prior,dict) and set(prior)==expected|{"INSTALL.json"} and + isinstance(mutable,dict) and set(mutable)==mutable_files and + (provider_sha is None if managed_provider is False else + isinstance(provider_sha,str) and + isinstance(managed[provider],dict) and + managed[provider].get("sha")==provider_sha), + "USER_STAGE_FILESET") + meta=prior["INSTALL.json"] + need(isinstance(meta,dict) and meta.get("exists") is True and + str(meta.get("sha","")).lower()==sha_file(installed).lower(), + "USER_BASELINE_METADATA_CHANGED") + return j + +def arm(source:Path,expected_uid:int): + need(os.geteuid()==0,"ROOT_REQUIRED") + status=preflight(source) + uid,user,home,um,rm,meta=checked_owner() + need(uid==expected_uid,"USER_IDENTITY_CHANGED") + staged_user_manifest(home,uid,um,meta["version"]) + baseline=guard_files(source,uid,user,home,um,rm) + s,t=unit_templates(baseline["deadlineUnix"]) + write_new(SERVICE,s,0o644) + write_new(TIMER,t,0o644) + for cmd in ([ "/usr/bin/systemctl","daemon-reload"], + ["/usr/bin/systemctl","enable","--now",UNIT+".timer"]): + r=command(cmd) + need(r.returncode==0,"SYSTEMD_ARM_"+r.stderr[-120:].replace(" ","_")) + verify_timer() + return {"status":"ARMED_INDEPENDENT_ROOT_USER_WATCHDOG", + "armed":True,"ownedGuard":str(GUARD), + "sourceSha":baseline["workerSha"],"timer":UNIT+".timer"} + +def verify_guard_files(): + regular(WORKER,0,0o500) + regular(ROOT_TX,0,0o500);regular(USER_TX,0,0o644) + regular(BASELINE_FILE,0,0o600) + need(GUARD.is_dir() and not GUARD.is_symlink() and GUARD.stat().st_uid==0 and + stat.S_IMODE(GUARD.stat().st_mode)==0o711,"GUARD_OWNER") + j=json.loads(BASELINE_FILE.read_text("utf8")) + need(j.get("revision")=="R552" and j.get("workerSha")==sha_file(WORKER) + and sha_file(ROOT_TX)==PIN_ROOT and sha_file(USER_TX)==PIN_USER, + "GUARD_SOURCE_SHA") + for p in (SERVICE,TIMER):regular(p,0,0o644) + need((j.get("versionFromRoot"),j.get("versionFromUser")) + in ALLOWED_BASELINE_PAIRS and j.get("versionTo")==VERSION, + "GUARD_ACCEPTED_PREDECESSOR_PAIR") + need(j.get("timerSeconds")==480 and + type(j.get("deadlineUnix")) is int and + j["deadlineUnix"]>0,"BASELINE_DEADLINE") + service,timer=unit_templates(j["deadlineUnix"]) + need(SERVICE.read_bytes()==service and TIMER.read_bytes()==timer, + "ROOT_OWNED_TIMER_BYTES_CHANGED") + return j + +def verify_timer(): + j=verify_guard_files() + p=command(["/usr/bin/systemctl","show",UNIT+".timer", + "--property=ActiveState,LoadState,UnitFileState,Triggers","--no-pager"]) + need(p.returncode==0,"TIMER_QUERY") + need("ActiveState=active" in p.stdout and "LoadState=loaded" in p.stdout + and "UnitFileState=enabled" in p.stdout and + UNIT+".service" in p.stdout,"TIMER_NOT_INDEPENDENT_ACTIVE") + return j + +def imported_root(): + spec=importlib.util.spec_from_file_location("dim_r552_root_tx",ROOT_TX) + need(spec is not None and spec.loader is not None,"ROOT_IMPORT") + module=importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.RootTransaction() + +def verify_installed_after_restore(j): + uid,user,home,um,rm,_=checked_owner() + need(uid==j["uid"] and user==j["user"] and str(home)==j["home"],"RESTORED_OWNER") + need(sha_file(rm)==j["oldRootMetaSha"],"RESTORED_ROOT_META_SHA") + need(sha_file(um)==j["oldUserMetaSha"],"RESTORED_USER_META_SHA") + dns=Path("/etc/resolv.conf") + need(sha_file(dns)==j["oldResolverSha"] and + (os.readlink(dns) if dns.is_symlink() else None)==j["oldResolverLink"], + "DNS_CHANGED_EXTERNALLY") + need(not (home/PENDING_REL).exists(),"USER_SLOT_REMAINS") + +def finish_accepted_pair(j,tx,home:Path,pending:Path): + """Resume only a ROOT-ACCEPTED pair; never invent acceptance or rollback it. + + Root's COMMITTED manifest is a privileged durable decision. The original + user slot stays intact until the exact copied user transaction validates + all managed bytes, mutable settings and new INSTALL.json and commits. + An ambiguous/foreign state fails closed and retains its recovery evidence. + """ + m=tx.manifest() + need(m.get("phase")=="COMMITTED" and m.get("toVersion")==VERSION and + m.get("fromVersion")==j["versionFromRoot"] and m.get("uid")==j["uid"] and + m.get("home")==str(home),"ROOT_ACCEPTANCE_MANIFEST") + tx._validate_slot(m) + states,meta=tx._target_states(m) + need(set(states)==set(m["files"]) and + all(states[k]==m["files"][k]["new"] for k in states) and + meta==m["newMetadataSHA"],"ACCEPTED_ROOT_BINARY_OR_METADATA_DRIFT") + rootmeta=json.loads(tx.meta.read_text("utf8")) + need(rootmeta.get("version")==VERSION and rootmeta.get("uid")==j["uid"], + "ACCEPTED_ROOT_INSTALL_IDENTITY") + usermeta=home/USER_REL/"INSTALL.json" + regular(usermeta,j["uid"],0o600) + installed=json.loads(usermeta.read_text("utf8")) + need(installed.get("schema")==2 and installed.get("version")==VERSION and + installed.get("protectedBackendVersion")==VERSION, + "ACCEPTED_USER_INSTALL_IDENTITY") + was_pending=pending.exists() or pending.is_symlink() + if was_pending: + need(pending.is_dir() and not pending.is_symlink() and + pending.stat().st_uid==j["uid"],"ACCEPTED_USER_SLOT_FOREIGN") + job=command(["/usr/sbin/runuser","-u",j["user"],"--", + "/usr/bin/env","HOME="+j["home"], + "PYTHONDONTWRITEBYTECODE=1","/usr/bin/python3","-B", + str(USER_TX),"--commit"],timeout=45) + need(job.returncode==0,"USER_FORWARD_COMMIT_"+job.stderr[-180:].replace(" ","_")) + need(not pending.exists() and not pending.is_symlink(), + "ACCEPTED_USER_PENDING_REMAINS") + # No network actions; no root backups are destroyed by this worker. + return was_pending + +def rollback_worker(): + need(os.geteuid()==0 and Path(__file__).resolve()==WORKER,"OWNED_WORKER_ROOT_REQUIRED") + start=time.monotonic() + report={"revision":"R552","status":"DEGRADED_UNRESOLVED", + "whenUtc":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), + "uid":os.geteuid(),"networkMutationAttempted":False, + "recoveryAttempted":False} + try: + # A one-shot monotonic timer may no longer be ActiveState=active + # while it is executing its service. Validate root-owned guard bytes + # instead of misclassifying a correctly fired timer as unsafe. + j=verify_guard_files() + home=Path(j["home"]) + pending=home/PENDING_REL + tx=imported_root() + need(tx.native,"REAL_ROOT_TRANSACTION") + phase=tx.status()["status"] + report["rootPhaseBefore"]=phase + if phase=="COMMITTED": + # Root acceptance is already durable and cannot be rolled back. + # If its user partner was interrupted during finalization, resume + # exact user-only commit after validating both installed sides. + # An active engine may continue because this deletes ONLY the + # owned user code backup, not the active network or program files. + report["finalizationAttempted"]=pending.exists() + finished=finish_accepted_pair(j,tx,home,pending) + if finished: + report["userForwardCommit"]="PASS" + report["status"]="PASS_RESUMED_ACCEPTED_ROOT_USER_COMMIT" + else: + report["status"]="NO_ROLLBACK_ALREADY_COMMITTED" + else: + # Before Root acceptance, replacing installed code under a live + # engine is unsafe; require authorized local Recovery. + need(not (home/USER_REL/"directmethod/state.json").exists(), + "LIVE_ENGINE_REQUIRES_LOCAL_RECOVERY") + if phase=="ROLLED_BACK": + # R751: independent OnActiveSec/OnCalendar timer expressions may + # re-activate the service after the first successful rollback. + # Verify terminal Root/User bytes and the immutable original + # root-owned success receipt; NEVER roll back a second time, + # overwrite its evidence, or auto-retire the guard/slot here. + need(not pending.exists() and not pending.is_symlink(), + "ROLLEDBACK_USER_PENDING_REMAINS") + regular(RESULT,0,0o600) + need(RESULT.stat().st_nlink==1,"ROLLEDBACK_RECEIPT_HARDLINK") + receipt_bytes=RESULT.read_bytes() + prior=json.loads(receipt_bytes) + need(receipt_bytes==canonical(prior),"ROLLEDBACK_RECEIPT_NONCANONICAL") + need(prior.get("revision")=="R552" and + prior.get("status")=="PASS_AUTONOMOUS_TWO_SIDED_RESTORATION" and + prior.get("rootRollback")=="PASS" and + prior.get("userRollback")=="PASS" and + prior.get("recoveryAttempted") is True and + prior.get("networkMutationAttempted") is False and + prior.get("uid")==0, + "ROLLEDBACK_PRIOR_RECEIPT_NOT_SUCCESSFUL") + m=tx.manifest() + need(m.get("phase")=="ROLLED_BACK" and + m.get("fromVersion")==j["versionFromRoot"] and + m.get("toVersion")==VERSION and + m.get("uid")==j["uid"] and + m.get("home")==j["home"] and + m.get("oldMetadataSHA")==j["oldRootMetaSha"], + "ROLLEDBACK_FOREIGN_ROOT_TRANSACTION") + tx._validate_slot(m) + states,metadata=tx._target_states(m) + need(set(states)==set(m["files"]) and + all(states[name]==m["files"][name]["old"] for name in states) and + metadata==m["oldMetadataSHA"], + "ROLLEDBACK_ORIGINAL_ROOT_BYTES_CHANGED") + verify_installed_after_restore(j) + report["status"]="NO_CHANGE_ALREADY_ROLLED_BACK" + report["restoredReceiptSha256"]=sha_file(RESULT) + elif phase=="NO_PENDING_UPGRADE" and not pending.exists(): + report["status"]="NO_CHANGE_NO_PENDING" + else: + need(phase in ("STAGED","APPLIED","DEGRADED","NO_PENDING_UPGRADE"), + "UNKNOWN_ROOT_PHASE") + report["recoveryAttempted"]=True + if pending.exists(): + need(pending.is_dir() and not pending.is_symlink(),"UNSAFE_USER_SLOT") + p=command(["/usr/sbin/runuser","-u",j["user"],"--", + "/usr/bin/env","HOME="+j["home"], + "PYTHONDONTWRITEBYTECODE=1","/usr/bin/python3","-B", + str(USER_TX),"--rollback"],timeout=45) + need(p.returncode==0,"USER_ROLLBACK_"+p.stderr[-180:].replace(" ","_")) + report["userRollback"]="PASS" + if phase in ("STAGED","APPLIED","DEGRADED"): + returned=tx.rollback() + need(returned.get("status")=="ROLLED_BACK","ROOT_ROLLBACK") + report["rootRollback"]="PASS" + verify_installed_after_restore(j) + report["status"]="PASS_AUTONOMOUS_TWO_SIDED_RESTORATION" + except Exception as ex: + report["failure"]=type(ex).__name__+":"+str(ex)[:320] + report["durationSeconds"]=round(time.monotonic()-start,3) + if not RESULT.exists() and not RESULT.is_symlink(): + write_new(RESULT,canonical(report),0o600) + print(json.dumps(report,sort_keys=True)) + return 0 if report["status"] in ( + "PASS_RESUMED_ACCEPTED_ROOT_USER_COMMIT", + "NO_ROLLBACK_ALREADY_COMMITTED","NO_CHANGE_NO_PENDING", + "PASS_AUTONOMOUS_TWO_SIDED_RESTORATION", + "NO_CHANGE_ALREADY_ROLLED_BACK") else 90 + +def _exact_owned_inventory(root:Path,files:set[str],dirs:set[str],mode:int): + """Allowlist every file/dir; no symlinks, foreign nodes or hardlinks.""" + need(root.is_dir() and not root.is_symlink(),"RETIRE_ROOT_UNSAFE") + st=root.stat() + need(st.st_uid==0 and stat.S_IMODE(st.st_mode)==mode, + "RETIRE_ROOT_OWNER_MODE") + actual_files=set();actual_dirs=set() + for item in root.rglob("*"): + rel=item.relative_to(root).as_posix() + s=item.lstat() + need(s.st_uid==0,"RETIRE_FOREIGN_OWNER_"+rel) + if stat.S_ISDIR(s.st_mode): + actual_dirs.add(rel) + elif stat.S_ISREG(s.st_mode): + need(s.st_nlink==1,"RETIRE_HARDLINK_"+rel) + actual_files.add(rel) + else: + raise RuntimeError("RETIRE_FOREIGN_NODE_"+rel) + need(actual_files==files and actual_dirs==dirs,"RETIRE_FOREIGN_ENTRY_SET") + return True + +def retire_rolledback(receipt_sha256:str): + """Narrow operator-authenticated *post-rollback* recovery-slot retirement. + + Never auto-delete audit or live installation. An administrator must first + export the unique rollback evidence to project Brain and supply the exact + receipt SHA256. Root/User baseline must be byte-perfect; the guard and + root-owned slots are only retired when already terminal ROLLED_BACK. + Terminal COMMITTED, ambiguous, partial, live or foreign states are refused. + """ + need(os.geteuid()==0 and Path(__file__).resolve()==WORKER, + "RETIRE_OWNED_ROOT_WORKER_REQUIRED") + need(type(receipt_sha256) is str and len(receipt_sha256)==64 and + all(ch in "0123456789abcdefABCDEF" for ch in receipt_sha256), + "RETIRE_EXACT_OPERATOR_RECEIPT_SHA_REQUIRED") + j=verify_guard_files() + regular(RESULT,0,0o600) + digest=sha_file(RESULT) + need(digest==receipt_sha256.upper(),"RETIRE_RECEIPT_SHA_CHANGED") + evidence=json.loads(RESULT.read_text("utf8")) + need(evidence.get("revision")=="R552" + and evidence.get("status")=="PASS_AUTONOMOUS_TWO_SIDED_RESTORATION" + and evidence.get("rootRollback")=="PASS" + and evidence.get("userRollback")=="PASS" + and evidence.get("recoveryAttempted") is True + and evidence.get("networkMutationAttempted") is False, + "RETIRE_ROLLBACK_EVIDENCE_NOT_ACCEPTED") + tx=imported_root() + need(tx.native and tx.status()["status"]=="ROLLED_BACK", + "RETIRE_NONTERMINAL_ROOT") + m=tx.manifest() + need(m.get("phase")=="ROLLED_BACK" and m.get("uid")==j["uid"] and + m.get("home")==j["home"] and m.get("fromVersion")==j["versionFromRoot"] and + m.get("toVersion")==VERSION,"RETIRE_FOREIGN_ROOT_TRANSACTION") + tx._validate_slot(m) + states,metadata=tx._target_states(m) + need(set(states)==set(m["files"]) and + all(states[name]==row["old"] for name,row in m["files"].items()) and + metadata==m["oldMetadataSHA"],"RETIRE_OLD_PROTECTED_BITS_NOT_RESTORED") + home=Path(j["home"]) + need(not (home/USER_REL/"directmethod/state.json").exists(), + "RETIRE_LIVE_DIRECT_ENGINE") + verify_installed_after_restore(j) + # Full inventory before deleting anything, including directories. + names=set(m["files"]) + slotfiles={"manifest.json","old/metadata","new/metadata"} + slotfiles.update("new/"+name for name in names) + slotfiles.update("old/"+name for name,row in m["files"].items() if row["old"]) + _exact_owned_inventory(tx.pending,slotfiles,{"old","new"},0o700) + guardfiles={"baseline.json","rollback-result.json", + "worker.py","root_tx.py","user_tx.py"} + _exact_owned_inventory(GUARD,guardfiles,set(),0o711) + unit_sha={} + for item in (SERVICE,TIMER): + regular(item,0,0o644) + need(item.stat().st_nlink==1,"RETIRE_HARDLINK_UNIT") + unit_sha[item.name]=sha_file(item) + for item in [*([tx.pending/p for p in slotfiles]), + BASELINE_FILE,RESULT,WORKER,ROOT_TX,USER_TX,SERVICE,TIMER]: + need(item.is_file() and not item.is_symlink() and item.stat().st_uid==0 + and item.stat().st_nlink==1,"RETIRE_NAMED_ENTRY_CHANGED_"+item.name) + state=command(["/usr/bin/systemctl","show",UNIT+".service", + "--property=ActiveState","--no-pager"]) + need(state.returncode==0 and + ("ActiveState=inactive" in state.stdout or + "ActiveState=failed" in state.stdout), + "RETIRE_SERVICE_IS_BUSY") + # No destructive operation before all checks above. After successful + # restoration, first DISARM the timer and only then remove exact owned + # transaction files. A partial maintenance interruption remains visibly + # fail-closed, never auto-overwrites foreign content on a retry. + disarmed=command(["/usr/bin/systemctl","disable","--now",UNIT+".timer"]) + need(disarmed.returncode==0,"RETIRE_TIMER_DISARM_FAILED") + # systemd Persistent= leaves a separate on-disk last-trigger stamp. + # Use the vendor-supported state-clean command before unit unlinking. + stamp=command(["/usr/bin/systemctl","clean","--what=state",UNIT+".timer"]) + need(stamp.returncode==0,"RETIRE_PERSISTENT_TIMER_STATE_CLEAN_FAILED") + for rel in sorted(slotfiles): + p=tx.pending/rel + need(p.is_file() and not p.is_symlink() and p.stat().st_uid==0, + "RETIRE_SLOT_RACE_"+rel) + p.unlink() + for name in ("new","old"): + (tx.pending/name).rmdir() + tx.pending.rmdir() + fsync_dir(tx.pending.parent) + for p in (ROOT_TX,USER_TX,BASELINE_FILE,WORKER,RESULT): + need(p.is_file() and not p.is_symlink() and p.stat().st_uid==0, + "RETIRE_GUARD_RACE_"+p.name) + p.unlink() + GUARD.rmdir() + fsync_dir(GUARD.parent) + for p in (SERVICE,TIMER): + need(p.is_file() and not p.is_symlink() and + sha_file(p)==unit_sha[p.name],"RETIRE_UNIT_CHANGED_"+p.name) + p.unlink() + fsync_dir(SERVICE.parent) + reload=command(["/usr/bin/systemctl","daemon-reload"]) + need(reload.returncode==0,"RETIRE_SYSTEMD_RELOAD_FAILED") + need(tx.status()["status"]=="NO_PENDING_UPGRADE" + and not GUARD.exists() and not TIMER.exists() and not SERVICE.exists(), + "RETIRE_POSTCLEANUP_SLOT_LEFT") + need(sha_file(Path("/etc/resolv.conf"))==j["oldResolverSha"], + "RETIRE_DNS_CHANGED") + return {"status":"PASS_AUTHORIZED_BOUNDED_ROLLBACK_RETIRE", + "receiptSha256":digest, + "oldRootVersion":j["versionFromRoot"],"oldUserVersion":j["versionFromUser"], + "fileScopesExact":True,"networkMutationAttempted":False, + "timerDisarmed":True,"repeatUpgradeUnblocked":True} + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument("action",choices=("preflight","arm","status","rollback","retire-rolledback")) + parser.add_argument("--source",type=Path) + parser.add_argument("--uid",type=int) + parser.add_argument("--receipt-sha256") + opts=parser.parse_args() + if opts.action in ("preflight","arm"): + need(opts.source is not None,"SOURCE_REQUIRED") + if opts.action=="preflight": + print(json.dumps(preflight(opts.source),sort_keys=True)) + else: + need(opts.uid is not None,"UID_REQUIRED") + print(json.dumps(arm(opts.source,opts.uid),sort_keys=True)) + elif opts.action=="retire-rolledback": + need(opts.receipt_sha256 is not None,"RETIRE_RECEIPT_REQUIRED") + print(json.dumps(retire_rolledback(opts.receipt_sha256),sort_keys=True)) + elif opts.action=="status": + need(os.geteuid()==0,"ROOT_REQUIRED") + print(json.dumps({"status":"ARMED",**verify_timer()},sort_keys=True)) + else: + raise SystemExit(rollback_worker()) + +if __name__=="__main__": + try:main() + except Exception as e: + print(json.dumps({"ok":False,"error":str(e)[:350]}),file=sys.stderr) + sys.exit(82) diff --git a/linux/system/upgrade_system.sh b/linux/system/upgrade_system.sh new file mode 100644 index 00000000..b146e984 --- /dev/null +++ b/linux/system/upgrade_system.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# R519 unreleased candidate; single privileged entrypoint for protected upgrade. +# Only after user transaction has verified and staged the exact extracted package. +# Never auto-commit root; native acceptance and owner review are separate. +set -Eeuo pipefail +umask 077 +SRC="${1:-}" +REQ_UID="${2:-}" +TX_SHA="239A0C21EB4A9564799A97AFE440C120668CEDCD49412E33D0AC78C8866820E7" +[ "$(id -u)" = 0 ] || { echo 'ROOT_REQUIRED' >&2; exit 77; } +case "$REQ_UID" in ''|*[!0-9]*) echo 'UID_INVALID' >&2; exit 77;; esac +[ "$REQ_UID" -gt 0 ] && [ "$REQ_UID" -lt 2147483648 ] || exit 77 +[ -d "$SRC" ] && [ ! -L "$SRC" ] && [ -f "$SRC/system/protected_upgrade_transaction.py" ] || { + echo "UPGRADE_SOURCE_MISSING_OR_SYMLINK" >&2; exit 77; +} +[ "$(basename "$SRC")" = "DirectInternetMethod_1.5.7_Linux_x86_64" ] || { + echo "WRONG_PACKAGE_LAYOUT" >&2; exit 77; +} +owner_uid="$(stat -c %u -- "$SRC")" +[ "$owner_uid" = "$REQ_UID" ] || { echo 'PACKAGE_OWNER_MISMATCH' >&2; exit 77; } +[ "$(sha256sum "$SRC/system/protected_upgrade_transaction.py" | cut -d' ' -f1)" = \ + "$(printf '%s' "$TX_SHA" | tr '[:upper:]' '[:lower:]')" ] || { + echo "UPGRADER_SOURCE_SHA_CHANGED" >&2; exit 77; +} +[ "$(cat /etc/directinternetmethod.uid 2>/dev/null)" = "$REQ_UID" ] || { + echo "PROTECTED_ACCOUNT_ID_CHANGED" >&2; exit 77; +} +python3 - "$REQ_UID" <<'PY' +from pathlib import Path +import json,sys,stat +expected=int(sys.argv[1]) +p=Path('/var/lib/directinternetmethod/install.json') +assert p.is_file() and not p.is_symlink() +s=p.stat() +assert s.st_uid==0 and stat.S_IMODE(s.st_mode)==0o644 +m=json.loads(p.read_text('utf8')) +assert m['schema']==1 and m['version'] in ('1.5.2','1.5.5-rc1','1.5.6') and m['uid']==expected +assert m['home'].startswith('/home/') +PY +# Arm the separately root-owned boot-persistent R553 guard BEFORE any +# protected Root Stage/Apply. Source identity is a frozen independent SHA; +# a signed-in OS administrator still authenticates through the installer. +GUARD_SHA="66B683E14FCDEC8D62CDFD2879AAB29D9DE0FD778DD67E0522BFB1218DC122D0" +[ -f "$SRC/system/upgrade_guard.py" ] && [ ! -L "$SRC/system/upgrade_guard.py" ] || { + echo "R553_GUARD_SOURCE_MISSING_OR_SYMLINK" >&2; exit 87; +} +[ "$(sha256sum "$SRC/system/upgrade_guard.py" | cut -d' ' -f1)" = \ + "$(printf '%s' "$GUARD_SHA" | tr '[:upper:]' '[:lower:]')" ] || { + echo "R553_GUARD_SOURCE_SHA_CHANGED" >&2; exit 87; +} +# R779: the user-owned source can change after its first SHA check. Copy +# into a private Root-only runtime directory, then hash the actual bytes that +# will be interpreted. This bootstrap is never a permanent second install. +GUARD_BOOTSTRAP_DIR="$(mktemp -d /run/directinternetmethod-guard-XXXXXXXX)" +[ -d "$GUARD_BOOTSTRAP_DIR" ] && [ ! -L "$GUARD_BOOTSTRAP_DIR" ] && +[ "$(stat -c %u -- "$GUARD_BOOTSTRAP_DIR")" = 0 ] && +[ "$(stat -c %a -- "$GUARD_BOOTSTRAP_DIR")" = 700 ] || { + echo 'R779_ROOT_BOOTSTRAP_DIR_UNSAFE' >&2; exit 87; +} +cleanup_guard_bootstrap() { + rm -f -- "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" + rmdir -- "$GUARD_BOOTSTRAP_DIR" +} +trap cleanup_guard_bootstrap EXIT +install -m 0500 -- "$SRC/system/upgrade_guard.py" "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" +[ -f "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" ] && +[ ! -L "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" ] && +[ "$(stat -c %u -- "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py")" = 0 ] && +[ "$(sha256sum "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" | cut -d' ' -f1)" = \ + "$(printf '%s' "$GUARD_SHA" | tr '[:upper:]' '[:lower:]')" ] || { + echo 'R779_BOOTSTRAP_COPY_SHA_CHANGED' >&2; exit 87; +} +python3 -B "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" arm --source "$SRC" --uid "$REQ_UID" +# R553: Do not touch root-owned binaries/metadata without a separately +# activated, root-owned two-sided timed rollback guard. A user-scoped backup +# from install.sh is reversible; it is NOT a protected autonomous watchdog. +python3 - "$REQ_UID" "$TX_SHA" <<'PY' +import hashlib,json,os,pathlib,stat,subprocess,sys +uid=int(sys.argv[1]); root_sha=sys.argv[2].upper() +unit="directinternetmethod-upgrade-rollback" +guard=pathlib.Path("/var/lib/directinternetmethod/upgrade-guard") +root=pathlib.Path("/var/lib/directinternetmethod/install.json") +try: + assert guard.is_dir() and not guard.is_symlink() + gs=guard.stat() + assert gs.st_uid==0 and stat.S_IMODE(gs.st_mode)==0o711 + paths={"baseline.json":0o600,"worker.py":0o500,"root_tx.py":0o500,"user_tx.py":0o644} + for name,mode in paths.items(): + p=guard/name + assert p.is_file() and not p.is_symlink() + st=p.stat() + assert st.st_uid==0 and stat.S_IMODE(st.st_mode)==mode + def sha(path): + h=hashlib.sha256() + with path.open("rb") as inp: + for b in iter(lambda:inp.read(1048576),b""):h.update(b) + return h.hexdigest().upper() + metadata=json.loads(root.read_text("utf8")) + home=pathlib.Path(metadata["home"]) + data=json.loads((guard/"baseline.json").read_text("utf8")) + assert data["schema"]==1 and data["revision"]=="R552" + assert data["uid"]==uid and metadata["uid"]==uid and data["home"]==str(home) + assert (data["versionFromRoot"],data["versionFromUser"]) in ( + ("1.5.2","1.5.4"),("1.5.5-rc1","1.5.5-rc1"),("1.5.6","1.5.6")) + assert metadata["version"]==data["versionFromRoot"] + assert data["versionTo"]=="1.5.7" + assert data["oldRootMetaSha"]==sha(root) + assert data["oldUserMetaSha"]==sha(home/".local/share/DirectInternetMethod/INSTALL.json") + assert data["rootTransactionSha"]==root_sha + assert sha(guard/"root_tx.py")==root_sha + assert sha(guard/"user_tx.py")=="69AE3A76CFCD3BB251546CCD5237F3AF081ED0477323A8F95737C8A4BC87B6F8" + assert data["userTransactionSha"]==sha(guard/"user_tx.py") + assert data["workerSha"]==sha(guard/"worker.py") + pending=home/".local/state/DirectInternetMethod/update-pending" + assert pending.is_dir() and not pending.is_symlink() and pending.stat().st_uid==uid + pm=pending/"manifest.json" + assert pm.is_file() and not pm.is_symlink() and pm.stat().st_uid==uid + stage=json.loads(pm.read_text("utf8")) + assert stage["schema"]==1 and stage["version"]=="1.5.7" + assert stage["previousVersion"]==data["versionFromUser"] + old=stage["old"]["INSTALL.json"] + assert old["exists"] is True and str(old["sha"]).upper()==data["oldUserMetaSha"] + for ext in (".service",".timer"): + p=pathlib.Path("/etc/systemd/system")/(unit+ext) + assert p.is_file() and not p.is_symlink() + st=p.stat() + assert st.st_uid==0 and stat.S_IMODE(st.st_mode)==0o644 + res=subprocess.run(["/usr/bin/systemctl","show",unit+".timer", + "--property=ActiveState,LoadState,UnitFileState,Triggers","--no-pager"], + text=True,capture_output=True,timeout=6,check=False) + assert res.returncode==0 and all(s in res.stdout for s in + ("ActiveState=active","LoadState=loaded","UnitFileState=enabled", + unit+".service")) +except Exception as ex: + print("R553_ROOT_GUARD_NOT_ARMED_FAIL_CLOSED "+type(ex).__name__,file=sys.stderr) + sys.exit(87) +print("R553_ROOT_GUARD_ACTIVE_PRESTAGE_VERIFIED") +PY +# Transactional stage + apply obtain native action lock; one rollback slot. +GUARD_ROOT_TX="/var/lib/directinternetmethod/upgrade-guard/root_tx.py" +python3 -B "$GUARD_ROOT_TX" stage --source "$SRC" +python3 -B "$GUARD_ROOT_TX" apply +python3 -B "$GUARD_ROOT_TX" status | + python3 -c 'import json,sys;j=json.load(sys.stdin);assert j["status"]=="APPLIED" and j["version"]=="1.5.7"' +echo '{"status":"ROOT_R519_APPLIED_UNCOMMITTED","needsRealNativeAcceptance":true}' diff --git a/tests/R062-NativeServiceCurl.ps1 b/tests/R062-NativeServiceCurl.ps1 new file mode 100644 index 00000000..e611602c --- /dev/null +++ b/tests/R062-NativeServiceCurl.ps1 @@ -0,0 +1,27 @@ +[CmdletBinding()] +param() +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Only GitHub-hosted disposable Windows. This guard runs BEFORE dotnet starts. +# The typed .NET 10 fixture itself independently repeats this host check. +if ($env:GITHUB_ACTIONS -cne 'true' -or + $env:RUNNER_ENVIRONMENT -cne 'github-hosted' -or + $env:RUNNER_OS -cne 'Windows' -or + [string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE) -or + [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_SHA) -or + $env:COMPUTERNAME -match '(?i)EMAD-PC') { + throw 'R062_ONLY_DISPOSABLE_GITHUB_WINDOWS_RUNNER' +} + +$project = Join-Path $env:GITHUB_WORKSPACE 'tests/service_curl_native/ServiceCurlNative.csproj' +if (!(Test-Path -LiteralPath $project -PathType Leaf)) { + throw 'R062_TYPED_NATIVE_DOTNET_PROJECT_MISSING' +} +# Avoid PowerShell Reflection.Invoke and its PSObject/CLR parameter wrapping. +# Native CLR strings + CancellationToken are handled inside a net10.0-windows executable. +& dotnet run --project $project -c Release --no-launch-profile +if ($LASTEXITCODE -ne 0) { + throw 'R062_NATIVE_SERVICE_CURL_LOOPBACK_TEST_FAILED' +} diff --git a/tests/dotnet10_target_contract.py b/tests/dotnet10_target_contract.py index fafea942..e79cbe1a 100644 --- a/tests/dotnet10_target_contract.py +++ b/tests/dotnet10_target_contract.py @@ -4,20 +4,36 @@ Existing public v1.5.3 release remains immutable. Source-only upgrade is not a new release. """ from pathlib import Path +import json from xml.etree import ElementTree as ET ROOT=Path(__file__).resolve().parents[1] TARGET="net10.0-windows" PROJECTS=("windows/gui/DirectInternetMethod.csproj","windows/service/DirectInternetMethod.Service.csproj") CI=ROOT/".github/workflows/ci.yml" +REL=json.loads((ROOT/"windows/RELEASE.json").read_text(encoding="utf-8-sig")) +CURRENT=REL.get("version") +if CURRENT=="1.5.4": + assert REL.get("status")=="CANDIDATE_V154_UNPUBLISHED" and REL.get("previousAccepted",{}).get("version")=="1.5.3" +elif CURRENT in ("1.5.5-rc1","1.5.6","1.5.7"): + accepted={"1.5.5-rc1":"PRIVATE_WINDOWS_RC1_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN", + "1.5.6":"PRIVATE_WINDOWS_V156_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN", + "1.5.7":"PRIVATE_WINDOWS_V157_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN"} + assert REL.get("status")==accepted[CURRENT] + assert REL.get("previousAccepted",{}).get("version")=="1.5.4" and REL.get("previousAccepted",{}).get("tag")=="v1.5.4" + assert REL.get("publication",{}).get("publishedAt") is None +else: + raise AssertionError("WINDOWS_CANDIDATE_VERSION_UNAUTHORIZED") def inspect(raw,project): tree=ET.fromstring(raw) target=tree.find(".//TargetFramework") version=tree.find(".//Version") + revision=tree.find(".//IncludeSourceRevisionInInformationalVersion") output=tree.find(".//OutputType") assert target is not None and target.text==TARGET,(project,"TargetFramework") - assert version is not None and version.text=="1.5.4",(project,"product_version_guard") + assert version is not None and version.text==CURRENT,(project,"product_version_guard") + assert revision is not None and revision.text=="false",(project,"reproducible_informational_version") assert output is not None and output.text=="WinExe",(project,"runtime_subsystem") if "/gui/" in project: wpf=tree.find(".//UseWPF") @@ -30,19 +46,28 @@ def negative_first(): inspect(data,project) assert data.count(b"net10.0-windows")==1 assert b"net8.0-windows" not in data + # In-memory negative fixture: a commit-derived InformationalVersion + # would make unchanged source differ between CI GitHub revisions. + try:inspect(data.replace(b"false",b"true"),project) + except AssertionError:pass + else:raise AssertionError(("FAIL_OPEN_SOURCE_REVISION_EMBED",project)) # In-memory source mutations must be rejected before packaging. for wrong in (b"net8.0-windows",b"net9.0-windows",b"net11.0-windows"): try:inspect(data.replace(b"net10.0-windows",wrong),project) except AssertionError:pass else:raise AssertionError(("FAIL_OPEN_VERSION_DRIFT",project,wrong)) - try:inspect(data.replace(b"1.5.4",b"1.5.3"),project) + try:inspect(data.replace((""+CURRENT+"").encode(),b"1.5.3"),project) except AssertionError:pass else:raise AssertionError(("FAIL_OPEN_PRODUCT_VERSION_DRIFT",project)) def main(): negative_first() ci=CI.read_text("utf-8") - assert ci.count("dotnet-version: '10.0.x'")==1 + expected_dotnet_jobs = 4 if CURRENT=="1.5.7" else (2 if CURRENT in ("1.5.5-rc1","1.5.6") else 1) + assert ci.count("dotnet-version: '10.0.x'")==expected_dotnet_jobs,"SDK_10_JOB_PIN_COUNT" + if CURRENT in ("1.5.5-rc1","1.5.6","1.5.7"): + assert ci.count(" windows-private-r535-native:")==1 + assert "runs-on: windows-2025" in ci and "tests/r535_windows_native_rollback.ps1" in ci assert "dotnet-version: '8.0.x'" not in ci assert ci.count("python -B tests/dotnet10_target_contract.py")==1 assert ci.count("dotnet build windows/gui/DirectInternetMethod.csproj")==1 diff --git a/tests/encrypted_selective_dns_controller_contract.py b/tests/encrypted_selective_dns_controller_contract.py new file mode 100644 index 00000000..834ebcff --- /dev/null +++ b/tests/encrypted_selective_dns_controller_contract.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""R517 negative-first, host-mutating commands fully mocked. Run on Linux user. +Raises if a potentially dangerous API command changes contract or live host +would be touched. No service/network/files outside a test-private directory. +""" +from __future__ import annotations +import importlib.util +import ipaddress +import json +import os +from pathlib import Path +import re +import shutil +import tempfile +import tomllib +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT=Path(__file__).resolve().parents[1] +CONTROLLER=ROOT/"linux/system/encrypted_selective_dns.py" +POLICY=ROOT/"linux/system/encrypted_selective_policy.py" +def import_source(name:str,path:Path): + spec=importlib.util.spec_from_file_location(name,path) + assert spec is not None and spec.loader is not None + mod=importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod +m=import_source("r517_safe_controller",CONTROLLER) +g=import_source("r517_safe_policy",POLICY) +STATE={ + "schema":1,"phase":"ACTIVE","uid":1000,"session":"f0e1d2c3b4", + "userHome":"/home/mmz","physical":"enp7s0", + "physicalBaseline":{"dns":["192.168.8.1"],"domains":[],"default":True}, + "tailscaleDNS":"A"*64,"ctrldSha":m.VERSIONED_CTRLD_SHA, + "configPath":str(m.CONFIG),"configSha":"B"*64, + "iface":m.IFACE,"ownedIp":m.CIDR,"port":m.PORT,"dummyIndex":7, + "started":0.0, + "ctrldUnit":"dim-edsn-dns-1000-f0e1d2c3b4.service", + "monitorUnit":"dim-edsn-watch-1000-f0e1d2c3b4.service", + "backstopTimer":"dim-edsn-backstop-1000-f0e1d2c3b4.timer", +} +COMMANDS=[] +def fail_if_called(*args,**kwargs): + COMMANDS.append((args,kwargs)) + raise AssertionError("UNEXPECTED_HOST_OR_MUTATING_COMMAND: "+repr(args)) + +class Contract(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp=tempfile.TemporaryDirectory(prefix="R517_nohost_",dir=str(ROOT)) + cls.home=Path(cls.temp.name) + cls.app=cls.home/".local/share/DirectInternetMethod" + cls.app.mkdir(parents=True) + cls.uid=os.getuid() + cls.tests=0 + @classmethod + def tearDownClass(cls): + cls.temp.cleanup() + def setUp(self): + COMMANDS.clear() + if self.app.exists(): shutil.rmtree(self.app) + self.app.mkdir(parents=True) + def pass_if_reject(self,func,reason): + with self.assertRaisesRegex((RuntimeError,ValueError),reason): + func() + def make_consent(self): + self.app.joinpath("strategy.txt").write_text("balanced\n") + self.app.joinpath("scope.txt").write_text("targeted\n") + consent=self.app/m.NEW_OPTIN + consent.write_text(m.CONSENT+"\n") + consent.chmod(0o600) + return consent + + def test_source_and_constants(self): + self.assertTrue(CONTROLLER.is_file() and POLICY.is_file()) + self.assertEqual(m.ACTION_LOCK.as_posix(),"/run/directinternetmethod-action.lock") + self.assertEqual(m.VERSIONED_CTRLD_SHA,"CA64579A2BC866CF72F3E46663E551162AFEFF0D448E899A01817A095E45B071") + self.assertEqual(m.CONSENT,"I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1") + self.assertNotEqual(m.CONSENT,"I_ACCEPT_UNENCRYPTED_SELECTIVE_DNS_V1") + self.assertEqual(m.IFACE,"dimesd0") + self.assertEqual(m.IP,"172.31.254.59") + self.assertEqual(m.PORT,53534) + self.assertEqual(m.TARGETS,("youtube.com","googlevideo.com","ytimg.com","youtu.be")) + + def test_policy_local_bind_and_router_no_wan(self): + for router in ("192.168.8.1","192.168.20.1","10.0.0.1","172.16.0.1"): + blob=g.build_owned_interface_policy(router) + conf=tomllib.loads(blob.decode("ascii")) + self.assertEqual(conf["listener"]["0"]["ip"],m.IP) + self.assertEqual(conf["listener"]["0"]["port"],m.PORT) + self.assertFalse(conf["listener"]["0"]["allow_wan_clients"]) + self.assertEqual(conf["network"]["0"]["cidrs"],[m.CIDR]) + self.assertEqual(conf["upstream"]["0"]["type"],"doh") + self.assertEqual(conf["upstream"]["0"]["endpoint"],"https://76.76.10.11/p0") + self.assertEqual(conf["upstream"]["1"]["type"],"legacy") + self.assertEqual(conf["upstream"]["1"]["endpoint"],router) + self.assertFalse(conf["service"]["leak_on_upstream_failure"]) + rules=conf["listener"]["0"]["policy"]["rules"] + self.assertEqual(len(rules),8) + self.assertTrue(all(list(r.values())==[["upstream.0"]] for r in rules)) + def test_policy_fallback_no_unexpected_listener(self): + for r in ("192.168.8.1","192.168.20.1"): + b=g.build_owned_interface_policy(r) + self.assertNotIn(b'0.0.0.0',b) + self.assertNotIn(b'0.0.0.0/0',b) + self.assertNotIn(b'53.53',b) + def test_policy_invalid_router_rejected(self): + for x in ("127.0.0.1","8.8.8.8","192.168.8.1\n[upstream.99]","192.168.8.1 ", + "169.254.10.1","10.0.0.0.1",None,42,"1.2.3.4","0.0.0.0"): + with self.subTest(input=x),self.assertRaises((ValueError,TypeError)): + g.build_owned_interface_policy(x) + def test_state_positive(self): + self.assertIs(m.validate_state(dict(STATE)).__class__,dict) + def test_state_negative_identity_and_units(self): + bad={ + "schema":[2, "1",None], + "phase":["RELEASED","READY",None], + "uid":[None,0,-1,3.2,"1000"], + "session":["../oops","A"*10,"123","f"*11,None], + "iface":["eth0","dimesd1",None], + "ownedIp":["0.0.0.0/0",None], + "port":[53,53535,None], + "ctrldSha":["0"*64,None], + "configPath":["/tmp/foreign",None], + "configSha":["b"*64,"bad",None], + "physical":["; rm -rf /","eth0;true",""], + "ctrldUnit":["ssh.service","dim-edsn-dns-0-f0e1d2c3b4.service",None], + "monitorUnit":["tailscaled.service",None], + "backstopTimer":["global.timer",None], + "dummyIndex":[-1,0,"7"], + "userHome":["/tmp/path","/home/mmz/../secret","relative",None], + "tailscaleDNS":["bad",None], + "physicalBaseline":[{},{"dns":[]},{"dns":["192.168.8.1","8.8.8.8"]}], + } + for k,values in bad.items(): + for val in values: + d=dict(STATE);d[k]=val + with self.subTest(field=k,val=val),self.assertRaises((RuntimeError,TypeError,ValueError,AttributeError,KeyError)): + m.validate_state(d) + def test_state_symlink_read_fails(self): + with patch.object(m,"STATE",self.app/"state.json"): + self.app.joinpath("state_target.json").write_text("{}") + self.app.joinpath("state.json").symlink_to(self.app/"state_target.json") + try: + self.pass_if_reject(m.read_state,"STATE_UNSAFE_TYPE") + finally: + self.app.joinpath("state.json").unlink() + def test_nonroot_mutations_rejected_no_commands(self): + with patch.object(m.os,"geteuid",return_value=1000),patch.object(m,"call",side_effect=fail_if_called): + self.pass_if_reject(lambda:m.activate(1000,"enp7s0",str(self.home)),"ROOT_REQUIRED") + self.pass_if_reject(lambda:m.rollback(),"ROOT_REQUIRED") + self.pass_if_reject(lambda:m.commit(),"ROOT_REQUIRED") + self.pass_if_reject(lambda:m.backstop(),"ROOT_REQUIRED") + self.pass_if_reject(lambda:m.monitor(),"ROOT_REQUIRED") + self.assertFalse(COMMANDS) + def test_missing_explicit_consent_denied(self): + with self.assertRaisesRegex(RuntimeError,"EXPLICIT_ENCRYPTED_CONSENT_MISSING"): + m.require_consent(self.uid,str(self.home)) + def test_explicit_consent_positive(self): + self.make_consent() + m.require_consent(self.uid,str(self.home)) + def test_old_udp_optin_blocks_new(self): + self.make_consent() + x=self.app/m.OLD_OPTIN;x.write_text("I_ACCEPT_UNENCRYPTED_SELECTIVE_DNS_V1\n") + with self.assertRaisesRegex(RuntimeError,"LEGACY_UNENCRYPTED_OPTIN_CONFLICT"): + m.require_consent(self.uid,str(self.home)) + def test_untrusted_encrypted_consent_denied(self): + x=self.make_consent();x.write_text("UNENCRYPTED_USER_ACCEPT\n") + with self.assertRaisesRegex(RuntimeError,"CONSENT_NOT_EXACT_OR_PRIVATE"): + m.require_consent(self.uid,str(self.home)) + def test_untrusted_consent_permissions_denied(self): + x=self.make_consent();x.chmod(0o644) + with self.assertRaisesRegex(RuntimeError,"CONSENT_NOT_EXACT_OR_PRIVATE"): + m.require_consent(self.uid,str(self.home)) + def test_encrypted_consent_symlink_denied(self): + x=self.make_consent();x.unlink();x.symlink_to(self.app/"strategy.txt") + with self.assertRaisesRegex(RuntimeError,"EXPLICIT_ENCRYPTED_CONSENT_MISSING"): + m.require_consent(self.uid,str(self.home)) + def test_other_profile_denied(self): + self.make_consent() + for name,bad in (("scope.txt","all-sites"),("strategy.txt","strong")): + p=self.app/name;old=p.read_text();p.write_text(bad+"\n") + with self.assertRaisesRegex(RuntimeError,"INVALID_CONSENT_PROFILE"): + m.require_consent(self.uid,str(self.home)) + p.write_text(old) + def test_no_missing_strategy_implicit_consent(self): + self.make_consent();self.app.joinpath("strategy.txt").unlink() + with self.assertRaisesRegex(RuntimeError,"INVALID_CONSENT_PROFILE"): + m.require_consent(self.uid,str(self.home)) + def test_link_dns_dbus_octets_exact(self): + calls=[] + with patch.object(m,"call",side_effect=lambda *args,**kwargs:calls.append(args)): + m.link_dns(7) + self.assertEqual(len(calls),3) + a=calls[0] + ix=a.index("SetLinkDNSEx") + self.assertEqual(a[ix+1:],("ia(iayqs)","7","1","2","4","172","31","254","59","53534","")) + self.assertIn("SetLinkDomains",calls[1]) + d=calls[1] + di=d.index("SetLinkDomains") + self.assertEqual(d[di+1:di+3],("ia(sb)","7")) + self.assertEqual(d[di+3],"4") + self.assertEqual([d[i] for i in range(di+4,len(d),2)],list(m.TARGETS)) + self.assertEqual([d[i] for i in range(di+5,len(d),2)],["true"]*4) + self.assertIn("SetLinkDefaultRoute",calls[2]) + def test_link_dns_no_user_controlled_index(self): + with patch.object(m,"call",side_effect=fail_if_called): + # The caller validates ifindex identity; this function is purposely + # not public and should not accept arbitrary negative indexes. + pass + def test_dns_is_owned_exact_matches_and_rejects(self): + valid={"dns":["172.31.254.59:53534"],"domains":["~youtube.com","~googlevideo.com","~ytimg.com","~youtu.be"],"default":False} + with patch.object(m,"link_state",return_value=valid): + self.assertTrue(m.dns_is_owned()) + for k,x in (("dns",["172.31.254.58:53534"]),("dns",["172.31.254.59:53"]), + ("domains",["~youtube.com"]),("default",True)): + a=dict(valid);a[k]=x + with patch.object(m,"link_state",return_value=a): + self.assertFalse(m.dns_is_owned()) + def test_public_dns_true_false(self): + for value,want in (("142.251.154.4\n",True),("10.10.34.36\n",False), + ("142.251.154.4\n10.10.34.36\n",False),("",False), + ("142.251.154.4\n172.16.1.1\n",False)): + with patch.object(m,"call",return_value=SimpleNamespace(stdout=value,returncode=0)): + self.assertEqual(m.public_dns("www.youtube.com"),want) + with patch.object(m,"call",return_value=SimpleNamespace(stdout="142.251.154.4",returncode=9)): + self.assertFalse(m.public_dns("www.youtube.com")) + def test_network_preflight_fails_on_exitnode(self): + def fake(*args,**kw): + if args[:3]==("tailscale","status","--json"): + return SimpleNamespace(stdout=json.dumps({"BackendState":"Running","ExitNodeStatus":{"ID":"x"}})) + raise AssertionError("DID_NOT_STOP_AFTER_EXITNODE") + with patch.object(m,"call",side_effect=fake): + self.pass_if_reject(lambda:m.network_preflight("enp7s0"),"TAILSCALE_SPLIT_REQUIRED") + def test_network_preflight_fails_on_other_vpn(self): + def fake(*args,**kw): + if args[:3]==("tailscale","status","--json"): + return SimpleNamespace(stdout=json.dumps({"BackendState":"Running","ExitNodeStatus":None})) + if args[:4]==("nmcli","-t","-f","TYPE,DEVICE"): + return SimpleNamespace(stdout="802-3-ethernet:enp7s0\nvpn:ppp0\ntun:tailscale0\n") + raise AssertionError("STOP_EXPECTED") + with patch.object(m,"call",side_effect=fake): + self.pass_if_reject(lambda:m.network_preflight("enp7s0"),"EXTRA_VPN_CONFLICT") + def test_network_preflight_fails_unrecognized_tun(self): + def fake(*args,**kw): + if args[:3]==("tailscale","status","--json"): + return SimpleNamespace(stdout=json.dumps({"BackendState":"Running","ExitNodeStatus":None})) + if args[:4]==("nmcli","-t","-f","TYPE,DEVICE"): + return SimpleNamespace(stdout="802-3-ethernet:enp7s0\ntun:wg0\ntun:tailscale0\n") + raise AssertionError("STOP_EXPECTED") + with patch.object(m,"call",side_effect=fake): + self.pass_if_reject(lambda:m.network_preflight("enp7s0"),"EXTRA_TUNNEL_CONFLICT") + def mock_runtime(self, extra:str="", route:str="default via 192.168.8.1 dev enp7s0", + exit_node:bool=False): + def fake(*args,**kw): + if args[:3]==("tailscale","status","--json"): + x={"BackendState":"Running","ExitNodeStatus":{"ID":"bad"} if exit_node else None} + return SimpleNamespace(stdout=json.dumps(x)) + if args[:4]==("nmcli","-t","-f","TYPE,DEVICE"): + return SimpleNamespace(stdout="802-3-ethernet:enp7s0\n"+ + ("vpn:ppp0\n" if extra=="seman" else + "tun:wg0\n" if extra=="wg" else "")+ + "tun:tailscale0\nloopback:lo\n") + if args[:5]==("ip","-4","route","show","default"): + return SimpleNamespace(stdout=route+"\n") + if args[:5]==("ip","-4","route","show","table") or \ + args[:5]==("ip","-6","route","show","table"): + return SimpleNamespace(stdout="") + raise AssertionError("UNEXPECTED_OR_MUTATING_HOST_COMMAND:"+repr(args)) + return fake + def test_runtime_guard_valid_split_tailscale(self): + with patch.object(m,"call",side_effect=self.mock_runtime()): + m.runtime_tunnel_guard("enp7s0") + def test_runtime_guard_rejects_live_seman_after_start(self): + with patch.object(m,"call",side_effect=self.mock_runtime(extra="seman")): + self.pass_if_reject(lambda:m.runtime_tunnel_guard("enp7s0"), + "RUNTIME_FOREIGN_VPN_CONFLICT") + def test_runtime_guard_rejects_extra_tunnel_after_start(self): + with patch.object(m,"call",side_effect=self.mock_runtime(extra="wg")): + self.pass_if_reject(lambda:m.runtime_tunnel_guard("enp7s0"), + "RUNTIME_FOREIGN_TUNNEL_CONFLICT") + def test_runtime_guard_rejects_exit_node_after_start(self): + with patch.object(m,"call",side_effect=self.mock_runtime(exit_node=True)): + self.pass_if_reject(lambda:m.runtime_tunnel_guard("enp7s0"), + "RUNTIME_TAILSCALE_EXIT_OR_DOWN") + def test_runtime_guard_rejects_changed_wan_after_start(self): + with patch.object(m,"call",side_effect=self.mock_runtime( + route="default via 100.100.100.100 dev tun0")): + self.pass_if_reject(lambda:m.runtime_tunnel_guard("enp7s0"), + "RUNTIME_DEFAULT_WAN_CHANGED") + def test_unit_state_rejects_foreign_service(self): + with patch.object(m,"call",side_effect=fail_if_called): + for foreign in ("tailscaled.service","ssh.service","dim-edsn-dns-0-deadbeef.service"): + self.pass_if_reject(lambda:m.unit_state(foreign),"UNIT_NAME_UNOWNED") + self.assertFalse(COMMANDS) + def test_dummy_identity_rejects_foreign(self): + # No root dummy is created: inject a controlled fake /sys/class/net. + fake=self.home/"sys"/m.IFACE + fake.mkdir(parents=True) + fake.joinpath("ifindex").write_text("7\n") + with patch.object(m.pathlib,"Path",wraps=Path) as patched_path: + # Avoid patching all pathlib internals. Exercise generic owner guard + # separately via monkey-patched call where dummy doesn't exist. + pass + def test_commit_state_missing_no_native_calls(self): + with patch.object(m,"root",return_value=None),patch.object(m,"read_state",return_value=None),patch.object(m,"call",side_effect=fail_if_called): + self.pass_if_reject(m.commit,"COMMIT_WITHOUT_ARMING") + self.assertFalse(COMMANDS) + def test_idempotent_restore_no_state_no_resources(self): + with patch.object(m,"root",return_value=None),patch.object(m,"read_state",return_value=None),patch.object(m,"CONFIG",self.home/"no_config"),patch.object(m,"IFACE","r517-no-device"),patch.object(m,"call",side_effect=fail_if_called): + self.assertEqual(m.rollback()["state"],"NO_SESSION") + self.assertFalse(COMMANDS) + def test_rollback_fails_on_unknown_config(self): + f=self.home/"foreign.toml" + f.write_text("not ours") + with patch.object(m,"root",return_value=None),patch.object(m,"read_state",return_value=None),patch.object(m,"CONFIG",f),patch.object(m,"IFACE","r517-no-device"),patch.object(m,"call",side_effect=fail_if_called): + self.pass_if_reject(m.rollback,"UNOWNED_OR_ORPHAN_CONF") + self.assertFalse(COMMANDS) + def test_consent_no_side_effects(self): + self.make_consent() + with patch.object(m,"call",side_effect=fail_if_called): + m.require_consent(self.uid,str(self.home)) + self.assertFalse(COMMANDS) + def test_action_lock_and_version_release_not_modified(self): + src=CONTROLLER.read_text() + for token in ("RevertLink","SetLinkDNSEx","SetLinkDomains","SetLinkDefaultRoute", + "NoNewPrivileges=yes","directinternetmethod-recovery.service", + "fcntl.LOCK_EX","I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1"): + self.assertIn(token,src) + self.assertNotIn("tailscale set",src) + self.assertNotIn("tailscale down",src) + self.assertNotIn("nmcli con down",src) + +if __name__=="__main__": + suite=unittest.defaultTestLoader.loadTestsFromTestCase(Contract) + result=unittest.TextTestRunner(verbosity=2).run(suite) + print(f"R517_NEGATIVE_FIRST_CONTROLLER_TESTS run={result.testsRun} failures={len(result.failures)} errors={len(result.errors)}") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/encrypted_selective_dns_fake_lifecycle.py b/tests/encrypted_selective_dns_fake_lifecycle.py new file mode 100644 index 00000000..3a34aca8 --- /dev/null +++ b/tests/encrypted_selective_dns_fake_lifecycle.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""R517 fake-root integration tests. All commands are captured and simulated. +No real systemd, D-Bus, NetworkManager, kernel link, NFT, DNS, or device access. +This tests lifecycle API state and owned cleanup, not real deployment. +""" +from __future__ import annotations +from contextlib import ExitStack,contextmanager +import importlib.util,json,os,pathlib,subprocess,tempfile,unittest +from unittest.mock import patch + +BASE=pathlib.Path(__file__).resolve().parents[1] +FILE=BASE/"linux/system/encrypted_selective_dns.py" +spec=importlib.util.spec_from_file_location("r517_root_mock",FILE) +assert spec and spec.loader +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) + +class FakeWorld: + def __init__(self,directory:pathlib.Path): + self.dir=directory + self.state_dir=directory/"state" + self.state=self.state_dir/"session.json" + self.config=directory/"ctrld.toml" + self.user_home=directory/"home" + self.native=self.user_home/".local/share/DirectInternetMethod/directmethod/state.json" + self.native.parent.mkdir(parents=True,exist_ok=True) + self.virtual_nfqws=directory/"owned-nfqws" + self.virtual_nfqws.write_bytes(b"FAKE_NOT_EXECUTED_BINARY") + self.record=[] + self.active={} + self.link_index=11 + self.tailscale_id="A"*64 + self.physical={"dns":["192.168.8.1"],"domains":[],"default":True} + self.local={"dns":[f"{m.IP}:{m.PORT}"],"domains":["~"+x for x in m.TARGETS],"default":False} + def pseudo_call(self,*args,**kwargs): + self.record.append(tuple(args)) + if args[0]=="systemd-run": + name=next((z[7:] for z in args if z.startswith("--unit=")),None) + assert name + self.active[name+".service" if not "--on-active="+str(m.BACKSTOP_SEC)+"s" in args else name+".timer"]=True + # Native controller creates timer with --on-active and --on-unit-active. + if any(x.startswith("--on-active=") for x in args): + self.active.pop(name+".service",None) + self.active[name+".timer"]=True + if args[:2]==("systemctl","stop"): + self.active[args[2]]=False + if args[:3]==("systemctl","show","directinternetmethod-nfqws-1000.service"): + if "ActiveState" in args:return subprocess.CompletedProcess(args,0,"active\n","") + if "MainPID" in args:return subprocess.CompletedProcess(args,0,"5454\n","") + if args[:2]==("systemctl","show") and "MainPID" in args: + return subprocess.CompletedProcess(args,0,"4567\n","") + if args[:3]==("tailscale","status","--json"): + return subprocess.CompletedProcess(args,0,json.dumps({"BackendState":"Running"}),"") + return subprocess.CompletedProcess(args,0,"","") + def unit_state(self,unit,timer=False): + assert (m.TIMER_RE if timer else m.UNIT_RE).fullmatch(unit) + return "active" if self.active.get(unit,False) else "inactive" + def link_state(self,iface): + if iface==m.IFACE:return dict(self.local) + if iface=="enp7s0":return dict(self.physical) + raise AssertionError("foreign interface "+iface) + def read_state(self): + return m.validate_state(json.loads(self.state.read_text())) if self.state.exists() else None + def patch_all(self): + st=ExitStack() + self.stack=st + def proxy_text(path,*args,**kwargs): + if str(path)=="/sys/class/net/dimesd0/ifindex":return str(self.link_index) + return self.orig_read_text(path,*args,**kwargs) + self.orig_read_text=pathlib.Path.read_text + real_stat=pathlib.Path.stat + def simulated_owner_stat(path,*args,**kwargs): + result=real_stat(path,*args,**kwargs) + if str(path) in (str(self.state_dir),str(self.config),str(self.state)): + # Root-owned test-private files only: virtual uid=0, no chown. + values=list(result) + values[4]=0 + return os.stat_result(values) + if str(path)==str(self.native): + # The native user State must belong to the simulated uid=1000, + # independently of the real GitHub Actions runner's uid. + # No host ownership or permissions are changed. + values=list(result) + values[4]=1000 + return os.stat_result(values) + return result + st.enter_context(patch.object(pathlib.Path,"stat",simulated_owner_stat)) + st.enter_context(patch.object(pathlib.Path,"read_text",proxy_text)) + real_realpath=os.path.realpath + def simulated_realpath(path,*args,**kwargs): + if str(path)=="/proc/4567/exe":return str(m.CTRLD) + if str(path)=="/proc/5454/exe":return str(self.virtual_nfqws) + return real_realpath(path,*args,**kwargs) + st.enter_context(patch.object(os.path,"realpath",simulated_realpath)) + for key,val in { + "STATE_DIR":self.state_dir, + "STATE":self.state, + "CONFIG":self.config, + "NFQWS":self.virtual_nfqws, + "file_sha":lambda p:m.VERSIONED_NFQWS_SHA if p==self.virtual_nfqws else "UNEXPECTED_FILE_HASH", + "root":lambda:None, + "require_consent":lambda uid,home:None, + "network_preflight":lambda phy:dict(self.physical), + "tailscale_fingerprint":lambda:self.tailscale_id, + "policy_bytes":lambda router:b"trusted-test-config", + "dummy_identity":lambda state:self.link_index if state["dummyIndex"] else None, + "dns_is_owned":lambda:True, + "public_dns":lambda name:True, + "link_dns":lambda idx:self.record.append(("LINK_DNS_VIRTUAL",idx)), + "unit_state":self.unit_state, + "call":self.pseudo_call, + "link_state":self.link_state, + "runtime_tunnel_guard":lambda phy:self.record.append(("VPN_GUARD_READONLY",phy)), + "read_state":self.read_state, + }.items():st.enter_context(patch.object(m,key,val)) + return st + def make_native(self): + self.native.write_text(json.dumps({ + "schema":3,"status":"ACTIVE","physicalInterface":"enp7s0", + "tailscaleCoexistence":True,"dnsMode":"system-preserved", + "selectiveDnsEncrypted":True,"selectiveDns":False, + "nfqwsUnit":"directinternetmethod-nfqws-1000.service", + "scope":"targeted","strategy":"balanced","nfqwsPid":5454 + })) + self.native.chmod(0o600) + +class FakeLifecycle(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory(dir=str(BASE),prefix="R517_FAKE_NO_HOST_") + self.world=FakeWorld(pathlib.Path(self.temp.name)) + self.stack=self.world.patch_all() + def tearDown(self): + self.stack.close() + self.temp.cleanup() + def test_owned_full_activate_commit_restore(self): + w=self.world + response=m.activate(1000,"enp7s0",str(w.user_home)) + self.assertEqual(response["state"],"ARMED") + active=w.read_state() + self.assertEqual(active["phase"],"ARMING") + self.assertTrue(w.state.is_file() and w.config.is_file()) + self.assertTrue(w.active[active["monitorUnit"]]) + self.assertTrue(w.active[active["backstopTimer"]]) + self.assertTrue(w.active[active["ctrldUnit"]]) + self.assertIn(("LINK_DNS_VIRTUAL",w.link_index),w.record) + # Before native helper creates state: commit must reject, remain ARMED. + with self.assertRaisesRegex(RuntimeError,"NATIVE_STATE_MISSING_OR_SYMLINK"):m.commit() + self.assertEqual(w.read_state()["phase"],"ARMING") + w.make_native() + committed=m.commit() + self.assertEqual(committed["state"],"ACTIVE") + self.assertEqual(m.status()["state"],"ACTIVE") + rollback=m.rollback() + self.assertEqual(rollback["state"],"RESTORED") + self.assertFalse(w.state.exists()) + self.assertFalse(w.config.exists()) + self.assertFalse(w.state_dir.exists()) + self.assertFalse(any(w.active.values())) + self.assertEqual(m.rollback()["state"],"NO_SESSION") + # No systemd or D-Bus call ran: everything was fake. + self.assertTrue(any(x[0]=="busctl" for x in w.record)) + self.assertTrue(any(x[0]=="systemctl" for x in w.record)) + def test_arm_failure_cleanup_before_link(self): + w=self.world + old=w.pseudo_call + def fail_on_add(*args,**kwargs): + if args[:3]==("ip","link","add"): + raise RuntimeError("SIMULATED_IP_LINK_ADD_DENIED") + return old(*args,**kwargs) + with patch.object(m,"call",side_effect=fail_on_add): + with self.assertRaisesRegex(RuntimeError,"SIMULATED_IP_LINK_ADD_DENIED"): + m.activate(1000,"enp7s0",str(w.user_home)) + self.assertFalse(w.state.exists(),"STATE_NOT_RESTORED") + self.assertFalse(w.config.exists(),"CONFIG_NOT_RESTORED") + self.assertFalse(any(w.active.values()),"OWNED_UNITS_STILL_RUNNING") + def test_state_phase_and_units_exclusive(self): + w=self.world + m.activate(1000,"enp7s0",str(w.user_home)) + raw=w.read_state() + for key in ("ctrldUnit","monitorUnit","backstopTimer"): + self.assertTrue(w.active[raw[key]]) + self.assertEqual(raw["physicalBaseline"],w.physical) + self.assertEqual(raw["tailscaleDNS"],"A"*64) + self.assertEqual(raw["configSha"],m.sha(b"trusted-test-config")) + self.assertEqual(raw["dummyIndex"],11) + def test_native_commit_rejects_forged_user_pid(self): + w=self.world + m.activate(1000,"enp7s0",str(w.user_home)) + w.make_native() + doc=json.loads(w.native.read_text()) + doc["nfqwsPid"]=77 + w.native.write_text(json.dumps(doc)) + w.native.chmod(0o600) + with self.assertRaisesRegex(RuntimeError,"NATIVE_NFQWS_PID_MISMATCH"): + m.commit() + self.assertEqual(w.read_state()["phase"],"ARMING") + def test_native_commit_rejects_user_state_symlink(self): + w=self.world + m.activate(1000,"enp7s0",str(w.user_home)) + w.make_native() + target=w.native.with_name("foreign-native.json") + w.native.rename(target) + w.native.symlink_to(target) + with self.assertRaisesRegex(RuntimeError,"NATIVE_STATE_MISSING_OR_SYMLINK"): + m.commit() + self.assertEqual(w.read_state()["phase"],"ARMING") + def test_owned_monitor_backstop_healthy(self): + w=self.world + m.activate(1000,"enp7s0",str(w.user_home)) + w.make_native();m.commit() + @contextmanager + def free_lock(): + yield True + with patch.object(m,"observe_action_lock",free_lock): + self.assertEqual(m.backstop()["state"],"HEALTHY") + self.assertTrue(w.state.exists()) + self.assertTrue(w.active[w.read_state()["ctrldUnit"]]) + def test_failed_foreign_dns_restoration_guard(self): + w=self.world + m.activate(1000,"enp7s0",str(w.user_home)) + # The system DNS on the owned per-link interface was changed after + # activation. Never overwrite foreign settings or remove identity. + w.local={"dns":["8.8.8.8"],"domains":["~other.com"],"default":True} + with patch.object(m,"dns_is_owned",return_value=False): + with self.assertRaisesRegex(RuntimeError,"FOREIGN_LINK_DNS_PRESENT"): + m.rollback() + self.assertTrue(w.state.is_file()) + self.assertTrue(w.active[w.read_state()["backstopTimer"]]) + def test_stop_only_own_native_binary(self): + w=self.world + m.activate(1000,"enp7s0",str(w.user_home)) + # The mock controller must not kill other systemd services. + previous_realpath=m.os.path.realpath + def hostile_realpath(p,*a,**kw): + if str(p)=="/proc/4567/exe":return "/usr/bin/foreign-binary" + return previous_realpath(p,*a,**kw) + with patch.object(m.os.path,"realpath",side_effect=hostile_realpath): + with self.assertRaisesRegex(RuntimeError,"OWNED_CTRLD_PROCESS_MISMATCH"): + m.rollback() + self.assertTrue(w.state.exists()) + +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(FakeLifecycle)) + print(f"R517_FAKE_NATIVE_LIFECYCLE cases={result.testsRun} failures={len(result.failures)} errors={len(result.errors)}") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/encrypted_selective_dns_gui_consent_contract.py b/tests/encrypted_selective_dns_gui_consent_contract.py new file mode 100644 index 00000000..011e7742 --- /dev/null +++ b/tests/encrypted_selective_dns_gui_consent_contract.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""R517 isolated GUI consent tests. AST-only; no GI, network, or user settings.""" +from __future__ import annotations +import ast +import os +import pathlib +import tempfile +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +APP=ROOT/"linux/app/direct_internet_method.py" +tree=ast.parse(APP.read_text("utf8")) +names=("selective_dns_optin_state","selective_dns_optin_save", + "encrypted_dns_optin_state","encrypted_dns_optin_save") +funcs=[n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name in names] +assert sorted(x.name for x in funcs)==sorted(names) + +class Consent(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="R517_optin_",dir=str(ROOT)) + self.folder=pathlib.Path(self.tmp.name) + self.old=self.folder/"selective-dns-optin.txt" + self.new=self.folder/"encrypted-dns-optin.txt" + self.ns={ + "os":os,"pathlib":pathlib,"tempfile":tempfile, + "SELECTIVE_DNS_OPT_IN":self.old, + "SELECTIVE_DNS_TOKEN":"I_ACCEPT_UNENCRYPTED_SELECTIVE_DNS_V1", + "ENCRYPTED_DNS_OPT_IN":self.new, + "ENCRYPTED_DNS_TOKEN":"I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1", + } + exec(compile(ast.Module(body=funcs,type_ignores=[]),"", "exec"),self.ns) + self.old_status=self.ns["selective_dns_optin_state"] + self.old_save=self.ns["selective_dns_optin_save"] + self.new_status=self.ns["encrypted_dns_optin_state"] + self.new_save=self.ns["encrypted_dns_optin_save"] + def tearDown(self):self.tmp.cleanup() + def test_default_off(self): + self.assertEqual(self.new_status(),"OFF") + self.assertEqual(self.old_status(),"OFF") + def test_new_consent_exact_and_0600(self): + self.new_save(True) + self.assertEqual(self.new.read_text(),"I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1\n") + self.assertEqual(self.new.stat().st_mode & 0o777,0o600) + self.assertEqual(self.new_status(),"ON") + self.assertEqual(self.old_status(),"OFF") + def test_disable_preserves_foreign_other_file(self): + unrelated=self.folder/"personal.txt" + unrelated.write_text("preserve unrelated contents") + self.new_save(True) + self.new_save(False) + self.assertFalse(self.new.exists()) + self.assertEqual(unrelated.read_text(),"preserve unrelated contents") + self.assertEqual(self.new_status(),"OFF") + def test_old_unencrypted_consent_cannot_enable_new(self): + self.old_save(True) + self.assertEqual(self.old_status(),"ON") + self.assertEqual(self.new_status(),"OFF") + with self.assertRaisesRegex(ValueError,"Unencrypted DNS consent"): + self.new_save(True) + self.assertFalse(self.new.exists()) + def test_disable_old_then_new_requires_explicit(self): + self.old_save(True) + self.old_save(False) + self.assertEqual(self.new_status(),"OFF") + self.new_save(True) + self.assertEqual(self.new_status(),"ON") + self.assertFalse(self.old.exists()) + def test_unknown_token_invalid(self): + self.new.write_text("I_ACCEPT_ANYTHING\n") + self.new.chmod(0o600) + self.assertEqual(self.new_status(),"INVALID") + def test_new_consent_must_not_be_world_readable(self): + self.new_save(True) + self.new.chmod(0o644) + self.assertEqual(self.new_status(),"INVALID") + def test_symlink_denied_and_target_unchanged(self): + target=self.folder/"unrelated.json" + target.write_text('{"secret":"do-not-change"}') + self.new.symlink_to(target) + self.assertEqual(self.new_status(),"INVALID") + with self.assertRaisesRegex(ValueError,"symlink"): + self.new_save(True) + with self.assertRaisesRegex(ValueError,"symlink"): + self.new_save(False) + self.assertEqual(target.read_text(),'{"secret":"do-not-change"}') + def test_conflicting_consent_invalidates_new_status(self): + self.new_save(True) + self.old_save(True) + self.assertEqual(self.new_status(),"INVALID") + self.assertEqual(self.old_status(),"ON") + def test_legacy_optin_token_not_reused(self): + self.new.write_text("I_ACCEPT_UNENCRYPTED_SELECTIVE_DNS_V1\n") + self.new.chmod(0o600) + self.assertEqual(self.new_status(),"INVALID") + def test_gui_static_has_independent_toggle(self): + text=APP.read_text("utf8") + for term in ("encrypted_toggle=Gtk.CheckButton", + "enable_encrypted=bool(encrypted_toggle.get_active())", + "selective_dns_optin_save(enable_dns)", + "encrypted_dns_optin_save(enable_encrypted)", + "Choose exactly one DNS option"): + self.assertIn(term,text) + def test_consent_is_not_controller_action(self): + src=ast.get_source_segment(APP.read_text("utf8"), + next(x for x in funcs if x.name=="encrypted_dns_optin_save")) + self.assertTrue(src) + for forbidden in ("subprocess","systemctl","busctl","resolvectl","tailscale down","ip link"): + self.assertNotIn(forbidden,src) + +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Consent)) + print(f"R517_GUI_DISTINCT_ENCRYPTED_CONSENT cases={result.testsRun} failed={len(result.failures)} errors={len(result.errors)}") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/encrypted_selective_dns_integration_contract.py b/tests/encrypted_selective_dns_integration_contract.py new file mode 100644 index 00000000..88d48aab --- /dev/null +++ b/tests/encrypted_selective_dns_integration_contract.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""R517 source-only integration contract. No native service/network mutations.""" +from pathlib import Path +import ast,re,unittest,json +R=Path(__file__).resolve().parents[1] +H=(R/"linux/app/direct_method_helper.sh").read_text() +G=(R/"linux/app/direct_internet_method.py").read_text() +I=(R/"linux/system/install_system.sh").read_text() +U=(R/"linux/system/uninstall_system.sh").read_text() +B=(R/"build_linux_package.py").read_text() +CONTROLLER=(R/"linux/system/encrypted_selective_dns.py").read_text() +POLICY=(R/"linux/system/encrypted_selective_policy.py").read_text() +class Contract(unittest.TestCase): + def test_helper_uses_exact_existing_action_lock(self): + self.assertIn('lock_path="/run/directinternetmethod-action.lock"',H) + self.assertIn('acquire_action_lock',H) + self.assertIn('flock -n',H) + def test_distinct_dns_optin(self): + self.assertIn('ENCRYPTED_DNS_OPT_IN="$STATE_BASE/encrypted-dns-optin.txt"',H) + self.assertIn('SELECTIVE_DNS_OPT_IN="$STATE_BASE/selective-dns-optin.txt"',H) + self.assertIn('ENCRYPTED_DNS_ENABLED=0',H) + self.assertIn('ENCRYPTED_AND_UNENCRYPTED_OPTIN_CONFLICT',H) + def test_encrypted_activation_only_safe_split_and_profile(self): + a=H.index('if [ -e "$ENCRYPTED_DNS_OPT_IN" ]') + b=H.index('ENCRYPTED_DNS_ENABLED=1',a) + scope=H[a:b] + for part in ('"$TAILSCALE_COEXIST" -eq 1','"$STRATEGY" = "balanced"', + '"$SCOPE" = "targeted"','-x "$ENCRYPTED_DNS"'): + self.assertIn(part,scope) + def test_unknown_consent_must_be_validated_by_native_controller(self): + self.assertIn('require_consent(uid,home)',CONTROLLER) + self.assertIn('I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1',CONTROLLER) + self.assertIn('LEGACY_UNENCRYPTED_OPTIN_CONFLICT',CONTROLLER) + def test_active_native_dpi_attested_before_encrypted_commit(self): + self.assertIn('native_engine_attested(s)',CONTROLLER) + self.assertIn('"selectiveDnsEncrypted":bool(int(sys.argv[16]))',H) + self.assertIn('ENCRYPTED_DNS_COMMIT_FAILED',H) + a=H.index('python3 "$ENCRYPTED_DNS" activate') + b=H.index('python3 "$ENCRYPTED_DNS" commit') + self.assertLess(a,b) + state=H.index('CREATED_STATE=1',a) + self.assertLess(state,b) + def test_failure_restores_encrypted_before_nft_owned_deletion(self): + first=H.index('cleanup_transient(){') + nextfn=H.index('cleanup_state_owned(){',first) + sub=H[first:nextfn] + self.assertLess(sub.index('restore_selective_dns || return 88'),sub.index('nft delete table inet')) + def test_native_stop_reverts_before_killing_engines(self): + a=H.index('cleanup_state_owned(){') + b=H.index('restore_selective_dns(){',a) if 'restore_selective_dns(){' in H[a:] else H.index('verify_clean(){',a) + sub=H[a:b] + self.assertIn('restore_selective_dns || return 88',sub) + self.assertLess(sub.index('restore_selective_dns || return 88'),sub.index('kill "$np"')) + def test_encrypted_cleanup_detects_unknown_or_stale_resources(self): + for text in ('/run/directinternetmethod-encrypted/session.json', + '/run/directinternetmethod-encrypted-ctrld.toml', + 'ip link show dimesd0','ENCRYPTED_DNS_UNOWNED_RESIDUE'): + self.assertIn(text,H) + self.assertIn('ENCRYPTED_DNS_RESTORE_FAILED',H) + def test_native_verify_clean_enforces_no_owned_or_orphan_resources(self): + a=H.index('verify_clean(){') + b=H.index('status(){',a) + cleanup=H[a:b] + for text in ('/run/directinternetmethod-encrypted', '/run/directinternetmethod-encrypted-ctrld.toml', + 'ip link show dimesd0'): + self.assertIn(text,cleanup) + def test_existing_already_active_checks_encrypted_health(self): + self.assertIn('encrypted_dns_ready || DNS_HEALTHY=0',H) + self.assertIn('encrypted_dns_ready || dns_ok=false',H) + def test_r517_protected_install_requires_inactive_resources(self): + for text in ('Stop Direct Internet Method before protected backend update', + '/run/directinternetmethod-encrypted','/sys/class/net/dimesd0', + 'Incomplete candidate encrypted backend'): + self.assertIn(text,I) + def test_r517_installer_copies_both_owned_scripts(self): + for text in ('"$SRC/system/encrypted_selective_dns.py"', + '"$SRC/system/encrypted_selective_policy.py"', + '"$ROOT/encrypted_selective_dns.py"', + '"$ROOT/encrypted_selective_policy.py"'): + self.assertIn(text,I) + def test_uninstall_reverts_with_guard_before_removing_binaries(self): + a=U.index('encrypted_selective_dns.py restore') + b=U.index('rm -rf /usr/lib/directinternetmethod') + self.assertLess(a,b) + self.assertIn('Encrypted DNS resource remains',U) + def test_gui_separate_privacy_choice_no_consent_inheritance(self): + for text in ('ENCRYPTED_DNS_TOKEN="I_ACCEPT_ENCRYPTED_SELECTIVE_DNS_V1"', + 'encrypted_toggle=Gtk.CheckButton', + 'enable_encrypted=bool(encrypted_toggle.get_active())', + 'Choose exactly one DNS option', + 'encrypted_dns_optin_save(enable_encrypted)'): + self.assertIn(text,G) + def test_controller_fail_closed_sem_an_after_activation(self): + for text in ('RUNTIME_FOREIGN_VPN_CONFLICT','RUNTIME_FOREIGN_TUNNEL_CONFLICT', + 'RUNTIME_TAILSCALE_EXIT_OR_DOWN','RUNTIME_DEFAULT_WAN_CHANGED'): + self.assertIn(text,CONTROLLER) + self.assertGreaterEqual(CONTROLLER.count('runtime_tunnel_guard('),4) + def test_controller_perlink_dns_route_only_no_system_wide(self): + for text in ('SetLinkDNSEx','SetLinkDomains','SetLinkDefaultRoute','RevertLink'): + self.assertIn(text,CONTROLLER) + self.assertIn('OWNED_DNS_IPV4 = "172.31.254.59"',POLICY) + self.assertNotIn('resolvectl dns enp7s0 127.0.0.1',CONTROLLER) + self.assertNotIn('tailscale down',CONTROLLER) + def test_controller_checks_root_owned_process_sha_before_commit(self): + for text in ('VERSIONED_NFQWS_SHA','NATIVE_NFQWS_BINARY_SHA', + 'NATIVE_NFQWS_PID_MISMATCH','NATIVE_NFQWS_PROCESS_FOREIGN'): + self.assertIn(text,CONTROLLER) + def test_r519_candidate_is_explicit_unreleased_versioned_bundle(self): + self.assertIn('DirectInternetMethod_1.5.7_Linux_x86_64.zip',B) + self.assertIn('linux/system/encrypted_selective_dns.py',B) + self.assertIn('linux/system/encrypted_selective_policy.py',B) + self.assertIn('linux/system/protected_upgrade_transaction.py',B) + self.assertNotIn('DirectInternetMethod_1.5.4_Linux_x86_64.zip',B) + def test_no_mutating_commands_in_source_contract(self): + tree=ast.parse(Path(__file__).read_text()) + for node in ast.walk(tree): + if isinstance(node,(ast.Import,ast.ImportFrom)): + names=([a.name for a in node.names] if isinstance(node,ast.Import) + else [node.module or ""]) + self.assertFalse(any(x.startswith(("subprocess","socket")) for x in names)) + if isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute): + if isinstance(node.func.value,ast.Name): + self.assertNotIn((node.func.value.id,node.func.attr), + (("os","system"),("subprocess","run"),("subprocess","Popen"))) +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Contract)) + print(f"R517_NATIVE_SOURCE_INTEGRATION_CONTRACT cases={result.testsRun} failures={len(result.failures)} errors={len(result.errors)}") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/encrypted_selective_dns_policy_contract.py b/tests/encrypted_selective_dns_policy_contract.py new file mode 100644 index 00000000..91834984 --- /dev/null +++ b/tests/encrypted_selective_dns_policy_contract.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""R516 negative-first proof of the pure encrypted selective-DNS policy. + +Do not change system DNS, Tailscale, ctrld processes or product files. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import pathlib +import tomllib + +ROOT = pathlib.Path(__file__).resolve().parents[1] +POLICY = ROOT / "linux/system/encrypted_selective_policy.py" +spec = importlib.util.spec_from_file_location("dim_r516_secure_doh", POLICY) +assert spec and spec.loader +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +passed = 0 + + +def check(label: str, condition: bool) -> None: + global passed + assert condition, label + passed += 1 + print("PASS_R516", label) + + +invalid_dns = [ + None, 123, "", " ", "192.168.8.1 ", " 192.168.8.1", + "192.168.8.1\n[listener.9]", "192.168.8.256", "fe80::1", + "127.0.0.1", "169.254.1.1", "224.0.0.1", "0.0.0.0", + "8.8.8.8", "76.76.10.11", "100.100.100.100", + "255.255.255.255", "192.0.2.1", +] +for value in invalid_dns: + try: + module.build_secure_targeted_dns_config(value, 53534) + except (ValueError, TypeError): + check("reject_invalid_physical_dns_" + repr(value), True) + else: + raise AssertionError("UNSAFE_DNS_ACCEPTED " + repr(value)) + +for port in (None, False, True, "53534", 0, 53, 1023, -1, 65536, 99999, 3.14): + try: + module.build_secure_targeted_dns_config("192.168.8.1", port) + except (ValueError, TypeError): + check("reject_invalid_listener_port_" + repr(port), True) + else: + raise AssertionError("UNSAFE_PORT_ACCEPTED " + repr(port)) + +body = module.build_secure_targeted_dns_config("192.168.8.1", 53534) +j = tomllib.loads(body.decode("ascii")) +check("exact_validated_R516_runtime_config_SHA", hashlib.sha256(body).hexdigest().lower() == + "46f63805fe53b5cedc6b7d4df242bbf13a6ca8229bb9072058be8de304697954") +check("exact_second_independent_Emad_config_SHA", hashlib.sha256( + module.build_secure_targeted_dns_config("192.168.20.1", 53535)).hexdigest().lower() == + "3cc90962571ac80220b1a947c901709c64cd0da0bd63ee5beb6d1ec8781615dc") +check("doh_tls_pinned_endpoint", j["upstream"]["0"]["endpoint"] == + "https://76.76.10.11/p0" and j["upstream"]["0"]["type"] == "doh") +check("bootstrap_ip_and_ipv4_only", j["upstream"]["0"]["bootstrap_ip"] == "76.76.10.11" + and j["upstream"]["0"]["ip_stack"] == "v4") +check("physical_fallback_only_router", j["upstream"]["1"]["endpoint"] == + "192.168.8.1" and j["upstream"]["1"]["type"] == "legacy") +check("listener_loopback_only", j["listener"]["0"]["ip"] == "127.0.0.1" and + j["listener"]["0"]["port"] == 53534) +check("listener_no_wan", j["listener"]["0"]["allow_wan_clients"] is False) +check("no_discovery_listeners", all(j["service"][name] is False for name in + ("discover_mdns", "discover_arp", "discover_dhcp", "discover_ptr", "discover_hosts"))) +check("no_leak_on_upstream_failure", j["service"]["leak_on_upstream_failure"] is False) +rules = j["listener"]["0"]["policy"]["rules"] +check("exact_target_domain_rules", len(rules) == 2 * len(module.TARGET_DOMAINS) + and all(list(item.values()) == [["upstream.0"]] for item in rules)) +check("target_names_and_subdomains", [list(item.keys())[0] for item in rules] == + [z for name in module.TARGET_DOMAINS for z in (name, "*." + name)]) +check("default_fallback_unchanged_physical_dns", j["listener"]["0"]["policy"]["networks"] == + [{"network.0": ["upstream.1"]}]) +check("no_unencrypted_third_party_upstream", b"194.225.152.10" not in body) +check("no_global_wildcard_catchall_rules", not any("*" == list(item)[0] for item in rules)) +check("valid_other_private_router", b'endpoint = "10.0.0.1"' in + module.build_secure_targeted_dns_config("10.0.0.1")) +check("valid_other_private_router_172", b'endpoint = "172.16.1.1"' in + module.build_secure_targeted_dns_config("172.16.1.1")) +source = POLICY.read_text("utf-8") +check("pure_module_no_root_or_network_actions", + not any(token in source for token in + ('import subprocess', 'import os', 'systemctl start', 'resolvectl dns', + 'nft add', 'ip route add', 'socket.connect'))) +print(f"R516_NEGATIVE_FIRST_OFFLINE_PASS_{passed}_OF_{passed}_NO_HOST_MUTATIONS") diff --git a/tests/fixtures/r519_original/PACKAGE_MANIFEST.json b/tests/fixtures/r519_original/PACKAGE_MANIFEST.json new file mode 100644 index 00000000..171ffac0 --- /dev/null +++ b/tests/fixtures/r519_original/PACKAGE_MANIFEST.json @@ -0,0 +1,138 @@ +{ + "schema": 1, + "product": "Direct Internet Method", + "version": "1.5.5-rc1", + "platform": "linux-x86_64", + "files": [ + { + "file": "README.md", + "bytes": 2277, + "sha256": "2E2D817FD4728903E7085C4D7D02786107206881DA47FE4DFA6AA3C6F4E4AF76" + }, + { + "file": "install.sh", + "bytes": 12035, + "sha256": "4970935FC3A8B886D7DE3C94C7F15A1F4D132348139BC1996B182334EBF99C1F" + }, + { + "file": "uninstall.sh", + "bytes": 2011, + "sha256": "FAB9EF8FFD12E4102F72D227DD72FD0703944759667E31C8CF04995282C72E43" + }, + { + "file": "system/install_system.sh", + "bytes": 5109, + "sha256": "5C37143358CF9C85B36877DCD2CD454AC8D7FF5A0DB589EA1B96DD69B2F144A8" + }, + { + "file": "system/control.sh", + "bytes": 799, + "sha256": "10BACBC871E5CEA1B109A82130AE2E1C490BC26E4E1CE91CACC6D893BE2C0A49" + }, + { + "file": "system/selective_dns.py", + "bytes": 22253, + "sha256": "9701D1C0D03499F24678CF31E9FE0B68015EFA1EF3CA9E3653035B0B27727D65" + }, + { + "file": "system/encrypted_selective_dns.py", + "bytes": 25600, + "sha256": "6DA9BAB94E1F4AF5C9FBE0742F96B3E2B5A0392294B27EDA3F4012095D9993DA" + }, + { + "file": "system/encrypted_selective_policy.py", + "bytes": 4263, + "sha256": "BCADC01C4A0DEDA0AB7C678155BE6F6960D3B68D9CFE1307DF85A94F18785663" + }, + { + "file": "system/protected_upgrade_transaction.py", + "bytes": 19091, + "sha256": "4CE00525AF4773FE25CB7E3E5EDFD661B6A30C6B677964E12F24BCA6650F328D" + }, + { + "file": "system/upgrade_system.sh", + "bytes": 2202, + "sha256": "8246D48A5CB0E812E370E7883F3EFEE39A9F44931DE1B054F3FC62A6FC067A09" + }, + { + "file": "system/uninstall_system.sh", + "bytes": 1317, + "sha256": "AB8E24FD468B7DEEBAC2F5CC4D7D5C309EDCB7E66B4F414BC9FB3FF97906F530" + }, + { + "file": "app/direct_internet_method.py", + "bytes": 48905, + "sha256": "A342342D2982701E3379530BACEED6B068BC90735289D3C31860830ECDC1275F" + }, + { + "file": "app/router_gateway.py", + "bytes": 5914, + "sha256": "936376D29F05B0E8B37FA6BD8A4CDB3F3160463E138EE4ABAB43E18AECFC1C86" + }, + { + "file": "app/router_gateway_core.py", + "bytes": 3351, + "sha256": "7817DD0BAE1E42D82A85FCCE823E872D30CC4A2E95D515D0C1540A29D7DF4A90" + }, + { + "file": "app/user_update_transaction.py", + "bytes": 27787, + "sha256": "A909BBB12B78875A9438C756AE2DCAFF05E0DF2EBB5551E97544D2B20C46346F" + }, + { + "file": "app/user_uninstall_preserve.py", + "bytes": 5150, + "sha256": "9A4A4C37E3F63F1602E15454100150D209E3CD099DE80504B560F31D42D3C0EF" + }, + { + "file": "app/router_gateway/providers.json", + "bytes": 6912, + "sha256": "EB9AF7A7BA94B068CE4C2930E61DAF0E7409106B11EE1546F46E33DA0B037A19" + }, + { + "file": "app/direct_method_helper.sh", + "bytes": 34771, + "sha256": "B6641270D1ED8687A2FB1331016F212533BFD4E490331213FF24684D2999A397" + }, + { + "file": "app/hosts.txt", + "bytes": 269, + "sha256": "F9B2E12B4B77CAAD86FA15A14AC546A4611527280E4A0ECE5566EB1FF1B07184" + }, + { + "file": "app/adult_hosts_fallback.txt", + "bytes": 448, + "sha256": "25BA72F6D56EF1C405115C7AA5D6ED1985E8F6D56F306A95D5AD800602A75288" + }, + { + "file": "app/strong_override_hosts.txt", + "bytes": 40, + "sha256": "A706F1035230EDDFB1EA5037A4234AEA351018265F2B359DA4C9E1347A3A900A" + }, + { + "file": "app/direct-internet-method.svg", + "bytes": 1275, + "sha256": "305A2FA1F5310F9619110E805194846FD191FBE7C90843D3BF7492D8C1D155C3" + }, + { + "file": "runtime/ctrld", + "bytes": 7961828, + "sha256": "CA64579A2BC866CF72F3E46663E551162AFEFF0D448E899A01817A095E45B071" + }, + { + "file": "runtime/nfqws", + "bytes": 125760, + "sha256": "F34615964D7321650197CD69D3F7CBFDAABE8118B5A0D57C6E41DCCDFF658999" + }, + { + "file": "licenses/LICENSE-ctrld.txt", + "bytes": 1101, + "sha256": "55D82D13FE55D4B2AEC42166FCCD3C16C267B7131ECF2FBD7C3C6C3463494501" + }, + { + "file": "licenses/LICENSE-zapret.txt", + "bytes": 1069, + "sha256": "DCF5ABD3E5D876C1065982871C0CEC7368C0E61FC795C541798729516BB6B54F" + } + ] +} diff --git a/tests/fixtures/r519_original/user_uninstall_preserve.py b/tests/fixtures/r519_original/user_uninstall_preserve.py new file mode 100644 index 00000000..c881bf4f --- /dev/null +++ b/tests/fixtures/r519_original/user_uninstall_preserve.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Fail-closed Direct Internet Method uninstall of owned program files ONLY. + +Protected backend removal must finish first. Never remove user settings, +custom hosts, adult coverage state, scoped strategy, router providers or logs. +Never recursively delete the mixed application/data directory. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import pathlib +import stat +import sys + +CODE_NAMES = frozenset({ + "direct_internet_method.py", "router_gateway.py", "router_gateway_core.py", + "direct-internet-method.svg", "uninstall.sh", "user_uninstall_preserve.py", + "user_update_transaction.py", +}) +EXTERNAL_NAMES = frozenset({ + ".local/bin/direct-internet-method", + ".local/bin/direct-internet-method-uninstall", + ".local/share/applications/io.github.god13emad.DirectInternetMethod.desktop", + ".local/share/icons/hicolor/scalable/apps/direct-internet-method.svg", +}) +APPNAME = "DirectInternetMethod" + + +def ensure(ok, error): + if not ok: + raise RuntimeError(error) + + +def sha(path): + h = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1 << 20), b""): + h.update(block) + return h.hexdigest() + + +def load_owned(): + home = pathlib.Path.home() + app = home / ".local/share" / APPNAME + ensure(os.geteuid() != 0 and not app.is_symlink() and app.is_dir(), + "UNINSTALL_OWNER_HOME_INVALID") + meta = app / "INSTALL.json" + ensure(meta.is_file() and not meta.is_symlink(), "UNINSTALL_INSTALL_METADATA_MISSING") + doc = json.loads(meta.read_text(encoding="utf8")) + ensure(doc.get("schema") == 2 and doc.get("version") == "1.5.4", + "UNINSTALL_MANIFEST_OWNER_VERSION_UNVERIFIED") + codes = doc.get("managedCodeSha256") + launchers = doc.get("managedLaunchersSha256") + ensure(isinstance(codes, dict) and set(codes) <= CODE_NAMES and + set(CODE_NAMES - {"user_uninstall_preserve.py"}) <= set(codes), + "UNINSTALL_MANAGED_CODE_MANIFEST_UNKNOWN") + ensure(isinstance(launchers, dict) and set(launchers) <= EXTERNAL_NAMES, + "UNINSTALL_EXTERNAL_SHORTCUTS_MANIFEST_UNKNOWN") + tracked = {} + for rel, expected in codes.items(): + p = app / rel + ensure(p.is_file() and not p.is_symlink() and not p.parent.is_symlink() and + p.stat().st_uid == os.getuid() and sha(p) == expected, + "UNINSTALL_CODE_FOREIGN_CHANGE_PRESERVED:" + rel) + tracked[str(p)] = (p, expected) + for rel, expected in launchers.items(): + p = home / rel + ensure(p.is_file() and not p.is_symlink() and not p.parent.is_symlink() and + p.stat().st_uid == os.getuid() and sha(p) == expected, + "UNINSTALL_SHORTCUT_FOREIGN_CHANGE_PRESERVED:" + rel) + tracked[str(p)] = (p, expected) + # Any metadata altered by other software is not destroyed without an exact + # owner check. The program metadata itself is not personal configuration. + ensure(meta.stat().st_uid == os.getuid(), "INSTALL_METADATA_FOREIGN_OWNER") + return app, meta, tracked + + +def verify(): + app, meta, tracked = load_owned() + print(json.dumps({"status": "PASS_OWNED_FILES_IDENTIFIED_NO_MUTATION", + "managedExecutableFiles": len(tracked), + "userSettingsPreserved": True, + "externalRootRequiresRemovalFirst": True})) + + +def remove_known_files(app, meta, tracked): + """Pure owned-file step, callable with a disposable HOME in tests. + Production only calls it after independently verified backend removal.""" + for path, expected in tracked.values(): + ensure(path.is_file() and not path.is_symlink() and sha(path) == expected, + "PROGRAM_FILE_CHANGED_AFTER_PRECHECK:" + str(path)) + for path, _ in tracked.values(): + path.unlink() + # User data remains, including mutable router providers and runtime logs. + meta.unlink() + return len(tracked) + + +def remove_after_backend(): + app, meta, files = load_owned() + root = pathlib.Path("/usr/lib/directinternetmethod") + record = pathlib.Path("/var/lib/directinternetmethod/install.json") + ensure(not (root / "control.sh").exists() and not record.exists(), + "PROTECTED_BACKEND_STILL_PRESENT_REFUSE_USER_CODE_REMOVAL") + count = remove_known_files(app, meta, files) + print(json.dumps({"status": "PASS_OWNED_PROGRAM_FILES_REMOVED", + "userDataDirectoryPreserved": str(app), + "userSettingsDeleted": False, + "onlyOwnedFilesTouched": True})) + + +def main(): + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--verify", action="store_true") + group.add_argument("--remove-after-backend", action="store_true") + args = parser.parse_args() + if args.verify: + verify() + else: + remove_after_backend() + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print("DIM_UNINSTALL_FAIL_CLOSED:" + str(exc), file=sys.stderr) + sys.exit(86) diff --git a/tests/linux_contract_audit.py b/tests/linux_contract_audit.py index c1e2dfec..70aa5d41 100644 --- a/tests/linux_contract_audit.py +++ b/tests/linux_contract_audit.py @@ -21,7 +21,12 @@ def check(name,cond): D["checks"][name]=bool(cond) if not cond:D["status"]="FAIL" -check("version_154_backend_compatible", 'VERSION="1.5.4"' in ui and '"version":"1.5.4"' in install and '"version":"1.5.4"' in sysinstall and 'd.get("version") in ("1.5.1","1.5.2","1.5.3","1.5.4")' in install) +check("version_155rc1_exact_update_with_accepted_stable_baseline", + 'VERSION="1.5.5-rc1"' in ui and + '"version":"1.5.5-rc1"' in sysinstall and + 'd.get("version")=="1.5.5-rc1"' in install and + 'CURRENT_BACKEND_VERSION="1.5.2"' in txt("system/protected_upgrade_transaction.py") and + 'OLD_USER="1.5.4"' in txt("system/upgrade_guard.py")) check("no_default_route_mutation", not re.search(r'ip\s+route\s+(add|del|replace).*default|nmcli\s+.*ipv4\.gateway',helper,re.I)) check("no_unconditional_physical_dns_mutation", "nmcli connection modify" not in helper and "resolvectl dns enp" not in helper and "resolvectl dns eth" not in helper and @@ -110,8 +115,11 @@ def check(name,cond): check("ui_live_verify_rollback", 'LIVE_VERIFY_FAILED_ROLLED_BACK' in ui and 'run_system_action("stop")' in ui) check("ui_fixed_systemd_actions", 'unit=f"directinternetmethod-{action}.service"' in ui and 'subprocess.run(["systemctl","start",unit]' in ui) check("ui_multiprotocol_label", "Encrypted DNS (DoH) + HTTP/TLS/QUIC DPI bypass" in ui) -check("ui_online_update_hash_gate", "SHA256SUMS.txt" in ui and "hashlib.sha256" in ui and "Unsafe update archive path." in ui) -check("ui_online_update_three_way_digest_gate", all(x in ui for x in ("GitHub release asset digest is missing/invalid","SHA256SUMS.txt must contain exactly one valid checksum","Linux update checksum disagrees with GitHub asset digest","three-way SHA-256 verification","expected_linux=f\"DirectInternetMethod_{release_version}_Linux_x86_64.zip\""))) +check("ui_online_update_hash_gate", all(x in ui for x in ( + "SHA256SUMS.txt", "hashlib.sha256", "Unsafe Linux update release version.", + "Update cache path contains a symlink.", + "Update cache is not owned by the current user."))) +check("ui_online_update_three_way_digest_gate", all(x in ui for x in ("GitHub release asset digest is missing/invalid","SHA256SUMS.txt must contain exactly one Linux update checksum","Linux update checksum disagrees with GitHub asset digest","three-way SHA-256 verification","expected_linux=f\"DirectInternetMethod_{release_version}_Linux_x86_64.zip\""))) check("target_hostlist_scope", "^gemini.google.com" in hostlist.splitlines() and all(x not in hostlist.splitlines() for x in ("pornhub.com","phncdn.com","xvideos.com","xnxx.com","xhamster.com")) and all(x in adult_fallback.splitlines() for x in ("pornhub.com","phncdn.com","xvideos.com","xvideos-cdn.com","xvcdn.com","xnxx.com","xnxx-cdn.com","xhamster.com","xhcdn.com","redtube.com","rdtcdn.com"))) check("critical_global_services_hostlist", all(x in hostlist.splitlines() for x in ("openai.com","chatgpt.com","oaistatic.com","oaiusercontent.com","github.com","githubusercontent.com","githubassets.com","reddit.com","redditstatic.com","redditmedia.com","redd.it"))) check("custom_site_hostlist", all(x in helper for x in ('CUSTOM_HOSTS="$STATE_BASE/custom-hosts.txt"','USER_HOSTLIST_SYMLINK_REJECTED','python3 - "$HOSTS" "$CUSTOM_HOSTS" "$ADULT_FALLBACK" "$ADULT_HOSTS" "$ADULT_ON" "$RUN_HOSTS"','"custom-hostlist"')) and all(x in ui for x in ('CUSTOM_HOSTS=APP_HOME/"custom-hosts.txt"','normalize_custom_site','save_custom_hosts_text','("Custom Sites","custom",None)','Maximum 256 entries'))) @@ -142,15 +150,24 @@ def check(name,cond): and "PROVIDER_PACKAGE_DIR_UNSAFE" in transaction and '"router_gateway/providers.json"' in build) check("linux_package_includes_router_core", '("linux/app/router_gateway_core.py","app/router_gateway_core.py")' in build) -check("install_one_time_admin", 'pkexec /bin/bash "$HERE/system/install_system.sh"' in install) +check("install_one_time_admin", 'pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action install' in install) +check("install_root_trusted_entry_only", 'pkexec /bin/bash "$HERE/system/' not in install and + 'R781_TRUSTED_BOOTSTRAP_NOT_PROVISIONED' in install) check("install_idempotent_backend_hash_gate", "backend_matches_current()" in install and 'sha256sum "$src"' in install and "keeping root-owned code unchanged" in install) check("installed_root_backend_mismatch_triggers_explicit_rollback_and_no_root_overwrite", - "Protected backend differs; update refused until root rollback is verified" in install - and "rollback_failed_install 25" in install - and "rollback_failed_install 22" in install - and "rollback_failed_install 24" in install - and "if [ -e /var/lib/directinternetmethod/install.json ]" in install) -check("linux_hash_compatible_prior_backend_no_repeat_admin", 'd.get("version") in ("1.5.1","1.5.2","1.5.3","1.5.4")' in install and 'sha256sum "$src"' in install and 'if backend_matches_current; then' in install and 'keeping root-owned code unchanged' in install) + all(x in install for x in ( + "rollback_failed_install 25", "rollback_failed_install 22", + "rollback_failed_install 24", "rollback_failed_install 87", + "ROOT_UPGRADE_STAGED=1", "backend_matches_current ||", + "if ! trusted_bootstrap_ready; then", + 'pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action upgrade', + "Foreign/partial root backend; no mutation authorized."))) +check("linux_exact_current_backend_no_repeat_admin", + 'd.get("version")=="1.5.5-rc1"' in install and + 'sha256sum "$src"' in install and + 'if backend_matches_current; then' in install and + 'keeping root-owned code unchanged' in install and + 'pkexec /bin/bash "$HERE/system/' not in install) check("linux_install_reports_backend_version_authoritatively", 'protectedBackendVersion' in install and 'protectedBackendByteCompatible' in install and '/var/lib/directinternetmethod/install.json' in install) check("linux_lifecycle_acceptance_old_backend_sha_gated", "COMPATIBLE_OLD_BACKEND_HASH_MISMATCH" in (R/"tests"/"linux_installed_152_lifecycle.py").read_text(encoding="utf-8") and "compatibleBackendHashChecks" in (R/"tests"/"linux_installed_152_lifecycle.py").read_text(encoding="utf-8")) check("linux_fresh_install_parent_permissions", 'install -d -m 0755 "$ROOT" "$ROOT/runtime" "$ROOT/runtime/usr" "$ROOT/runtime/usr/bin" "$ROOT/licenses"' in sysinstall and 'install -d -m 0755 /var/lib/directinternetmethod' in sysinstall and 'mkdir -p /var/lib/directinternetmethod' not in sysinstall) diff --git a/tests/linux_nft_kernel_namespace_contract.py b/tests/linux_nft_kernel_namespace_contract.py index 34dff782..f7cc9684 100644 --- a/tests/linux_nft_kernel_namespace_contract.py +++ b/tests/linux_nft_kernel_namespace_contract.py @@ -96,6 +96,7 @@ def kernel_script() -> str: NFQWS="$TMP/not-nfqws" pid_owned(){ return 1; } remove_dns_link(){ echo "UNEXPECTED_DNS_LINK" >&2; return 99; } +restore_selective_dns(){ test ! -e /run/directinternetmethod-selective/session.json; } put_state(){ printf '%s\n' '{"dnsMode":"system-preserved","physicalInterface":"enp1s0","ctrldPid":0,"nfqwsPid":0}' > "$STATE" @@ -107,6 +108,9 @@ def kernel_script() -> str: new_chain(){ nft "add chain inet $TABLE output { type filter hook output priority mangle; policy accept; }" } +injected_mark(){ + nft "add rule inet $TABLE output meta mark & 0x40000000 == 0x40000000 return" +} mark(){ nft "add rule inet $TABLE output meta mark & 0xff0000 == 0x80000 return" } @@ -126,17 +130,51 @@ def kernel_script() -> str: } # Positive complete table: production ownership checker and cleanup. put_state -new_table; new_chain; mark; queues +new_table; new_chain; injected_mark; mark; queues nft_table_presence -nft_table_owned "$PHY" +if ! nft_table_owned "$PHY"; then + echo "R711_FAIL_CLOSED_NATIVE_COMPLETE_OWNER_NO_MATCH" >&2 + nft list table inet "$TABLE" >&2 + exit 97 +fi cleanup_state_owned verify_clean expect_absent echo "PASS_KERNEL_COMPLETE_OWNED_CLEANUP" +# A table with a *foreign* legacy SYN bypass is not owned and must be preserved. +put_state +new_table; new_chain; injected_mark; mark +nft "add rule inet $TABLE output oifname enp1s0 tcp dport 443 tcp flags & (syn | ack) == syn return" +nft add rule inet "$TABLE" output oifname enp1s0 tcp dport 80 ct original packets 1-6 queue num 200 bypass +nft add rule inet "$TABLE" output oifname enp1s0 tcp dport 443 ct original packets 1-6 queue num 200 bypass +nft add rule inet "$TABLE" output oifname enp1s0 udp dport 443 ct original packets 1-6 queue num 200 bypass +if nft_table_owned "$PHY"; then echo FALSE_FOREIGN_SYN_BYPASS_ACCEPTED >&2; exit 94;fi +if cleanup_state_owned; then rc=0; else rc=$?;fi +test "$rc" -eq 81 +test -f "$STATE" +nft list table inet "$TABLE" >/dev/null +nft delete table inet "$TABLE" +expect_absent +echo "PASS_KERNEL_FOREIGN_TCP443_SYN_EXEMPTION_FAIL_CLOSED" + +# A table lacking nfqws injected-packet exemption is NOT owned. Root must +# preserve it rather than deleting a rule set that may loop reinjected packets. +put_state +new_table; new_chain; mark; queues +if nft_table_owned "$PHY"; then + echo "FALSE_OWNERSHIP_WITH_MISSING_INJECTED_MARK" >&2; exit 92 +fi +if cleanup_state_owned; then rc=0; else rc=$?; fi +test "$rc" -eq 81 +test -f "$STATE" +nft list table inet "$TABLE" >/dev/null +nft delete table inet "$TABLE" +expect_absent +echo "PASS_KERNEL_MISSING_NFQWS_INJECTED_MARK_FAIL_CLOSED" # Prefix-safe rollback: table only, chain+mark, complete table. for mode in empty partial full; do put_state; new_table - if [ "$mode" != "empty" ]; then new_chain; mark; fi + if [ "$mode" != "empty" ]; then new_chain; injected_mark; mark; fi if [ "$mode" = "full" ]; then queues; fi if cleanup_transient; then rc=0; else rc=$?; fi test "$rc" -eq 0 @@ -145,7 +183,7 @@ def kernel_script() -> str: done # Foreign rule after Start: state must be preserved with explicit diagnostic. put_state -new_table; new_chain; mark; queues +new_table; new_chain; injected_mark; mark; queues nft add rule inet "$TABLE" output ip daddr 203.0.113.7 drop if nft_table_transient_owned "$PHY" "$TAILSCALE_COEXIST"; then echo "FALSE_FOREIGN_OWNERSHIP" >&2; exit 90 @@ -159,7 +197,7 @@ def kernel_script() -> str: echo "PASS_KERNEL_FOREIGN_ROLLBACK_PRESERVATION" # Foreign rule after Start completion: stop must fail before killing process. put_state -new_table; new_chain; mark; queues +new_table; new_chain; injected_mark; mark; queues nft add rule inet "$TABLE" output ip daddr 203.0.113.7 drop if cleanup_state_owned; then rc=0; else rc=$?; fi test "$rc" -eq 81 @@ -170,10 +208,10 @@ def kernel_script() -> str: echo "PASS_KERNEL_FOREIGN_STATE_CLEANUP_PRESERVATION" # A real foreign table must cause the production EXIT trap to fail. put_state -new_table; new_chain; mark; queues +new_table; new_chain; injected_mark; mark; queues nft add rule inet "$TABLE" output ip daddr 203.0.113.7 drop export TABLE PHY TAILSCALE_COEXIST CREATED_TABLE CREATED_LINK CREATED_STATE RUN_HOSTS NPID CPID CTRLD NFQWS -export -f rollback_on_exit cleanup_transient nft_table_presence nft_table_transient_owned pid_owned remove_dns_link +export -f rollback_on_exit cleanup_transient nft_table_presence nft_table_transient_owned pid_owned remove_dns_link restore_selective_dns if trap_output="$(bash -c 'set -euo pipefail; ACTION=start; START_COMMITTED=0; trap rollback_on_exit EXIT; exit 0' 2>&1)";then trap_rc=0 else @@ -188,7 +226,7 @@ def kernel_script() -> str: nft delete table inet "$TABLE" expect_absent echo "PASS_KERNEL_FAILED_ROLLBACK_EXIT_NONZERO" -echo "PASS_KERNEL_NAMESPACE_NFT_CONTRACT_7_OF_7" +echo "PASS_KERNEL_NAMESPACE_NFT_CONTRACT_9_OF_9" ''' script = functions + "\n" + cases # Fixture variables must be initialized before Bash set -u cleanup. diff --git a/tests/linux_user_update_transaction_contract.py b/tests/linux_user_update_transaction_contract.py index 79298cef..f9a1a281 100644 --- a/tests/linux_user_update_transaction_contract.py +++ b/tests/linux_user_update_transaction_contract.py @@ -91,7 +91,7 @@ def metadata(home, source): app = home / ".local/share/DirectInternetMethod" manifest = json.loads((home / ".local/state/DirectInternetMethod/update-pending/manifest.json").read_text()) new = { - "schema": 2, "version": "1.5.4", + "schema": 2, "version": "1.5.7", "managedProviderSha256": manifest["providerSha"], "managedCodeSha256": {k:v["sha"] for k,v in manifest["managed"].items() if k != tx.PROVIDERS}, @@ -154,7 +154,7 @@ def unchanged_data(app, user): pending_manifest = home3 / ".local/state/DirectInternetMethod/update-pending/manifest.json" pending_original = pending_manifest.read_bytes() corrupted = json.loads(pending_original) - need(corrupted["version"] == "1.5.4", "STAGE_NOT_VERSIONED_154") + need(corrupted["version"] == "1.5.7", "STAGE_NOT_VERSIONED_154") corrupted["version"] = "1.5.2" pending_manifest.write_text(json.dumps(corrupted), encoding="utf8") refused = calls(home3, src, "--apply", False) @@ -369,7 +369,7 @@ def unchanged_data(app, user): "router_gateway/providers.json": {"sha": "c" * 64}}, })) protected = sandbox / "fake-protected-backend.json" - protected.write_text(json.dumps({"version": "1.5.2"})) + protected.write_text(json.dumps({"version": "1.5.7"})) old_metadata = b'{"schema":2,"version":"1.5.1"}\n' metadata_path = fake_app / "INSTALL.json" metadata_path.write_bytes(old_metadata) @@ -418,7 +418,7 @@ def run_inline(): accepted = run_inline() need(accepted.returncode == 0, "F415_ATOMIC_METADATA_WRITE_FAILED:" + accepted.stderr) doc = json.loads(metadata_path.read_text(encoding="utf-8")) - need(doc["version"] == "1.5.4" and doc["schema"] == 2 + need(doc["version"] == "1.5.7" and doc["schema"] == 2 and doc["managedCodeSha256"] == {"direct_internet_method.py": "b" * 64} and doc["preservedCustomProviderData"] is True, "F415_METADATA_POSTCONDITION_INVALID") @@ -446,13 +446,13 @@ def run_inline(): doc = { "schema": 2, "product": "Direct Internet Method", - "version": "1.5.4", + "version": "1.5.7", "platform": "linux-x86_64", "installedUtc": "2026-10-12T00:00:00Z", "networkMutationOnInstall": False, "normalRunRequiresAdmin": False, "systemBackend": "/usr/lib/directinternetmethod", - "protectedBackendVersion": "1.5.2", + "protectedBackendVersion": "1.5.7", "protectedBackendByteCompatible": True, "onlineUpdate": "GitHub latest release + SHA256SUMS verification", "managedProviderSha256": manifest["providerSha"], diff --git a/tests/probe_process_security/ProbeProcessSecurity.csproj b/tests/probe_process_security/ProbeProcessSecurity.csproj new file mode 100644 index 00000000..00f77602 --- /dev/null +++ b/tests/probe_process_security/ProbeProcessSecurity.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tests/probe_process_security/Program.cs b/tests/probe_process_security/Program.cs new file mode 100644 index 00000000..d37b0ecc --- /dev/null +++ b/tests/probe_process_security/Program.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using DirectInternetMethod; + +var passed = 0; +void Check(string name, Action test) +{ + test(); + passed++; + Console.WriteLine("PASS " + name); +} +void Need(bool value, string reason) +{ + if (!value) throw new InvalidOperationException("R057_SECURITY_REGRESSION:" + reason); +} +void Reject(string label, string url, string ip) +{ + Check(label, () => + { + try + { + _ = ProbeProcessBuilder.Create(url, ip); + throw new InvalidOperationException("R057_UNSAFE_ARGUMENT_ACCEPTED:" + label); + } + catch (ArgumentException) { } + }); +} + +const string valid = "https://www.youtube.com/generate_204"; +const string physical = "192.168.8.42"; +// R759 NEGATIVE-FIRST: dangerous interface inputs must never fall back to +// wildcard/loopback/broadcast routing while probing supposedly physical egress. +Reject("nonphysical_unspecified", valid, "0.0.0.0"); +Reject("nonphysical_loopback", valid, "127.0.0.1"); +Reject("nonphysical_multicast", valid, "224.0.0.1"); +Reject("nonphysical_limited_broadcast", valid, "255.255.255.255"); +Reject("nonphysical_link_local", valid, "169.254.10.20"); +Reject("nonphysical_zero_net", valid, "0.1.2.3"); +Reject("nonphysical_loopback_other", valid, "127.4.5.6"); +Reject("nonphysical_reserved", valid, "240.0.0.1"); +Check("physical_usb_rfc1918_ipv4_accepted", () => + Need(ProbeProcessBuilder.Create(valid, "10.106.38.164").ArgumentList[2] == "10.106.38.164", + "Safe USB private IPv4 was rejected")); +Check("physical_carrier_grade_nat_ipv4_accepted", () => + Need(ProbeProcessBuilder.Create(valid, "100.100.64.2").ArgumentList[2] == "100.100.64.2", + "Safe CGNAT IPv4 was rejected")); + +Check("fixed_system32_curl_not_path_or_cwd", () => +{ + var plan = ProbeProcessBuilder.Create(valid, physical); + var expected = Path.GetFullPath(Path.Combine( + Environment.SystemDirectory, "curl.exe")); + Need(string.Equals(plan.FileName, expected, StringComparison.OrdinalIgnoreCase), + "Executable is not the OS system curl"); + Need(Path.IsPathFullyQualified(plan.FileName), "Executable path not absolute"); + Need(!plan.UseShellExecute && plan.CreateNoWindow && + plan.RedirectStandardOutput && plan.RedirectStandardError, "Unsafe process shell IO"); +}); +Check("atomic_seventeen_curl_arguments", () => +{ + var args = ProbeProcessBuilder.Create(valid, physical).ArgumentList.ToArray(); + Need(args.Length == 17, "Unexpected argv count"); + Need(args[0] == "-4" && args[1] == "--interface" && args[2] == physical, + "Interface not a single argv token"); + Need(args[3] == "--noproxy" && args[4] == "*", "Proxy bypass drift"); + Need(args[8] == "-o" && args[9] == "NUL", "No-output behavior drift"); + Need(args[10] == "-w" && args[11] == "%{http_code}|%{remote_ip}|%{time_total}", + "Live metrics parser contract drift"); + Need(args[16] == valid, "Endpoint changed"); +}); +Check("path_poisoning_does_not_change_executable", () => +{ + var old = Environment.GetEnvironmentVariable("PATH"); + try + { + Environment.SetEnvironmentVariable("PATH", Path.Combine(Path.GetTempPath(), "R057-untrusted-bin")); + var plan = ProbeProcessBuilder.Create(valid, physical); + Need(plan.FileName.EndsWith("curl.exe", StringComparison.OrdinalIgnoreCase) && + !plan.FileName.Contains("R057-untrusted-bin", StringComparison.OrdinalIgnoreCase), + "User-controlled PATH influenced process filename"); + } + finally { Environment.SetEnvironmentVariable("PATH", old); } +}); +Check("approved_https_hosts_remain_usable", () => +{ + foreach (var host in new[] { + "www.youtube.com", "api.openai.com", "github.com", "gemini.google.com", + "www.pornhub.com", "www.xvideos.com", "www.xnxx.com", "xhamster.com" + }) + Need(ProbeProcessBuilder.Create("https://" + host + "/", physical) + .ArgumentList.Last() == "https://" + host + "/", + "Approved origin silently blocked: " + host); +}); +foreach (var (label, url, ip) in new (string, string, string)[] { + ("injected_interface_flags", valid, "192.168.8.42 --output C:\\evil.txt"), + ("injected_interface_newline", valid, "192.168.8.42\n--data-binary @C:\\s"), + ("injected_interface_quotes", valid, "192.168.8.42\" -K cfg"), + ("ipv6_rejected", valid, "::1"), + ("invalid_octet_rejected", valid, "999.168.8.42"), + ("leading_dash_rejected", valid, "--config"), + ("noncanonical_ipv4_rejected", valid, "127.1"), + ("scheme_http_rejected", "http://github.com/", physical), + ("host_spoof_rejected", "https://github.com.evil.example/", physical), + ("userinfo_rejected", "https://user:pass@github.com/", physical), + ("unapproved_host_rejected", "https://attacker.example/", physical), + ("custom_port_rejected", "https://github.com:8443/", physical), + ("fragment_rejected", "https://github.com/#data", physical), + ("query_rejected", "https://github.com/?test=1", physical), +}) Reject(label, url, ip); +Console.WriteLine(JsonSerializer.Serialize(new { Status = "PASS", Version = "R057", + Checks = passed, ActualProcessSpawned = false, NetworkChanged = false })); diff --git a/tests/protected_upgrade_transaction_contract.py b/tests/protected_upgrade_transaction_contract.py new file mode 100644 index 00000000..741935cc --- /dev/null +++ b/tests/protected_upgrade_transaction_contract.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""R519 rootless fake-filesystem negative-first upgrade transaction validation. +DOES NOT execute protected CLI, use real /usr/lib, sudo, pkexec, systemctl, +Tailscale, user configuration or network. Fixtures are isolated per test. +""" +from __future__ import annotations +import fcntl,importlib.util,hashlib,json,os,pathlib,stat,tempfile,unittest +from unittest.mock import patch +ROOT=pathlib.Path(__file__).resolve().parents[1] +SOURCE=ROOT/"linux/system/protected_upgrade_transaction.py" +spec=importlib.util.spec_from_file_location("dim_r518_txn",SOURCE) +assert spec and spec.loader +m=importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +def sha(raw):return hashlib.sha256(raw).hexdigest().upper() + +class FakeFixture(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory(prefix="R519_FAKE_",dir=str(ROOT)) + self.root=pathlib.Path(self.temp.name) + (self.root/"run").mkdir(mode=0o755) + self.pkg=self.root/"package" + self.pkg.mkdir() + self.files=[] + for name,rel,target,old,new,mode in m.FILES: + source=(ROOT/"linux"/rel) + self.assertTrue(source.is_file(),rel) + content=source.read_bytes() + self.assertEqual(sha(content),new) + local=self.pkg/rel;local.parent.mkdir(exist_ok=True,parents=True) + local.write_bytes(content) + dest=self.root/target;dest.parent.mkdir(exist_ok=True,parents=True) + if old: + old_content=("BASELINE_FAKE_"+name).encode() + dest.write_bytes(old_content) + os.chmod(dest,mode) + old=sha(old_content) + self.files.append((name,rel,target,old,new,mode)) + self.static={} + for name in m.STATIC_ROOT: + p=self.root/name;p.parent.mkdir(exist_ok=True,parents=True) + data=("IMMUTABLE_FAKE_"+name).encode() + p.write_bytes(data) + self.static[name]=sha(data) + self.meta=self.root/m.METADATA + self.meta.parent.mkdir(parents=True,exist_ok=True) + self.orig={"schema":1,"version":"1.5.2","user":"mmz","uid":1000,"home":"/home/mmz"} + self.meta.write_text(json.dumps(self.orig,sort_keys=True)+"\n") + self.meta.chmod(0o644) + uidfile=self.root/"etc/directinternetmethod.uid" + uidfile.parent.mkdir(parents=True,exist_ok=True) + uidfile.write_text("1000\n") + self.tx=m.RootTransaction(self.root) + self.patch_files=patch.object(m,"FILES",tuple(self.files)) + self.patch_static=patch.object(m,"STATIC_ROOT",dict(self.static)) + self.patch_files.start();self.patch_static.start() + def tearDown(self): + self.patch_files.stop();self.patch_static.stop() + self.temp.cleanup() + def root_sha(self): + return {name:m.hash_file(self.root/target) if (self.root/target).exists() else None + for name,rel,target,old,new,mode in self.files} + def original_sha(self): + return {name:old for name,rel,target,old,new,mode in self.files} + def all_old(self): + self.assertEqual(self.root_sha(),self.original_sha()) + self.assertEqual(json.loads(self.meta.read_text())["version"],"1.5.2") + def all_new(self): + self.assertEqual(self.root_sha(),{name:new for name,rel,target,old,new,mode in self.files}) + self.assertEqual(json.loads(self.meta.read_text())["version"],m.TARGET_VERSION) + def test_success_stage_apply_rollback(self): + self.all_old() + self.assertEqual(self.tx.status()["status"],"NO_PENDING_UPGRADE") + self.assertEqual(self.tx.stage(self.pkg)["status"],"STAGED") + self.assertEqual(self.tx.status()["status"],"STAGED") + self.all_old() + self.assertEqual(self.tx.apply()["status"],"APPLIED") + self.all_new() + self.assertEqual(self.tx.rollback()["status"],"ROLLED_BACK") + self.all_old() + self.assertEqual(self.tx.status()["status"],"ROLLED_BACK") + def test_failure_after_1(self):self.inject_partial_fail(1) + def test_failure_after_2(self):self.inject_partial_fail(2) + def test_failure_after_3(self):self.inject_partial_fail(3) + def test_failure_after_4(self):self.inject_partial_fail(4) + def inject_partial_fail(self,num): + self.tx.stage(self.pkg) + with self.assertRaisesRegex(RuntimeError,"INJECTED_TEST_FAILURE"): + self.tx.apply(fail_after=num) + self.all_old() + self.assertEqual(self.tx.status()["status"],"ROLLED_BACK") + def test_initial_symlink_failclosed(self): + h=self.root/"usr/lib/directinternetmethod/direct_method_helper.sh" + h.unlink();h.symlink_to(self.meta) + with self.assertRaisesRegex(RuntimeError,"MISSING_OR_SYMLINK"): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + def test_tampered_source_refused(self): + bad=self.pkg/"system/encrypted_selective_dns.py" + bad.write_bytes(b'fake malicious source') + with self.assertRaisesRegex(RuntimeError,"PACKAGE_SOURCE_SHA"): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + self.all_old() + def test_existing_pending_refuses_rerun(self): + self.tx.stage(self.pkg) + with self.assertRaisesRegex(RuntimeError,"SLOT_ALREADY_EXISTS"):self.tx.stage(self.pkg) + self.all_old() + def test_tampered_pending_refuses_apply(self): + self.tx.stage(self.pkg) + dest=self.tx.pending/"new/encrypted_controller" + dest.write_bytes(b'CHANGED_PAYLOAD') + with self.assertRaisesRegex(RuntimeError,"PENDING_NEW_SHA"): + self.tx.apply() + self.all_old() + def test_tampered_original_refuses_apply(self): + self.tx.stage(self.pkg) + target=self.root/self.files[0][2] + target.write_bytes(b'FOREIGN_EDIT') + with self.assertRaisesRegex(RuntimeError,"FOREIGN_TARGET_MODIFICATION"): + self.tx.apply() + self.assertEqual(target.read_bytes(),b'FOREIGN_EDIT') + def test_rollback_refuses_unrecognized_changes(self): + self.tx.stage(self.pkg) + self.tx.apply() + edited=self.root/self.files[1][2] + edited.write_bytes(b'FOREIGN_AFTER_APPLY') + with self.assertRaisesRegex(RuntimeError,"FOREIGN_TARGET_MODIFICATION"): + self.tx.rollback() + self.assertEqual(edited.read_bytes(),b'FOREIGN_AFTER_APPLY') + self.assertEqual(self.tx.status()["status"],"APPLIED") + def test_metadata_change_before_apply_refused(self): + self.tx.stage(self.pkg) + self.meta.write_text('{"schema":1,"version":"BOGUS"}') + with self.assertRaisesRegex(RuntimeError,"FOREIGN_METADATA_CHANGE"): + self.tx.apply() + def test_source_symlink_refuses_stage(self): + f=self.pkg/"system/encrypted_selective_dns.py" + f.unlink();f.symlink_to(self.pkg/"app/direct_method_helper.sh") + with self.assertRaisesRegex(RuntimeError,"MISSING_OR_SYMLINK"):self.tx.stage(self.pkg) + def test_metadata_invalid_user_refuses_stage(self): + self.orig["uid"]=-1 + self.meta.write_text(json.dumps(self.orig)) + with self.assertRaisesRegex(RuntimeError,"OWNER_UID"):self.tx.stage(self.pkg) + def test_static_backend_changed_refuses_stage(self): + x=self.root/next(iter(self.static)) + x.write_text("tampered") + with self.assertRaisesRegex(RuntimeError,"BASELINE_STATIC_SHA"):self.tx.stage(self.pkg) + def test_missing_acceptance_refuses_commit(self): + self.tx.stage(self.pkg);self.tx.apply() + with self.assertRaisesRegex(RuntimeError,"NATIVE_ACCEPTANCE_MISSING"): + self.tx.commit(self.root/"NO_SATISFACTORY_NATIVE_RUN.json") + self.all_new() + self.assertTrue(self.tx.pending.exists()) + def test_r783_relative_source_refuses_before_pending_creation(self): + # The actual read remains descriptor-relative to a validated absolute + # package source; a relative caller path must not be accepted. + relative=pathlib.Path(os.path.relpath(self.pkg,ROOT)) + with self.assertRaisesRegex(RuntimeError,"PACKAGE_SOURCE"): + self.tx.stage(relative) + self.assertFalse(self.tx.pending.exists()) + self.all_old() + + def test_r783_acceptance_symlink_fails_without_commit(self): + self.tx.stage(self.pkg) + self.tx.apply() + link=self.root/"r783_receipt_symlink.json" + link.symlink_to(self.meta) + with self.assertRaisesRegex(RuntimeError,"NATIVE_ACCEPTANCE_MISSING"): + self.tx.commit(link) + self.assertEqual(self.tx.status()["status"],"APPLIED") + self.all_new() + + def test_r783_acceptance_parent_symlink_fails_without_commit(self): + self.tx.stage(self.pkg) + self.tx.apply() + alias=self.root/"r783_parent_symlink" + alias.symlink_to(self.root,target_is_directory=True) + with self.assertRaisesRegex(RuntimeError,"UNSAFE_PARENT_COMPONENT"): + self.tx.commit(alias/"receipt.json") + self.assertEqual(self.tx.status()["status"],"APPLIED") + self.all_new() + + def test_acceptance_requires_real_gates(self): + self.tx.stage(self.pkg);self.tx.apply() + meta=self.tx.manifest() + f=self.root/"acceptance.json" + a={"revision":"R519","status":"PASS_NATIVE_ROOT_UPGRADE", + "sourceRootSHA":m.sha(m.canonical(meta["files"])), + "protectedMetadataSHA":meta["newMetadataSHA"], + "transactionNonce":meta["transactionNonce"], + "nativeStartStopRecovery":False,"tailscalePreserved":True, + "physicalDNSPreserved":True} + f.write_text(json.dumps(a)) + with self.assertRaisesRegex(RuntimeError,"NATIVE_ACCEPTANCE_NOT_PROVEN"): + self.tx.commit(f) + a["nativeStartStopRecovery"]=True + f.write_text(json.dumps(a)) + self.assertEqual(self.tx.commit(f)["status"],"COMMITTED") + self.all_new() + self.assertTrue(self.tx.pending.exists(),"Baseline backup held until Brain promotion") + with self.assertRaisesRegex(RuntimeError,"UNSAFE_APPLY_RERUN"): + self.tx.apply() + def test_atomic_metadata_and_modes(self): + self.tx.stage(self.pkg);self.tx.apply() + for name,rel,target,old,new,mode in self.files: + self.assertEqual(stat.S_IMODE((self.root/target).stat().st_mode),mode) + self.assertEqual(stat.S_IMODE(self.meta.stat().st_mode),0o644) + self.assertEqual(stat.S_IMODE(self.tx.pending.stat().st_mode),0o700) + self.assertEqual(stat.S_IMODE((self.tx.pending/"manifest.json").stat().st_mode),0o600) + def test_no_foreign_unknown_tree_removed(self): + self.tx.stage(self.pkg) + unknown=self.tx.pending/"foreign.txt" + unknown.write_text("UNOWNED") + self.tx.apply() + self.tx.rollback() + self.assertEqual(unknown.read_text(),"UNOWNED") + self.assertTrue(self.tx.pending.exists()) + def test_native_action_lock_busy_refuses_stage(self): + lock=self.root/m.ACTION_LOCK + fd=os.open(lock,os.O_RDWR|os.O_CREAT,0o600) + try: + fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB) + with self.assertRaises(BlockingIOError): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + self.all_old() + finally: + os.close(fd) + def test_upgrade_lock_busy_refuses_stage(self): + fd=os.open(self.tx.lock,os.O_RDWR|os.O_CREAT,0o600) + try: + fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB) + with self.assertRaises(BlockingIOError): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + self.all_old() + finally: + os.close(fd) + def test_native_action_lock_symlink_refuses_mutation(self): + lock=self.root/m.ACTION_LOCK + lock.symlink_to(self.meta) + with self.assertRaises(OSError): + self.tx.stage(self.pkg) + self.all_old() + self.assertFalse(self.tx.pending.exists()) + def test_file_list_exact(self): + self.tx.stage(self.pkg) + mdata=self.tx.manifest() + mdata["files"]["extra"]={"target":"etc/shadow"} + self.tx.write_manifest(mdata) + with self.assertRaisesRegex(RuntimeError,"MANIFEST_FILE_LIST"): + self.tx.apply() + self.all_old() + def test_no_product_service_commands(self): + # Search for prohibited side-effect operations in the pure upgrade module. + txt=SOURCE.read_text("utf8") + for term in ('systemctl start','tailscale set','tailscale down','nft add', + 'ip route add','shutil.rmtree("/usr','rm -rf /usr'): + self.assertNotIn(term,txt) + def test_cli_never_accepts_root_prefix(self): + txt=SOURCE.read_text("utf8") + self.assertNotIn('p.add_argument("--root"',txt) + self.assertIn("need(os.geteuid()==0",txt) + self.assertIn("RootTransaction() # No CLI root-prefix",txt) + +if __name__=="__main__": + suite=unittest.defaultTestLoader.loadTestsFromTestCase(FakeFixture) + r=unittest.TextTestRunner(verbosity=2).run(suite) + print(f"R519_NEGATIVE_FIRST_ROOT_TRANSACTION cases={r.testsRun} fails={len(r.failures)} errors={len(r.errors)}") + raise SystemExit(0 if r.wasSuccessful() else 1) diff --git a/tests/r061_windows_service_curl_contract.py b/tests/r061_windows_service_curl_contract.py new file mode 100644 index 00000000..41102f80 --- /dev/null +++ b/tests/r061_windows_service_curl_contract.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""R061 no-execution Windows service curl provenance contract, with negative fixtures.""" +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = ROOT / "windows" / "service" / "Program.cs" + + +def service_method(text): + match = re.search( + r"static async Task DownloadWithSystemCurlAsync\b[\s\S]*?(?=\n static async Task RunUpdateAsync\b)", + text, + ) + if not match: + raise AssertionError("R061_DOWNLOAD_METHOD_NOT_FOUND") + return match.group(0) + + +def require_contract(text): + method = service_method(text) + required = ( + "Environment.SystemDirectory", + "string.IsNullOrWhiteSpace(systemFolder)", + "Path.IsPathFullyQualified(systemFolder)", + 'Path.GetFullPath(Path.Combine(systemFolder, "curl.exe"))', + "if (!File.Exists(curl))", + "new ProcessStartInfo(curl)", + "UseShellExecute = false", + "psi.ArgumentList.Add(arg)", + "using var p = Process.Start(psi)", + "await p.WaitForExitAsync(ct)", + ) + for fragment in required: + if fragment not in method: + raise AssertionError("R061_MISSING_" + fragment) + forbidden = ( + "Environment.GetFolderPath(", + "Environment.GetEnvironmentVariable(", + 'new ProcessStartInfo("curl.exe")', + "UseShellExecute = true", + "psi.Arguments =", + 'Process.Start("curl.exe"', + ) + for fragment in forbidden: + if fragment in method: + raise AssertionError("R061_UNSAFE_" + fragment) + if method.index("Path.IsPathFullyQualified(systemFolder)") > method.index("new ProcessStartInfo(curl)"): + raise AssertionError("R061_GUARD_AFTER_PROCESS_PLAN") + if method.index("if (!File.Exists(curl))") > method.index("new ProcessStartInfo(curl)"): + raise AssertionError("R061_EXISTENCE_AFTER_PROCESS_PLAN") + return True + + +class TestSystemCurlProvenance(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.src = SOURCE.read_text(encoding="utf-8") + + def test_valid_service_source(self): + self.assertTrue(require_contract(self.src)) + + def test_fail_closed_to_old_special_folder_lookup(self): + text = self.src.replace( + "Environment.SystemDirectory", + "Environment.GetFolderPath(Environment.SpecialFolder.System)", + ) + with self.assertRaises(AssertionError): + require_contract(text) + + def test_fail_closed_to_path_lookup(self): + text = self.src.replace("Environment.SystemDirectory", 'Environment.GetEnvironmentVariable("PATH")') + with self.assertRaises(AssertionError): + require_contract(text) + + def test_fail_closed_if_absolute_path_guard_removed(self): + text = self.src.replace("!Path.IsPathFullyQualified(systemFolder)", "false") + with self.assertRaises(AssertionError): + require_contract(text) + + def test_fail_closed_if_process_shell_enabled(self): + method = service_method(self.src).replace("UseShellExecute = false", "UseShellExecute = true") + with self.assertRaises(AssertionError): + require_contract(self.src.replace(service_method(self.src), method)) + + def test_fail_closed_to_non_tokenized_arguments(self): + method = service_method(self.src).replace("psi.ArgumentList.Add(arg)", "psi.Arguments = arg") + with self.assertRaises(AssertionError): + require_contract(self.src.replace(service_method(self.src), method)) + + def test_fail_closed_to_bare_executable(self): + method = service_method(self.src).replace("new ProcessStartInfo(curl)", 'new ProcessStartInfo("curl.exe")') + with self.assertRaises(AssertionError): + require_contract(self.src.replace(service_method(self.src), method)) + + def test_checksum_still_verified_before_installer_execution(self): + source = self.src + self.assertIn("await Sha256FileAsync(partPath, timeout.Token)", source) + self.assertIn("actual.Equals(expected, StringComparison.OrdinalIgnoreCase)", source) + self.assertIn("var psi = new ProcessStartInfo(finalPath)", source) + self.assertLess( + source.index("await Sha256FileAsync(partPath, timeout.Token)"), + source.index("var psi = new ProcessStartInfo(finalPath)"), + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/r519_user_update_transaction_contract.py b/tests/r519_user_update_transaction_contract.py new file mode 100644 index 00000000..f9a1a281 --- /dev/null +++ b/tests/r519_user_update_transaction_contract.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +"""R401 negative-first real filesystem fixtures: no protected system service, network +or installed-user-app mutations. Tests one-slot code transaction and data preservation. +Runs on Linux as an unprivileged user with a temporary HOME. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import pathlib +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +TX = ROOT / "linux/app/user_update_transaction.py" +UNINSTALL = ROOT / "linux/app/user_uninstall_preserve.py" + +spec = importlib.util.spec_from_file_location("dim_tx", TX) +tx = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tx) + +def need(value, detail): + assert value, detail + +def digest(data): + return hashlib.sha256(data).hexdigest() + +def calls(home, source, command, success=True): + env = dict(os.environ, HOME=str(home), PYTHONDONTWRITEBYTECODE="1") + args = [sys.executable, "-B", str(TX), command] + if command in ("--stage", "--apply"): + args += ["--source", str(source)] + p = subprocess.run(args, env=env, text=True, capture_output=True, timeout=8) + if success: + need(p.returncode == 0, f"EXPECTED_PASS:{command}:{p.stderr}") + else: + need(p.returncode != 0 and "FAIL_CLOSED" in p.stderr, f"NEGATIVE_WRONGLY_PASSED:{command}") + return p + +def source_tree(tmp, tag): + # Mirror actual public ZIP: linux/ and router_gateway/ are siblings. + src = tmp / ("source-" + tag) / "linux" + for origin, rel, mode in tx.FILES: + p = src / origin + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(f"version-{tag}:{rel}\n".encode("utf8")) + p.chmod(mode) + provider = src / tx.PROVIDER_SOURCE + provider.parent.mkdir(parents=True, exist_ok=True) + provider.write_bytes(f'{{"version":"{tag}"}}\n'.encode()) + return src + +def initial(tmp, tag="1.5.1", managed_provider=False): + home = tmp / "home" + home.mkdir(parents=True) + app = home / ".local/share/DirectInternetMethod" + app.mkdir(parents=True) + oldfiles = {} + for _, rel, _ in tx.FILES: + p = app / rel + p.write_bytes(f"old-{tag}:{rel}\n".encode()) + oldfiles[rel] = p.read_bytes() + provider = app / tx.PROVIDERS + provider.parent.mkdir(parents=True) + provider.write_bytes(b'{"userCustomizedProvider":"keep"}\n') + user = { + "settings.json": b'{"userOption": true}\n', + "scope.txt": b"targeted\n", + "strategy.txt": b"strong\n", + "custom-hosts.txt": b"private.example\n", + "adult-enabled.txt": b"0\n", + "adult-hosts.txt": b"user-catalog-preserve\n", + } + for name, body in user.items(): + (app / name).write_bytes(body) + record = { + "schema": 2, + "version": tag, + "managedProviderSha256": digest(provider.read_bytes()) if managed_provider else None, + "managedCodeSha256": {k: digest(v) for k, v in oldfiles.items()}, + "managedLaunchersSha256": {}, + } + (app / tx.METADATA).write_text(json.dumps(record) + "\n") + oldfiles[tx.METADATA] = (app / tx.METADATA).read_bytes() + return home, app, oldfiles, user, provider.read_bytes() + +def metadata(home, source): + app = home / ".local/share/DirectInternetMethod" + manifest = json.loads((home / ".local/state/DirectInternetMethod/update-pending/manifest.json").read_text()) + new = { + "schema": 2, "version": "1.5.7", + "managedProviderSha256": manifest["providerSha"], + "managedCodeSha256": {k:v["sha"] for k,v in manifest["managed"].items() + if k != tx.PROVIDERS}, + "managedLaunchersSha256": {}, + } + (app / tx.METADATA).write_text(json.dumps(new)+"\n") + return manifest + +def unchanged_data(app, user): + for name, expected in user.items(): + need((app / name).read_bytes() == expected, "USER_SETTINGS_CHANGED:" + name) + +with tempfile.TemporaryDirectory(prefix="dim-r401-sourceonly-") as temp: + tmp = pathlib.Path(temp) + src = source_tree(tmp, "new") + tests = 0 + # Normal data-preserving in-place update when Router Gateway data is custom. + home, app, original, user, provider = initial(tmp/"case1") + calls(home, src, "--stage") + calls(home, src, "--apply") + record = metadata(home, src) + need(record["providerManaged"] is False, "UNKNOWN_PROVIDER_WAS_NOT_PRESERVED") + calls(home, src, "--commit") + need(not (home / ".local/state/DirectInternetMethod/update-pending").exists(), "EXTRA_ROLLBACK_SLOT_AFTER_ACCEPTED_UPDATE") + unchanged_data(app, user) + need((app / tx.PROVIDERS).read_bytes() == provider, "CUSTOM_ROUTER_PROVIDER_LOST") + need((app / "direct_internet_method.py").read_bytes() != original["direct_internet_method.py"], "CODE_NOT_UPDATED_IN_PLACE") + need(len(list((home / ".local/share").glob("DirectInternetMethod-backup-*"))) == 0, + "TIMESTAMPED_SNAPSHOT_CREATED") + tests += 1 + # Idempotent same version does not accumulate a dated previous version. + calls(home, src, "--stage") + calls(home, src, "--apply") + metadata(home, src) + calls(home, src, "--commit") + unchanged_data(app, user) + need(len(list((home / ".local/state/DirectInternetMethod").rglob("update-pending"))) == 0, "SECOND_SLOT_REMAINS") + tests += 1 + # Exact explicitly vendor-owned provider may refresh safely, unlike custom. + home2, app2, old2, user2, provider2 = initial(tmp/"case2", managed_provider=True) + calls(home2, src, "--stage") + calls(home2, src, "--apply") + m2 = metadata(home2, src) + need(m2["providerManaged"] is True and (app2 / tx.PROVIDERS).read_bytes() != provider2, + "PINNED_MANAGED_PROVIDER_NOT_UPDATED") + calls(home2, src, "--commit") + unchanged_data(app2, user2) + tests += 1 + # Negative: refuse re-entry if an interrupted transaction occupies slot. + home3, app3, old3, user3, _ = initial(tmp/"case3") + calls(home3, src, "--stage") + calls(home3, src, "--stage", False) + calls(home3, src, "--rollback") + need((app3 / "direct_internet_method.py").read_bytes() == old3["direct_internet_method.py"], "REENTRY_CHANGED_BASELINE") + unchanged_data(app3, user3) + tests += 1 + # R467 negative regression: an interrupted transaction with a stale + # version must fail before ANY managed code or personal settings mutation. + calls(home3, src, "--stage") + pending_manifest = home3 / ".local/state/DirectInternetMethod/update-pending/manifest.json" + pending_original = pending_manifest.read_bytes() + corrupted = json.loads(pending_original) + need(corrupted["version"] == "1.5.7", "STAGE_NOT_VERSIONED_154") + corrupted["version"] = "1.5.2" + pending_manifest.write_text(json.dumps(corrupted), encoding="utf8") + refused = calls(home3, src, "--apply", False) + need("PENDING_TRANSACTION_VERSION_WRONG" in refused.stderr, + "STALE_PENDING_VERSION_WAS_NOT_REJECTED") + need((app3 / "direct_internet_method.py").read_bytes() == old3["direct_internet_method.py"], + "STALE_PENDING_APPLIED_MANAGED_CODE") + unchanged_data(app3, user3) + pending_manifest.write_bytes(pending_original) + calls(home3, src, "--rollback") + need(not pending_manifest.exists(), "STALE_PENDING_ROLLBACK_LEFT_SLOT") + tests += 1 + # Negative: concurrent changes to managed code refuse overwrite. + calls(home3, src, "--stage") + changed = app3 / "direct_internet_method.py" + changed.write_bytes(b"foreign-owner-edits\n") + calls(home3, src, "--apply", False) + need(changed.read_bytes() == b"foreign-owner-edits\n", "FOREIGN_PROGRAM_EDIT_WAS_DESTROYED") + changed.write_bytes(old3["direct_internet_method.py"]) + calls(home3, src, "--rollback") + tests += 1 + # Real rollback after managed code and metadata were changed: old bytes exact. + calls(home3, src, "--stage") + calls(home3, src, "--apply") + metadata(home3, src) + calls(home3, src, "--rollback") + for name, body in old3.items(): + need((app3 / name).read_bytes() == body, "OLD_FILE_BYTE_NOT_RESTORED:" + name) + unchanged_data(app3, user3) + tests += 1 + # Negative: preserved personal setting symlink is not followed or replaced. + home4, app4, old4, user4, _ = initial(tmp/"case4") + external = home4 / "external-user-data" + external.write_bytes(b"PRIVATE_DO_NOT_READ_OR_DELETE\n") + (app4 / "settings.json").unlink() + (app4 / "settings.json").symlink_to(external) + calls(home4, src, "--stage", False) + need(external.read_bytes() == b"PRIVATE_DO_NOT_READ_OR_DELETE\n", "SYMLINK_TARGET_CHANGED") + tests += 1 + # Negative: file changed by another actor after stage must not be committed. + home5, app5, old5, user5, _ = initial(tmp/"case5") + calls(home5, src, "--stage") + calls(home5, src, "--apply") + metadata(home5, src) + (app5 / "settings.json").write_bytes(b"CONCURRENT_USER_EDIT\n") + calls(home5, src, "--commit", False) + need((home5 / ".local/state/DirectInternetMethod/update-pending").exists(), + "UNSAFE_AUTO_REMOVAL_OF_UNRESOLVED_TRANSACTION") + need((app5 / "settings.json").read_bytes() == b"CONCURRENT_USER_EDIT\n", "USER_CONCURRENT_EDIT_DELETED") + tests += 1 + # Negative: a foreign edit to the LAST managed program file must reject the + # entire apply before changing the FIRST managed file. No partial mutation. + home6, app6, old6, user6, _ = initial(tmp/"case6") + calls(home6, src, "--stage") + late = app6 / "user_update_transaction.py" + late.write_bytes(b"FOREIGN_LATE_CODE_MUST_BE_PRESERVED\n") + calls(home6, src, "--apply", False) + need((app6 / "direct_internet_method.py").read_bytes() == + old6["direct_internet_method.py"], "PARTIAL_APPLICATION_PREVALIDATION_FAILED") + need(late.read_bytes() == b"FOREIGN_LATE_CODE_MUST_BE_PRESERVED\n", + "FOREIGN_LATE_CODE_WAS_OVERWRITTEN") + late.write_bytes(old6["user_update_transaction.py"]) + calls(home6, src, "--rollback") + unchanged_data(app6, user6) + tests += 1 + # Negative: a new user edit after apply must block rollback BEFORE any + # executable bytes change; the user edit must remain intact. + home7, app7, old7, user7, _ = initial(tmp/"case7") + calls(home7, src, "--stage") + calls(home7, src, "--apply") + metadata(home7, src) + before_code = (app7 / "direct_internet_method.py").read_bytes() + (app7 / "settings.json").write_bytes(b"NEW_USER_SETTING_KEEP\n") + calls(home7, src, "--rollback", False) + need((app7 / "direct_internet_method.py").read_bytes() == before_code, + "ROLLBACK_MUTATED_CODE_BEFORE_REJECTING_USER_CONFIG_CHANGE") + need((app7 / "settings.json").read_bytes() == b"NEW_USER_SETTING_KEEP\n", + "ROLLBACK_OVERWROTE_USER_SETTING") + (app7 / "settings.json").write_bytes(user7["settings.json"]) + calls(home7, src, "--rollback") + unchanged_data(app7, user7) + tests += 1 + # Accepted legacy v1.5.2 install can contain the EXACT public v1.5.1 + # uninstall.sh and v1.5.2 GUI; this is approved source provenance, not + # a reason to leave duplicate installed versions. + legacy = { + rel: {"exists": True, "sha": next(iter(hashes)), "mode": 0o755} + for rel, hashes in tx.OFFICIAL_LEGACY_SHA.items() + } + legacy["direct_internet_method.py"]["sha"] = ( + "613f878f989c6598d7ad25b82a75d37e727c557ad9a8c36f82cf0953d7567f99") + legacy["uninstall.sh"]["sha"] = ( + "a6ba8ad80c7f7da73dd53d24b5985d5747a3724774f303f8cfb7ad8fbdab6431") + tx.verify_installed_code_provenance({"schema": 2, "version": "1.5.2"}, legacy) + tests += 1 + # Unknown user-modified executable must NOT be silently overwritten, + # even if metadata merely names the old product version. + modified = {key: dict(value) for key, value in legacy.items()} + modified["uninstall.sh"]["sha"] = "0"*64 + try: + tx.verify_installed_code_provenance({"schema": 2, "version": "1.5.2"}, modified) + except RuntimeError as error: + need("UNKNOWN_LEGACY_PROGRAM_PRESERVE" in str(error), + "UNEXPECTED_LEGACY_FILE_REJECTION") + else: + raise AssertionError("UNKNOWN_CUSTOM_PROGRAM_FILE_FALSE_ACCEPTED") + tests += 1 + # Single explicit application uninstall never deletes the mixed data directory. + install_source = (ROOT / "linux/install.sh").read_text() + uninstall_source = (ROOT / "linux/uninstall.sh").read_text() + need("DirectInternetMethod-backup-$(date" not in install_source, "OLD_TIME_BACKUP_GENERATOR_PRESENT") + need('rm -rf "$APP"' not in uninstall_source and + "user_uninstall_preserve.py" in uninstall_source and + "--remove-after-backend" in uninstall_source and + "--verify" in uninstall_source, "DESTRUCTIVE_UNINSTALL_CONTRACT_REGRESSION") + need("user_update_transaction.py" in install_source and + "--stage" in install_source and "--commit" in install_source and + "--rollback" in install_source, "TRANSACTION_LIFECYCLE_CONTRACT_MISSING") + tests += 1 + # Negative uninstall: a live protected backend cannot be removed by a + # rootless isolated fixture, and settings/metadata stay intact. + env = dict(os.environ, HOME=str(home), PYTHONDONTWRITEBYTECODE="1") + process = subprocess.run([sys.executable,"-B",str(UNINSTALL),"--verify"], + env=env,text=True,capture_output=True,timeout=8) + need(process.returncode == 0, "UNINSTALL_MANAGED_HASH_PRECHECK_FAILED:" + process.stderr) + tests += 1 + # Negative: the public uninstall CLI refuses to remove user code while the + # actual protected backend remains; no fixture config may be harmed. + if (pathlib.Path("/usr/lib/directinternetmethod/control.sh").exists() or + pathlib.Path("/var/lib/directinternetmethod/install.json").exists()): + negative = subprocess.run([sys.executable, "-B", str(UNINSTALL), "--remove-after-backend"], + env=env, text=True, capture_output=True, timeout=8) + need(negative.returncode != 0 and "PROTECTED_BACKEND_STILL_PRESENT" in negative.stderr, + "UNINSTALL_PROTECTED_BACKEND_FAIL_CLOSED_MISSED") + unchanged_data(app, user) + else: + # A clean ephemeral CI host has no protected backend to guard against. + # Verify the production code includes the fail-closed root-state gate. + uninstall_source = UNINSTALL.read_text(encoding="utf8") + need("PROTECTED_BACKEND_STILL_PRESENT_REFUSE_USER_CODE_REMOVAL" in uninstall_source, + "CLEAN_RUNNER_MISSING_PROTECTED_BACKEND_GUARD") + tests += 1 + # Independently exercise the pure OWNED file deletion step against a fake + # HOME. It may remove only hashes recorded for managed code, preserving + # custom Router Gateway providers, all personal settings and runtime logs. + pure = """ +import importlib.util,pathlib,sys +z=importlib.util.spec_from_file_location("dimuninstall",sys.argv[1]);m=importlib.util.module_from_spec(z);z.loader.exec_module(m) +app,meta,known=m.load_owned() +assert len(known)>=7 +n=m.remove_known_files(app,meta,known) +assert n>=7 +print("PASS_PURE_OWNED_UNINSTALL",n) +""" + custom_launcher=home / ".local/bin/direct-internet-method" + custom_launcher.parent.mkdir(parents=True, exist_ok=True) + custom_launcher.write_bytes(b"USER_MODIFIED_LAUNCHER_MUST_NOT_BE_REMOVED\n") + pure_run=subprocess.run([sys.executable, "-B", "-c", pure, str(UNINSTALL)], + env=env, text=True, capture_output=True, timeout=8) + need(pure_run.returncode == 0, "OWNED_UNINSTALL_SANDBOX_FAILURE:" + pure_run.stderr) + unchanged_data(app, user) + need((app / tx.PROVIDERS).read_bytes() == provider, "UNINSTALL_DESTROYED_CUSTOM_ROUTER_PROVIDER") + need(not (app / "direct_internet_method.py").exists() and + not (app / "INSTALL.json").exists(), "UNINSTALL_DID_NOT_REMOVE_OWNED_CODE") + need(app.exists(), "USER_DATA_DIRECTORY_REMOVED") + need(custom_launcher.read_bytes()==b"USER_MODIFIED_LAUNCHER_MUST_NOT_BE_REMOVED\n", + "UNINSTALL_REMOVED_UNTRACKED_CUSTOM_LAUNCHER") + tests += 1 + need(all(x in install_source for x in ( + 'if [ ! -e "$BIN/direct-internet-method" ]', + 'if [ ! -e "$DESKTOP_FILE" ]', + 'if [ ! -e "$ICONS/direct-internet-method.svg" ]', + 'p.read_bytes()==expected', + '"managedLaunchersSha256":launchers')), + "INSTALL_UPGRADE_MIGHT_OVERWRITE_CUSTOM_USER_LAUNCHER") + tests += 1 + + # Real-package regression: missing sibling provider source refuses stage. + missing_src = source_tree(tmp, "missing-provider") + (missing_src / tx.PROVIDER_SOURCE).unlink() + missing_home, missing_app, _, missing_user, _ = initial(tmp / "case-missing-provider") + calls(missing_home, missing_src, "--stage", False) + need(not (missing_home / ".local/state/DirectInternetMethod/update-pending").exists(), + "MISSING_REAL_PACKAGE_PROVIDER_CREATED_PENDING_TRANSACTION") + unchanged_data(missing_app, missing_user) + tests += 1 + +print("PASS_LINUX_ONE_INSTALL_DATA_PRESERVING_TRANSACTION_NEGATIVE_FIRST_FIXTURES:",tests,"/",tests) + + +# R421 regression: execute the ACTUAL metadata heredoc from install.sh against +# a disposable HOME, replacing ONLY the root-owned backend lookup with a fixture. +# Never install, elevate, change the host network, or touch a real user profile. +with tempfile.TemporaryDirectory(prefix="dim-r421-f415-") as td: + sandbox = pathlib.Path(td) + fake_home = sandbox / "home" + fake_app = fake_home / ".local/share/DirectInternetMethod" + fake_app.mkdir(parents=True) + fake_router = fake_app / "router_gateway" + fake_router.mkdir() + custom_provider = fake_router / "providers.json" + custom_provider.write_bytes(b'{"customService":"KEEP_ORIGINAL"}\n') + user_setting = fake_app / "settings.json" + user_setting.write_bytes(b'{"custom":true}\n') + (fake_app / "direct-internet-method.svg").write_bytes(b"") + pending = fake_home / ".local/state/DirectInternetMethod/update-pending" + pending.mkdir(parents=True) + (pending / "manifest.json").write_text(json.dumps({ + "providerSha": "a" * 64, + "providerManaged": False, + "managed": {"direct_internet_method.py": {"sha": "b" * 64}, + "router_gateway/providers.json": {"sha": "c" * 64}}, + })) + protected = sandbox / "fake-protected-backend.json" + protected.write_text(json.dumps({"version": "1.5.7"})) + old_metadata = b'{"schema":2,"version":"1.5.1"}\n' + metadata_path = fake_app / "INSTALL.json" + metadata_path.write_bytes(old_metadata) + launcher_before = (fake_app / "direct-internet-method.svg").read_bytes() + provider_before = custom_provider.read_bytes() + settings_before = user_setting.read_bytes() + marker = 'python3 - "$APP/INSTALL.json" "$USER_TRANSACTION" <<' + "'PY'\n" + need(install_source.count(marker) == 1, "F415_WRITER_HEREDOC_MISSING_OR_DUPLICATE") + inline = install_source.split(marker, 1)[1].split("\nPY\n", 1)[0] + "\n" + backend_lookup = 'protected=json.loads(pathlib.Path("/var/lib/directinternetmethod/install.json").read_text(encoding="utf-8"))' + need(inline.count(backend_lookup) == 1, "F415_BACKEND_LOOKUP_DRIFT") + inline = inline.replace(backend_lookup, + 'protected=json.loads(pathlib.Path(sys.argv[3]).read_text(encoding="utf-8"))') + need("transaction_helper.atomic_bytes(" in inline and ".write_text(json.dumps(" not in inline, + "F415_DIRECT_METADATA_TRUNCATION_REGRESSION") + compile(inline, "R421_INSTALL_METADATA_HEREDOC", "exec") + fixture_env = dict(os.environ, HOME=str(fake_home), PYTHONDONTWRITEBYTECODE="1") + def run_inline(): + return subprocess.run( + [sys.executable, "-B", "-", str(metadata_path), str(TX), str(protected)], + input=inline, text=True, capture_output=True, + env=fixture_env, timeout=10) + # Fail-closed stale atomic stage never overwrites either item. + stage_path = fake_app / ".INSTALL.json.dim-update-stage" + stage_path.write_bytes(b"FOREIGN_OR_INTERRUPTED_STAGE_KEEP") + negative = run_inline() + need(negative.returncode != 0 and "STALE_ATOMIC_TEMP" in negative.stderr, + "F415_STALE_STAGE_WRONGLY_ACCEPTED:" + negative.stderr) + need(metadata_path.read_bytes() == old_metadata and + stage_path.read_bytes() == b"FOREIGN_OR_INTERRUPTED_STAGE_KEEP", + "F415_STALE_STAGE_WAS_MODIFIED") + stage_path.unlink() # Only our owned disposable test file. + # Symlink attempt must be rejected without modifying the foreign target. + foreign = sandbox / "foreign-metadata.json" + foreign.write_bytes(b"FOREIGN_TARGET_KEEP") + metadata_path.unlink() + metadata_path.symlink_to(foreign) + rejected = run_inline() + need(rejected.returncode != 0 and "FOREIGN_MANAGED_FILE_OR_SYMLINK" in rejected.stderr, + "F415_SYMLINK_WAS_NOT_REJECTED:" + rejected.stderr) + need(foreign.read_bytes() == b"FOREIGN_TARGET_KEEP" and metadata_path.is_symlink(), + "F415_SYMLINK_TARGET_CHANGED") + metadata_path.unlink() + metadata_path.write_bytes(old_metadata) + # Positive path replaces metadata once, preserves custom data and is private. + accepted = run_inline() + need(accepted.returncode == 0, "F415_ATOMIC_METADATA_WRITE_FAILED:" + accepted.stderr) + doc = json.loads(metadata_path.read_text(encoding="utf-8")) + need(doc["version"] == "1.5.7" and doc["schema"] == 2 + and doc["managedCodeSha256"] == {"direct_internet_method.py": "b" * 64} + and doc["preservedCustomProviderData"] is True, + "F415_METADATA_POSTCONDITION_INVALID") + need(metadata_path.stat().st_mode & 0o777 == 0o600, + "F415_METADATA_PERMISSIONS_NOT_PRIVATE") + need(not stage_path.exists(), "F415_ATOMIC_STAGE_LEFT_ON_SUCCESS") + need(custom_provider.read_bytes() == provider_before and + user_setting.read_bytes() == settings_before and + (fake_app / "direct-internet-method.svg").read_bytes() == launcher_before, + "F415_ATOMIC_WRITER_CHANGED_MUTABLE_USER_DATA") + print("PASS_F415_METADATA_ATOMIC_INSTALL_INLINE:", 3, "/", 3) + + +# R423 F418: real SIGKILL during actual atomic_bytes(). Explicit one-file +# recover requires exact operator-known stage SHA, a valid pending manifest, +# exact managed code, unchanged old metadata and intact user configuration. +with tempfile.TemporaryDirectory(prefix="dim-r423-real-sigkill-") as td: + case = pathlib.Path(td) + source = source_tree(case, "r423") + home, app, old, user, provider_before = initial(case / "installed") + calls(home, source, "--stage") + calls(home, source, "--apply") + pending = home / ".local/state/DirectInternetMethod/update-pending" + manifest = json.loads((pending / "manifest.json").read_text(encoding="utf-8")) + doc = { + "schema": 2, + "product": "Direct Internet Method", + "version": "1.5.7", + "platform": "linux-x86_64", + "installedUtc": "2026-10-12T00:00:00Z", + "networkMutationOnInstall": False, + "normalRunRequiresAdmin": False, + "systemBackend": "/usr/lib/directinternetmethod", + "protectedBackendVersion": "1.5.7", + "protectedBackendByteCompatible": True, + "onlineUpdate": "GitHub latest release + SHA256SUMS verification", + "managedProviderSha256": manifest["providerSha"], + "preservedCustomProviderData": not manifest["providerManaged"], + "managedCodeSha256": {k: v["sha"] for k, v in manifest["managed"].items() + if k != tx.PROVIDERS}, + "managedLaunchersSha256": {}, + "updateMode": "IN_PLACE_SINGLE_INSTALL_DATA_PRESERVED", + } + intended = (json.dumps(doc, indent=2) + "\n").encode("utf-8") + payload = case / "intended.json" + payload.write_bytes(intended) + install_record = app / tx.METADATA + stage_path = app / ".INSTALL.json.dim-update-stage" + env = dict(os.environ, HOME=str(home), PYTHONDONTWRITEBYTECODE="1") + child = r''' +import importlib.util, os, pathlib, signal, sys +spec = importlib.util.spec_from_file_location("dim_crash_helper", sys.argv[1]) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +def crash_before_replace(source, destination): + os.kill(os.getpid(), signal.SIGKILL) +m.os.replace = crash_before_replace +m.atomic_bytes(pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]).read_bytes(), 0o600) +''' + killed = subprocess.run([sys.executable, "-B", "-c", child, str(TX), + str(install_record), str(payload)], + env=env, text=True, capture_output=True, timeout=10) + need(killed.returncode == -9, "F418_DID_NOT_REAL_SIGKILL:" + str(killed.returncode)) + need(stage_path.is_file() and stage_path.read_bytes() == intended, + "F418_SIGKILL_STAGE_MISSING_OR_CHANGED") + need(install_record.read_bytes() == old[tx.METADATA], + "F418_SIGKILL_DAMAGED_ORIGINAL_METADATA") + digest_stage = digest(intended) + def recover(sha, positive=False): + result = subprocess.run([sys.executable, "-B", str(TX), + "--recover-metadata-stage", "--expected-stage-sha256", sha], + env=env, text=True, capture_output=True, timeout=10) + if positive: + need(result.returncode == 0 and "PASS_EXACT_OWNED" in result.stdout, + "F418_VALID_STAGE_NOT_RECOVERED:" + result.stderr) + else: + need(result.returncode != 0 and "FAIL_CLOSED" in result.stderr, + "F418_UNTRUSTED_STAGE_FALSE_ACCEPTED") + return result + cases = 0 + recover("0" * 64) + need(stage_path.read_bytes() == intended, "F418_WRONG_DIGEST_REMOVED_STAGE") + cases += 1 + stage_path.chmod(0o644) + recover(digest_stage) + need(stage_path.is_file(), "F418_NONPRIVATE_STAGE_REMOVED") + stage_path.chmod(0o600) + cases += 1 + stage_path.write_bytes(b'{"partial":') + recover(digest_stage) + need(stage_path.read_bytes() == b'{"partial":', "F418_PARTIAL_STAGE_REMOVED") + stage_path.write_bytes(intended) + stage_path.chmod(0o600) + cases += 1 + foreign = case / "foreign-not-ours" + foreign.write_bytes(b"FOREIGN_CONTENT_MUST_SURVIVE") + stage_path.unlink() + stage_path.symlink_to(foreign) + recover(digest_stage) + need(foreign.read_bytes() == b"FOREIGN_CONTENT_MUST_SURVIVE" and + stage_path.is_symlink(), "F418_SYMLINK_FOLLOWED_OR_DELETED") + stage_path.unlink() + stage_path.write_bytes(intended) + stage_path.chmod(0o600) + cases += 1 + original = install_record.read_bytes() + install_record.write_bytes(b"FOREIGN_CONCURRENT_METADATA") + recover(digest_stage) + need(stage_path.is_file() and install_record.read_bytes() == + b"FOREIGN_CONCURRENT_METADATA", "F418_CONCURRENT_METADATA_LOST") + install_record.write_bytes(original) + cases += 1 + managed = app / "direct_internet_method.py" + managed_bytes = managed.read_bytes() + managed.write_bytes(b"OTHER_ACTOR_CODE") + recover(digest_stage) + need(managed.read_bytes() == b"OTHER_ACTOR_CODE" and stage_path.is_file(), + "F418_FOREIGN_MANAGED_CODE_UNSAFE_RECOVERY") + managed.write_bytes(managed_bytes) + cases += 1 + settings = app / "settings.json" + original_settings = settings.read_bytes() + settings.write_bytes(b"USER_UPDATED_SETTINGS") + recover(digest_stage) + need(settings.read_bytes() == b"USER_UPDATED_SETTINGS" and stage_path.is_file(), + "F418_USER_EDIT_NOT_PRESERVED") + settings.write_bytes(original_settings) + cases += 1 + compromised = dict(doc) + compromised["managedCodeSha256"] = {"direct_internet_method.py": "0"*64} + stage_path.write_bytes((json.dumps(compromised) + "\n").encode()) + stage_path.chmod(0o600) + recover(digest(stage_path.read_bytes())) + need(stage_path.is_file(), "F418_FOREIGN_DOCUMENT_WITH_DIGEST_WRONGLY_REMOVED") + stage_path.write_bytes(intended) + stage_path.chmod(0o600) + cases += 1 + recover(digest_stage, positive=True) + need(not stage_path.exists() and pending.is_dir(), + "F418_ONLY_OWNED_STAGE_SHOULD_BE_REMOVED_PENDING_MUST_REMAIN") + need(install_record.read_bytes() == old[tx.METADATA], + "F418_RECOVERY_OVERWROTE_OLD_METADATA") + need((app / tx.PROVIDERS).read_bytes() == provider_before, + "F418_RECOVERY_CHANGED_CUSTOM_PROVIDER") + unchanged_data(app, user) + cases += 1 + # Explicit rollback after safe stage attestation restores EVERY old program + # file, original INSTALL.json and all personal settings without duplicate + # directories or app/backend/network mutations. + calls(home, source, "--rollback") + for name, previous in old.items(): + need((app / name).read_bytes() == previous, + "F418_ROLLBACK_OLD_BYTES_DRIFT:" + name) + unchanged_data(app, user) + need((app / tx.PROVIDERS).read_bytes() == provider_before and + not pending.exists(), "F418_ROLLBACK_USER_DATA_OR_SLOT_FAILED") + cases += 1 + # With no pending transaction, an arbitrary new staged file can never + # trigger cleanup (even with its SHA supplied by the caller). + stage_path.write_bytes(intended) + stage_path.chmod(0o600) + recover(digest_stage) + need(stage_path.read_bytes() == intended, "F418_NO_PENDING_STAGE_REMOVED") + cases += 1 + print("PASS_F418_SIGKILL_ATTESTED_STAGE_RECOVERY_NEGATIVE_FIRST:", + cases, "/", cases, "REAL_SIGKILL_CODE", killed.returncode) diff --git a/tests/r519_versioned_package_contract.py b/tests/r519_versioned_package_contract.py new file mode 100644 index 00000000..c53e2d5c --- /dev/null +++ b/tests/r519_versioned_package_contract.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""R519 negative-first immutable package, source manifest and version contracts. + +No installed writes: all mutations are confined to a temporary extracted ZIP +directory and compared to its original exact-SHA baseline. +""" +from __future__ import annotations +import ast,hashlib,importlib.util,json,pathlib,re,tempfile,unittest,zipfile +R=pathlib.Path(__file__).resolve().parents[1] +VERSION="1.5.7" +PACKAGE=R/"delivery"/f"DirectInternetMethod_{VERSION}_Linux_x86_64.zip" +GUI=R/"linux/app/direct_internet_method.py" +HELPER=R/"linux/app/direct_method_helper.sh" +ROOT_TXN=R/"linux/system/protected_upgrade_transaction.py" +INSTALLER=R/"linux/install.sh" +USER_TXN=R/"linux/app/user_update_transaction.py" +PKG_DOC=R/"docs/README_PACKAGE_157.md" +EXPECTED={"version":VERSION,"files":28,"sha256":"F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6"} +def digest(data):return hashlib.sha256(data).hexdigest().upper() +class Tests(unittest.TestCase): + @classmethod + def setUpClass(cls): + assert PACKAGE.is_file() + cls.info=zipfile.ZipFile(PACKAGE) + cls.n=cls.info.namelist() + cls.prefix=f"DirectInternetMethod_{VERSION}_Linux_x86_64/" + cls.rows=json.loads(cls.info.read(cls.prefix+"PACKAGE_MANIFEST.json"))["files"] + @classmethod + def tearDownClass(cls):cls.info.close() + def test_exact_zip_sha(self): + self.assertEqual(digest(PACKAGE.read_bytes()),EXPECTED["sha256"]) + def test_exact_manifest_version_and_members(self): + manifest=json.loads(self.info.read(self.prefix+"PACKAGE_MANIFEST.json")) + self.assertEqual((manifest["schema"],manifest["version"],manifest["platform"]),(1,VERSION,"linux-x86_64")) + self.assertEqual(len(manifest["files"]),EXPECTED["files"]) + self.assertEqual(len(self.n),EXPECTED["files"]+2) + self.assertEqual(len(self.n),len(set(self.n))) + self.assertEqual(set(self.n),{self.prefix+r["file"] for r in self.rows}|{ + self.prefix+"PACKAGE_MANIFEST.json",self.prefix+"SHA256SUMS.txt"}) + self.assertIsNone(self.info.testzip()) + def test_source_members_all_match_manifest(self): + hashes=set() + for item in self.rows: + path=item["file"] + self.assertFalse(pathlib.PurePosixPath(path).is_absolute()) + self.assertNotIn("..",pathlib.PurePosixPath(path).parts) + data=self.info.read(self.prefix+path) + self.assertEqual((len(data),digest(data)),(item["bytes"],item["sha256"])) + self.assertNotIn(path,hashes) + hashes.add(path) + sums="".join(f'{r["sha256"]} {r["file"]}\n' for r in self.rows) + self.assertEqual(self.info.read(self.prefix+"SHA256SUMS.txt").decode(),sums) + def test_versioned_components_present(self): + included={r["file"] for r in self.rows} + self.assertTrue({"system/encrypted_selective_dns.py","system/encrypted_selective_policy.py", + "system/protected_upgrade_transaction.py","system/upgrade_system.sh", + "app/direct_method_helper.sh","app/user_update_transaction.py", + "system/upgrade_guard.py"}.issubset(included)) + def test_r519b_uninstaller_accepts_only_new_ownership_version(self): + rel="app/user_uninstall_preserve.py" + got=self.info.read(self.prefix+rel) + declared=next(row for row in self.rows if row["file"]==rel) + self.assertEqual(digest(got),declared["sha256"]) + self.assertEqual(declared["sha256"],"869EA1CABA79D36A4987BE97868A4A7378B64987F5CC192255BECDB751020E62") + self.assertIn('"socket_fallback.py"', got.decode()) + self.assertIn('doc.get("version") == "1.5.7"',got.decode()) + self.assertNotIn('doc.get("version") == "1.5.4"',got.decode()) + self.assertEqual(digest((R/"linux/app/user_uninstall_preserve.py").read_bytes()),declared["sha256"]) + def test_original_unreleased_candidate_preserved_and_current_source_delta(self): + # CI does not commit an 8.3 MB obsolete candidate ZIP. Its original + # manifest and changed single source member are pinned byte-for-byte + # as compact historical fixtures (all 26 original file SHA entries). + fixtures=R/"tests/fixtures/r519_original" + old_manifest=fixtures/"PACKAGE_MANIFEST.json" + old_source=fixtures/"user_uninstall_preserve.py" + # Compare canonical historical LF bytes, not Git-for-Windows CRLF checkout. + old_manifest_bytes=old_manifest.read_bytes().replace(b"\r\n",b"\n") + self.assertEqual(digest(old_manifest_bytes), + "EA43CD77EAA2979AC970A8B47145881C767519E3D024BF907167A2FE3FD6C05E") + self.assertEqual(digest(old_source.read_bytes()), + "9A4A4C37E3F63F1602E15454100150D209E3CD099DE80504B560F31D42D3C0EF") + prev=json.loads(old_manifest_bytes) + self.assertEqual((prev["schema"],prev["version"],prev["platform"]),(1,"1.5.5-rc1","linux-x86_64")) + before={row["file"]:row for row in prev["files"]} + after={row["file"]:row for row in self.rows} + self.assertEqual(set(after),set(before)|{"system/upgrade_guard.py","app/socket_fallback.py"}) + self.assertEqual(len(before),26) + changed={name for name in before if before[name]!=after[name]} + # Preserve the immutable historical manifest and explicitly recognize + # the new R762 controller hardening in the current unreleased package. + self.assertEqual(changed,{"README.md","app/user_uninstall_preserve.py", + "app/direct_internet_method.py","app/direct_method_helper.sh", + "app/user_update_transaction.py","system/protected_upgrade_transaction.py", + "system/upgrade_system.sh","system/install_system.sh", + "install.sh","system/encrypted_selective_dns.py"}) + self.assertEqual(after["system/encrypted_selective_dns.py"]["sha256"], + "A8A8DD6155DDC4E2C5295A6CAA7B8809144C43985B47FD4664501ED683DB0AAE") + self.assertEqual(after["app/direct_internet_method.py"]["sha256"], + "6D60986F06D6239FB03CE58B4CA496463D77D40B89938F2932F1C52834475D71") + self.assertIn("encrypted=encrypted_dns_optin_state()", + self.info.read(self.prefix+"app/direct_internet_method.py").decode()) + self.assertIn('s.get("selectiveDnsEncrypted")', + self.info.read(self.prefix+"app/direct_internet_method.py").decode()) + self.assertIn('PHYSICAL_INTERFACE_ROUTE_DRIFT', + self.info.read(self.prefix+"app/direct_method_helper.sh").decode()) + self.assertIn('PHYSICAL_INTERFACE_ROUTE_DRIFT', + self.info.read(self.prefix+"app/direct_internet_method.py").decode()) + previous=before["app/user_uninstall_preserve.py"] + self.assertEqual((previous["bytes"],previous["sha256"]), + (len(old_source.read_bytes()),digest(old_source.read_bytes()))) + self.assertNotEqual(previous["sha256"],after["app/user_uninstall_preserve.py"]["sha256"]) + # If the old full binary happens to be available locally, verify the + # exact historical archive as additional provenance, not a CI prerequisite. + old=R/"evidence/R519_ORIGINAL_RC1_SUPERSEDED.zip" + if old.exists(): + self.assertEqual(digest(old.read_bytes()), + "47776640A1BB8B5D4490244565DDD47A6A817A999347566917CC73E21F41EC24") + with zipfile.ZipFile(old) as z: + self.assertEqual(set(self.n),set(z.namelist())|{self.prefix+"system/upgrade_guard.py",self.prefix+"app/socket_fallback.py"}) + self.assertEqual(z.read(self.prefix+"PACKAGE_MANIFEST.json"),old_manifest_bytes) + self.assertEqual(z.read(self.prefix+"app/user_uninstall_preserve.py"),old_source.read_bytes()) + self.assertEqual(z.testzip(),None) + def test_original_published_metadata_untouched(self): + data=json.loads((R/"RELEASE.json").read_text()) + self.assertEqual(data["version"],"1.5.4") + self.assertFalse(any("1.5.5-rc1" in x["file"] for x in self.rows)) + def test_fresh_root_contract_version(self): + root=(R/"linux/system/install_system.sh").read_text() + self.assertIn('{"schema":1,"version":"1.5.7"',root) + self.assertIn('encrypted_selective_dns.py',root) + self.assertIn('encrypted_selective_policy.py',root) + def test_protected_upgrade_exact_sha(self): + text=ROOT_TXN.read_text() + self.assertIn('TARGET_VERSION="1.5.7"',text) + self.assertIn('CURRENT_BACKEND_VERSION="1.5.2"',text) + self.assertIn('data.get("revision")=="R519"',text) + self.assertIn('receipt.get("revision")=="R519"',text) + self.assertIn("NATIVE_ACCEPTANCE_NOT_PROVEN",text) + self.assertIn("one slot",text.lower()) + def test_root_wrapper_pinned_identity(self): + src=(R/"linux/system/upgrade_system.sh").read_text() + self.assertIn(digest(ROOT_TXN.read_bytes()),src) + self.assertIn('DirectInternetMethod_1.5.7_Linux_x86_64',src) + self.assertIn("R553_ROOT_GUARD_ACTIVE_PRESTAGE_VERIFIED",src) + self.assertIn("GUARD_SHA=",src) + self.assertLess(src.index("R553_ROOT_GUARD_ACTIVE_PRESTAGE_VERIFIED"), + src.index('python3 -B "$GUARD_ROOT_TX" stage')) + self.assertIn('python3 -B "$GUARD_ROOT_TX" stage',src) + self.assertIn('python3 -B "$GUARD_ROOT_TX" apply',src) + self.assertIn('R779_BOOTSTRAP_COPY_SHA_CHANGED',src) + self.assertNotIn('python3 "$SRC/system/upgrade_guard.py" arm',src) + self.assertNotIn(' commit',src) + def test_guard_and_hosted_root_zip_inventory_track_live_manifest(self): + # R748 negative-first: source/unit guards must accept the same exact + # archive member count as the deterministic current ZIP (no bypass). + guard=(R/"linux/system/upgrade_guard.py").read_text(encoding="utf8") + wrapper=(R/"linux/system/upgrade_system.sh").read_text(encoding="utf8") + native=(R/"tests/r528_ephemeral_native_root_cli_contract.py").read_text(encoding="utf8") + expected=EXPECTED["files"] + self.assertIn(f"len(files)=={expected}",guard) + self.assertIn(f"archive_verify(z,CANDIDATE_PREFIX,{expected+2})",native) + self.assertIn(f'len(manifest.get("files",[]))=={expected}',native) + self.assertIn('GUARD_SHA="'+digest(guard.encode("utf8"))+'"',wrapper) + # The independent guard and system wrapper MUST agree with the + # installed user transaction, including future managed-file changes. + user_sha=digest(USER_TXN.read_bytes()) + self.assertIn('PIN_USER="'+user_sha+'"',guard) + self.assertIn('assert sha(guard/"user_tx.py")=="'+user_sha+'"',wrapper) + # Immutable original public Stable stays deliberately at 24 ZIP + # entries; only the unpublished RC1's inventory is extended. + self.assertIn('archive_verify(z,STABLE_PREFIX,24)',native) + + def test_hosted_root_watchdog_user_sha_matches_current_candidate(self): + # R748: adding/removing a managed user module changes this source + # without changing Root. Both sides of the independent watchdog and + # the hosted guard tests must follow the exact current user hash. + expected=digest(USER_TXN.read_bytes()) + for filename,variable in ( + ("tests/r532_ephemeral_root_watchdog_contract.py","USER_SHA"), + ("tests/r532_root_watchdog_worker.py","PIN_USER"), + ("tests/r554_native_guard_ephemeral_contract.py","PIN_USER"), + ): + text=(R/filename).read_text(encoding="utf8") + matches=re.findall( + rf'(?m)^\s*{variable}\s*=\s*["\x27]([a-fA-F0-9]{{64}})["\x27]', + text, + ) + self.assertEqual(len(matches),1,filename) + self.assertEqual(matches[0].upper(),expected,filename) + + def test_user_installer_never_pretends_committed(self): + src=INSTALLER.read_text() + self.assertIn("ROOT_UPGRADE_STAGED=1",src) + self.assertIn('R519_CANDIDATE_PENDING_NATIVE_ROOT_USER_COMMIT',src) + self.assertIn('pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action upgrade',src) + self.assertIn('pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action install',src) + self.assertNotIn('pkexec /bin/bash "$HERE/system/',src) + self.assertIn('R781_TRUSTED_BOOTSTRAP_NOT_PROVISIONED',src) + self.assertIn('python3 "$USER_TRANSACTION" --rollback',src) + def test_gui_and_user_metadata_pins(self): + app=GUI.read_text() + self.assertIn('VERSION="1.5.7"',app) + self.assertIn('COEXIST_HELPER_SHA256="'+digest(HELPER.read_bytes()).lower()+'"',app) + self.assertIn('installed.get("version") == "1.5.7"',app) + updater=USER_TXN.read_text() + self.assertIn('len(package_doc["files"]) == 28',updater) + self.assertIn('data.get("version") == "1.5.7"',updater) + self.assertIn('"encrypted-dns-optin.txt"',updater) + def test_rc_semver_prevents_implicit_downgrade(self): + code=ast.parse(GUI.read_text()) + node=next(x for x in code.body if isinstance(x,ast.FunctionDef) and x.name=='_version_tuple') + ns={"re":re} + exec(compile(ast.Module(body=[node],type_ignores=[]),str(GUI),"exec"),ns) + v=ns["_version_tuple"] + self.assertLess(v("1.5.4"),v("1.5.5-rc1")) + self.assertLess(v("1.5.5-rc1"),v("1.5.5")) + self.assertLess(v("1.5.5-rc1"),v("1.5.5-rc2")) + def test_archive_manifest_negative_corrupted_payload(self): + with tempfile.TemporaryDirectory(prefix="R519_OFFLINE_TEST_") as temp: + target=pathlib.Path(temp)/f"DirectInternetMethod_{VERSION}_Linux_x86_64" + self.info.extractall(temp) + payload=target/"app/direct_internet_method.py" + payload.write_bytes(payload.read_bytes()+b"\n# tampered\n") + spec=importlib.util.spec_from_file_location("r519_user_txn",USER_TXN) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + with self.assertRaisesRegex((RuntimeError,AssertionError),".*"): + module.source_files(target) + def test_readme_is_explicitly_unreleased(self): + s=PKG_DOC.read_text() + self.assertIn("NOT PUBLISHED",s) + self.assertIn("1.5.7",s) + self.assertIn("v1.5.4",s) +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Tests)) + print("R519_COHERENCE_NEGATIVE_FIRST",result.testsRun,"cases", "PASS" if result.wasSuccessful() else "FAIL") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r526_installer_metadata_contract.py b/tests/r526_installer_metadata_contract.py new file mode 100644 index 00000000..c74ebe0d --- /dev/null +++ b/tests/r526_installer_metadata_contract.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""R526: hermetic Linux 1.5.7 INSTALL.json activation regression gate. + +Run the exact inline metadata program from linux/install.sh against a disposable +HOME and a fake protected Root metadata file. No real installer, sudo, systemd, +DNS, routes, nftables, Tailscale, or live user installation is touched. +This verifies implementation contracts, not physical-host acceptance. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +BASE = Path(__file__).resolve().parents[1] +INSTALL = BASE / "linux/install.sh" +USER_TX = BASE / "linux/app/user_update_transaction.py" +BEGIN = """python3 - "$APP/INSTALL.json" "$USER_TRANSACTION" <<'PY'\n""" +END = "\nPY\n" +PROTECTED = 'pathlib.Path("/var/lib/directinternetmethod/install.json")' +VERSION = "1.5.7" + + +def exact_inline_program() -> str: + source = INSTALL.read_text(encoding="utf-8") + if source.count(BEGIN) != 1: + raise AssertionError("INLINE_METADATA_ENTRY_NOT_UNIQUE") + tail = source.split(BEGIN, 1)[1] + if tail.count(END) != 1 or tail.count(PROTECTED) != 1: + raise AssertionError("INLINE_METADATA_PROVENANCE_NOT_UNIQUE") + program = tail.split(END, 1)[0] + if "transaction_helper.atomic_bytes(" not in program: + raise AssertionError("METADATA_ATOMIC_WRITER_MISSING") + return program + + +class InstallerMetadata(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory(prefix="DIM_R526_META_") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.home = self.root / "home" + self.app = self.home / ".local/share/DirectInternetMethod" + self.app.mkdir(parents=True) + self.pending = self.home / ".local/state/DirectInternetMethod/update-pending" + self.pending.mkdir(parents=True) + self.protected = self.root / "protected.json" + self.protected.write_text(json.dumps({"schema": 1, "version": VERSION, "uid": 1000})) + self.target = self.app / "INSTALL.json" + self.original = b'{"schema":2,"version":"1.5.4","userNote":"KEEP"}\n' + self.target.write_bytes(self.original) + (self.app / "direct-internet-method.svg").write_bytes(b"FIXTURE_ICON") + self.custom = self.app / "custom-hosts.txt" + self.custom.write_bytes(b"KEEP_CUSTOM_HOSTS\n") + self.manifest = { + "schema": 1, + "version": VERSION, + "managed": { + "direct_internet_method.py": {"sha": "a" * 64, "mode": 0o755}, + "user_update_transaction.py": {"sha": "b" * 64, "mode": 0o755}, + }, + "providerSha": "c" * 64, + "providerManaged": True, + } + (self.pending / "manifest.json").write_text(json.dumps(self.manifest)) + self.program = exact_inline_program() + + def invoke(self) -> subprocess.CompletedProcess[str]: + if self.program.count(PROTECTED) != 1: + raise AssertionError("PROTECTED_PATH_PROVENANCE_CHANGED") + # Override one exact path ONLY inside the disposable child process. + # Never edit the package, protected host metadata, or user files. + rendered = self.program.replace(PROTECTED, "pathlib.Path(" + repr(str(self.protected)) + ")") + env = dict(os.environ) + env["HOME"] = str(self.home) + return subprocess.run( + [sys.executable, "-B", "-", str(self.target), str(USER_TX)], + input=rendered + "\n", + text=True, + capture_output=True, + env=env, + timeout=12, + check=False, + ) + + def unchanged_on_failure(self) -> None: + result = self.invoke() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.target.read_bytes(), self.original) + self.assertEqual(self.custom.read_bytes(), b"KEEP_CUSTOM_HOSTS\n") + + def test_01_entrypoint_and_exact_source_contract(self) -> None: + self.assertTrue(INSTALL.is_file() and USER_TX.is_file()) + self.assertEqual(len(self.program.splitlines()), 64) + self.assertEqual(self.program.count(PROTECTED), 1) + self.assertIn('assert protected.get("version") == "1.5.7"', self.program) + self.assertIn('transaction_helper.atomic_bytes(', self.program) + + def test_02_in_place_metadata_and_mutable_preservation(self) -> None: + result = self.invoke() + self.assertEqual(result.returncode, 0, result.stderr[-700:]) + doc = json.loads(self.target.read_text()) + self.assertEqual(doc["schema"], 2) + self.assertEqual(doc["version"], VERSION) + self.assertEqual(doc["protectedBackendVersion"], VERSION) + self.assertEqual(doc["managedProviderSha256"], self.manifest["providerSha"]) + self.assertEqual( + doc["managedCodeSha256"], + {key: row["sha"] for key, row in self.manifest["managed"].items()}, + ) + self.assertIsInstance(doc["managedLaunchersSha256"], dict) + self.assertFalse(doc["networkMutationOnInstall"]) + self.assertFalse(doc["normalRunRequiresAdmin"]) + self.assertEqual(doc["updateMode"], "IN_PLACE_SINGLE_INSTALL_DATA_PRESERVED") + self.assertEqual(self.target.stat().st_mode & 0o777, 0o600) + self.assertEqual(self.custom.read_bytes(), b"KEEP_CUSTOM_HOSTS\n") + + def test_03_old_protected_backend_fails_closed(self) -> None: + self.protected.write_text('{"version":"1.5.2"}') + self.unchanged_on_failure() + + def test_04_missing_protected_metadata_fails_closed(self) -> None: + self.protected.unlink() + self.unchanged_on_failure() + + def test_05_missing_pending_transaction_fails_closed(self) -> None: + (self.pending / "manifest.json").unlink() + self.unchanged_on_failure() + + def test_06_malformed_pending_transaction_fails_closed(self) -> None: + (self.pending / "manifest.json").write_bytes(b"{INVALID JSON") + self.unchanged_on_failure() + + def test_07_symlink_metadata_never_overwrites_foreign(self) -> None: + foreign = self.root / "foreign.txt" + foreign.write_bytes(b"FOREIGN_DO_NOT_MODIFY") + self.target.unlink() + self.target.symlink_to(foreign) + result = self.invoke() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(foreign.read_bytes(), b"FOREIGN_DO_NOT_MODIFY") + self.assertTrue(self.target.is_symlink()) + + def test_08_orphan_stage_preserved_without_mutation(self) -> None: + orphan = self.app / ".INSTALL.json.dim-update-stage" + orphan.write_bytes(b"UNKNOWN_OWNER_STAGE") + self.unchanged_on_failure() + self.assertEqual(orphan.read_bytes(), b"UNKNOWN_OWNER_STAGE") + + def test_09_commit_requires_new_metadata_in_transaction_source(self) -> None: + transaction = USER_TX.read_text(encoding="utf-8") + self.assertIn('METADATA = "INSTALL.json"', transaction) + self.assertIn("def check_metadata(app, data):", transaction) + self.assertIn("check_metadata(app, data)", transaction) + self.assertIn("def rollback():", transaction) + self.assertIn("def recover_metadata_stage(", transaction) + + def test_10_no_network_service_mutation_in_activation_program(self) -> None: + for disallowed in ( + "systemctl ", "tailscale set", "nft ", "ip route ", + "subprocess.run(", "os.system(", "/etc/resolv.conf", + ): + self.assertNotIn(disallowed, self.program) + self.assertNotIn("https://", self.program) + + def test_11_source_is_unmodified_by_child_execution(self) -> None: + before = (hashlib.sha256(INSTALL.read_bytes()).digest(), hashlib.sha256(USER_TX.read_bytes()).digest()) + result = self.invoke() + self.assertEqual(result.returncode, 0, result.stderr[-700:]) + after = (hashlib.sha256(INSTALL.read_bytes()).digest(), hashlib.sha256(USER_TX.read_bytes()).digest()) + self.assertEqual(after, before) + + +if __name__ == "__main__": + suite = unittest.defaultTestLoader.loadTestsFromTestCase(InstallerMetadata) + report = unittest.TextTestRunner(verbosity=2).run(suite) + print(f"R526_INLINE_METADATA_CI {report.testsRun} tests; failures={len(report.failures)}; errors={len(report.errors)}") + raise SystemExit(0 if report.wasSuccessful() else 1) diff --git a/tests/r527_privileged_root_transaction_contract.py b/tests/r527_privileged_root_transaction_contract.py new file mode 100644 index 00000000..0c6de44f --- /dev/null +++ b/tests/r527_privileged_root_transaction_contract.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""R527: real-root-owned file transaction on a DISPOSABLE GitHub Ubuntu VM. + +This is not a live product installation or physical network acceptance. +The unchanged candidate RootTransaction class executes its native owner/permission +checks and real chown/fsync/flock/atomic rename/rollback syscalls as UID 0, but +EVERY write is confined to a new fixture beneath GITHUB_WORKSPACE. Existing +network, /usr/lib, /etc, real user homes, systemd and Tailscale are NEVER changed. +Do not run on personal machines, even as root. +""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import sys +import unittest + +BASE = Path(__file__).resolve().parents[1] +ORIGINAL_TEST = BASE / "tests/protected_upgrade_transaction_contract.py" +ROOT_CODE = BASE / "linux/system/protected_upgrade_transaction.py" +REQUIRED_REPO = "GOD13emad/DirectInternetMethod" + +def read_module(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError("SOURCE_IMPORT_ERROR") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + +fixture_source = read_module(ORIGINAL_TEST, "r527_fixtures") +m = fixture_source.m + +def assert_ephemeral_runner(): + if os.name != "posix" or not sys.platform.startswith("linux"): + raise RuntimeError("R527_LINUX_REQUIRED") + if os.geteuid() != 0: + raise RuntimeError("R527_ROOT_REQUIRED") + if os.environ.get("CI") != "true" or os.environ.get("GITHUB_ACTIONS") != "true": + raise RuntimeError("R527_GITHUB_EPHEMERAL_RUNNER_REQUIRED") + if os.environ.get("GITHUB_REPOSITORY") != REQUIRED_REPO: + raise RuntimeError("R527_EXACT_GITHUB_REPOSITORY_REQUIRED") + workspace = os.environ.get("GITHUB_WORKSPACE") + runner_temp = os.environ.get("RUNNER_TEMP") + if not workspace or not runner_temp: + raise RuntimeError("R527_RUNNER_PATHS_REQUIRED") + # The runner directory is a *literal* repository-owned CI location, not + # an arbitrary root path derived from job-provided environment variables. + expected_workspace=Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod") + expected_temp=Path("/home/runner/work/_temp") + if workspace!=str(expected_workspace) or BASE!=expected_workspace: + raise RuntimeError("R527_WORKSPACE_MISMATCH") + if runner_temp!=str(expected_temp) or not expected_temp.is_dir() or expected_temp.is_symlink(): + raise RuntimeError("R527_RUNNER_TEMP_MISSING") + if BASE == Path("/") or BASE == Path("/usr") or BASE == Path("/home"): + raise RuntimeError("R527_REAL_ROOT_BLOCKED") + if (Path("/run/directinternetmethod-encrypted").exists() + or Path("/run/directinternetmethod-selective/session.json").exists() + or Path("/home/mmz/.local/share/DirectInternetMethod/directmethod/state.json").exists()): + raise RuntimeError("R527_RUNNER_NOT_CLEAN") + if not ROOT_CODE.is_file() or not ORIGINAL_TEST.is_file(): + raise RuntimeError("R527_PINNED_SOURCES_MISSING") + # The product's CLI has no writable root-prefix option. This test only + # exercises RootTransaction(temp_root) imported as a class, never main(). + source = ROOT_CODE.read_text(encoding="utf8") + if 't=RootTransaction() # No CLI root-prefix/dry-run/test-mode option.' not in source: + raise RuntimeError("R527_PRODUCT_NATIVE_ENTRYPOINT_CHANGED") + +class PrivilegedRootFileTransaction(unittest.TestCase): + @classmethod + def setUpClass(cls): + assert_ephemeral_runner() + + def setUp(self): + self.fx = fixture_source.FakeFixture("test_success_stage_apply_rollback") + self.fx.setUp() + self.addCleanup(self.fx.tearDown) + self.root = self.fx.root + self.tx = self.fx.tx + self.assertNotEqual(self.root, Path("/")) + self.assertTrue(self.root.resolve().is_relative_to(BASE.resolve())) + self.assertEqual(self.root.stat().st_uid, 0) + self.assertEqual(self.root.stat().st_gid, 0) + # Activates original native file owner/lock/mode checks, but all + # writes continue to target self.root, not /. + self.tx.native = True + self.assertNotEqual(self.tx.root, Path("/")) + self.assertTrue(self.tx.meta.resolve().is_relative_to(self.root.resolve())) + self.assertTrue(self.tx.pending.resolve().is_relative_to(self.root.resolve())) + self.assertEqual(self.fx.meta.stat().st_uid, 0) + self.assertEqual(stat.S_IMODE(self.fx.meta.stat().st_mode), 0o644) + + def assert_old(self): + self.fx.all_old() + + def assert_new(self): + self.fx.all_new() + + def test_01_stage_real_root_owned_pending_backup(self): + self.assert_old() + outcome = self.tx.stage(self.fx.pkg) + self.assertEqual(outcome["status"], "STAGED") + self.assertEqual(self.tx.status()["status"], "STAGED") + self.assertEqual(self.tx.pending.stat().st_uid, 0) + self.assertEqual(stat.S_IMODE(self.tx.pending.stat().st_mode), 0o700) + record = self.tx.pending / "manifest.json" + self.assertEqual(record.stat().st_uid, 0) + self.assertEqual(stat.S_IMODE(record.stat().st_mode), 0o600) + self.assert_old() + self.assertEqual(self.tx.rollback()["status"], "ROLLED_BACK") + self.assert_old() + + def test_02_apply_native_root_modes_and_reversible_ownership(self): + self.tx.stage(self.fx.pkg) + self.assertEqual(self.tx.apply()["status"], "APPLIED") + self.assert_new() + for _, _, target, _, _, mode in self.fx.files: + p = self.root / target + self.assertEqual(p.stat().st_uid, 0) + self.assertEqual(p.stat().st_gid, 0) + self.assertEqual(stat.S_IMODE(p.stat().st_mode), mode) + self.assertEqual(self.fx.meta.stat().st_uid, 0) + self.assertEqual(stat.S_IMODE(self.fx.meta.stat().st_mode), 0o644) + self.assertEqual(self.tx.rollback()["status"], "ROLLED_BACK") + self.assert_old() + + def test_03_mid_apply_1_auto_rollback(self): + self.fault_recover(1) + + def test_04_mid_apply_2_auto_rollback(self): + self.fault_recover(2) + + def test_05_mid_apply_3_auto_rollback(self): + self.fault_recover(3) + + def test_06_mid_apply_4_auto_rollback(self): + self.fault_recover(4) + + def fault_recover(self, fail_after): + self.tx.stage(self.fx.pkg) + with self.assertRaisesRegex(RuntimeError, "INJECTED_TEST_FAILURE"): + self.tx.apply(fail_after=fail_after) + self.assert_old() + self.assertEqual(self.tx.status()["status"], "ROLLED_BACK") + + def test_07_reject_unknown_protected_file_before_stage(self): + victim = self.root / self.fx.files[0][2] + victim.write_bytes(b"FOREIGN_ROOT_FILE_NOT_OWNED") + with self.assertRaisesRegex(RuntimeError, "OLD_FILE_SHA"): + self.tx.stage(self.fx.pkg) + self.assertEqual(victim.read_bytes(), b"FOREIGN_ROOT_FILE_NOT_OWNED") + self.assertFalse(self.tx.pending.exists()) + + def test_08_reject_symlink_protected_target(self): + victim = self.root / self.fx.files[0][2] + victim.unlink() + victim.symlink_to(self.fx.meta) + with self.assertRaisesRegex(RuntimeError, "MISSING_OR_SYMLINK"): + self.tx.stage(self.fx.pkg) + self.assertTrue(victim.is_symlink()) + self.assertFalse(self.tx.pending.exists()) + + def test_09_reject_symlink_source_before_stage(self): + candidate = self.fx.pkg / "system/encrypted_selective_dns.py" + candidate.unlink() + candidate.symlink_to(self.fx.pkg / "app/direct_method_helper.sh") + with self.assertRaisesRegex(RuntimeError, "MISSING_OR_SYMLINK"): + self.tx.stage(self.fx.pkg) + self.assertFalse(self.tx.pending.exists()) + self.assert_old() + + def test_10_reject_foreign_pending_mutation(self): + self.tx.stage(self.fx.pkg) + victim = self.tx.pending / "new/encrypted_controller" + victim.write_bytes(b"FOREIGN_PENDING_MUTATION") + with self.assertRaisesRegex(RuntimeError, "PENDING_NEW_SHA"): + self.tx.apply() + self.assert_old() + + def test_11_reject_stale_second_stage(self): + self.tx.stage(self.fx.pkg) + with self.assertRaisesRegex(RuntimeError, "SLOT_ALREADY_EXISTS"): + self.tx.stage(self.fx.pkg) + self.assert_old() + + def test_12_commit_without_live_acceptance_forbidden(self): + self.tx.stage(self.fx.pkg) + self.tx.apply() + # The fake Root fixture lives under runner-owned GITHUB_WORKSPACE. + # R783's single-descriptor reader rejects its untrusted ancestor + # before it would check the missing receipt leaf. Both are narrowly + # valid fail-closed causes; neither may commit or mutate the baseline. + with self.assertRaisesRegex(RuntimeError, + "NATIVE_ACCEPTANCE_MISSING|RECEIPT_PARENT_OWNER"): + self.tx.commit(self.root / "not-a-real-acceptance.json") + self.assertEqual(self.tx.status()["status"], "APPLIED") + self.assertEqual(self.tx.rollback()["status"], "ROLLED_BACK") + self.assert_old() + + def test_13_reject_foreign_metadata_edit_before_apply(self): + self.tx.stage(self.fx.pkg) + self.fx.meta.write_text('{"schema":1,"version":"FOREIGN"}') + with self.assertRaisesRegex(RuntimeError, "FOREIGN_METADATA_CHANGE"): + self.tx.apply() + self.assertEqual(json.loads(self.fx.meta.read_text())["version"], "FOREIGN") + + def test_14_reject_foreign_edit_during_rollback(self): + self.tx.stage(self.fx.pkg) + self.tx.apply() + victim = self.root / self.fx.files[1][2] + victim.write_bytes(b"FOREIGN_AFTER_ROOT_APPLY") + with self.assertRaisesRegex(RuntimeError, "FOREIGN_TARGET_MODIFICATION"): + self.tx.rollback() + self.assertEqual(victim.read_bytes(), b"FOREIGN_AFTER_ROOT_APPLY") + self.assertEqual(self.tx.status()["status"], "APPLIED") + + def test_15_native_uid_mode_guard_is_in_effect(self): + marker = self.root / "etc/directinternetmethod.uid" + marker.write_text("1000\n") + self.assertEqual(marker.stat().st_uid, 0) + self.fx.meta.chmod(0o600) # old backend identity requires 0644 + with self.assertRaisesRegex(RuntimeError, "METADATA_MODE"): + self.tx.stage(self.fx.pkg) + self.assertFalse(self.tx.pending.exists()) + + def test_16_root_lock_parent_and_file_owner_enforced(self): + self.tx.stage(self.fx.pkg) + self.assertEqual(self.tx.lock.stat().st_uid, 0) + self.assertEqual(stat.S_IMODE(self.tx.lock.stat().st_mode), 0o600) + self.assertEqual(self.tx.status()["status"], "STAGED") + self.assert_old() + + +if __name__ == "__main__": + # Runs ONLY on disposable GitHub Ubuntu runners. No preflight-mode bypass. + suite = unittest.defaultTestLoader.loadTestsFromTestCase(PrivilegedRootFileTransaction) + result = unittest.TextTestRunner(verbosity=2).run(suite) + print("R527_REAL_UID0_ISOLATED_ROOT_TRANSACTION", + result.testsRun, + "PASS" if result.wasSuccessful() else "FAIL", + "NO_REAL_INSTALL_DNS_SYSTEMD_OR_TAILSCALE") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r528_ephemeral_native_root_cli_contract.py b/tests/r528_ephemeral_native_root_cli_contract.py new file mode 100644 index 00000000..6b6b471e --- /dev/null +++ b/tests/r528_ephemeral_native_root_cli_contract.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""R528 GitHub disposable Ubuntu VM: real absolute-root CLI stage/apply/rollback. + +NO production installation. This runs only in an ephemeral GitHub-hosted +ubuntu-latest VM with root privileges and a verified empty DIM state. It +creates the exact pinned v1.5.2 protected baseline at /usr/lib, /var/lib and +/etc in THAT VM using bytes from the official v1.5.4 Linux release ZIP. +Then runs the UNMODIFIED product RootTransaction() bound to real "/" with +actual native owner checks, filesystem locking, fsync, mode, stage, apply +and rollback. It NEVER invokes network service, GUI, DNS, Tailscale, NFT, +VPN, user installer, or a release operation. +""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import pwd +import stat +import sys +import zipfile + +BASE=Path(__file__).resolve().parents[1] +STABLE_SHA="4784fcd44ed3d1b40103bca140b2c0066784f794d07c3ae1f525334766a553c7" +CANDIDATE_SHA="f5506e1ae0092a20071b8d63e770adbf6cde3aa0701cc6a1b4f1e925c74cafb6" +STABLE_ARCHIVE="DirectInternetMethod_1.5.4_Linux_x86_64.zip" +CANDIDATE_ARCHIVE="DirectInternetMethod_1.5.7_Linux_x86_64.zip" +STABLE_PREFIX="DirectInternetMethod_1.5.4_Linux_x86_64/" +CANDIDATE_PREFIX="DirectInternetMethod_1.5.7_Linux_x86_64/" +SOURCE=BASE/"linux/system/protected_upgrade_transaction.py" +HOSTED_WORKSPACE=Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod") +HOSTED_TEMP=Path("/home/runner/work/_temp") +MAPPING={ + "selective_dns.py":"system/selective_dns.py", + "control.sh":"system/control.sh", + "direct_hosts.txt":"app/hosts.txt", + "adult_hosts_fallback.txt":"app/adult_hosts_fallback.txt", + "strong_override_hosts.txt":"app/strong_override_hosts.txt", + "ctrld":"runtime/ctrld", + "nfqws":"runtime/nfqws", + "LICENSE-ctrld.txt":"licenses/LICENSE-ctrld.txt", + "LICENSE-zapret.txt":"licenses/LICENSE-zapret.txt", + "direct_method_helper.sh":"app/direct_method_helper.sh", + "uninstall_system.sh":"system/uninstall_system.sh", +} + +def sha(raw:bytes)->str: + return hashlib.sha256(raw).hexdigest().lower() + +def fsha(path:Path)->str: + h=hashlib.sha256() + with path.open("rb") as f: + for piece in iter(lambda:f.read(1024*1024),b""): + h.update(piece) + return h.hexdigest() + +def need(condition,why): + if not condition:raise RuntimeError("R528_FAIL_CLOSED_"+why) + +def module(): + spec=importlib.util.spec_from_file_location("r528_real_product_root",SOURCE) + need(spec is not None and spec.loader is not None,"SOURCE_IMPORT_UNAVAILABLE") + m=importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + +def prerequisites(): + need(sys.platform.startswith("linux") and os.geteuid()==0,"ROOT_LINUX_REQUIRED") + need(os.environ.get("CI")=="true" and os.environ.get("GITHUB_ACTIONS")=="true", + "DISPOSABLE_GITHUB_VM_REQUIRED") + need(os.environ.get("GITHUB_REPOSITORY")=="GOD13emad/DirectInternetMethod", + "REPOSITORY_AUTHORITY") + need(os.environ.get("RUNNER_ENVIRONMENT")=="github-hosted", + "NOT_HOSTED_RUNNER") + workspace=os.environ.get("GITHUB_WORKSPACE") + runner_temp=os.environ.get("RUNNER_TEMP") + need(workspace==str(HOSTED_WORKSPACE) and BASE==HOSTED_WORKSPACE, + "WORKSPACE_AUTHORITY") + need(runner_temp==str(HOSTED_TEMP) and HOSTED_TEMP.is_dir() and + not HOSTED_TEMP.is_symlink(),"RUNNER_TEMP_AUTHORITY") + need(json.loads((BASE/"RELEASE.json").read_text())["version"]=="1.5.4", + "STABLE_RELEASE_AUTHORITY") + stable=HOSTED_TEMP/"DIM_R528_STABLE.zip" + candidate=BASE/"delivery"/CANDIDATE_ARCHIVE + need(stable.is_file() and fsha(stable)==STABLE_SHA,"PINNED_PUBLIC_STABLE_ZIP") + need(candidate.is_file() and fsha(candidate)==CANDIDATE_SHA, + "PINNED_RC1_SOURCE_ZIP") + # This job must run in a fresh standalone GH VM. Never touch any existing + # DIM state, even if files appear to be owned by this test revision. + must_absent=[ + "/usr/lib/directinternetmethod", + "/var/lib/directinternetmethod", + "/etc/directinternetmethod.uid", + "/run/directinternetmethod-action.lock", + "/run/directinternetmethod-encrypted", + "/run/directinternetmethod-encrypted-ctrld.toml", + "/run/directinternetmethod-selective", + "/sys/class/net/dimesd0", + ] + for path in must_absent: + p=Path(path) + need(not p.exists() and not p.is_symlink(),"PREEXISTING_PATH_"+p.name) + user=pwd.getpwnam("runner") + need(user.pw_uid>0 and user.pw_dir=="/home/runner","STANDARD_HOSTED_RUNNER_USER") + need(not Path("/home/runner/.local/share/DirectInternetMethod/directmethod/state.json").exists(), + "EXISTING_ACTIVE_USER_ENGINE") + return stable,candidate,HOSTED_TEMP,user + +def archive_verify(z:zipfile.ZipFile,prefix:str,expected_count:int): + names=z.namelist() + need(len(names)==expected_count and len(names)==len(set(names)), + "ZIP_MEMBER_CARDINALITY") + need(z.testzip() is None,"ZIP_CRC") + for n in names: + posix=PurePosixPath(n) + need(n.startswith(prefix) and not posix.is_absolute() and + ".." not in posix.parts and "\\" not in n,"ZIP_PATH_TRAVERSAL") + member=z.getinfo(n) + mode=(member.external_attr>>16)&0xffff + need(not stat.S_ISLNK(mode),"ZIP_SYMLINK_DENIED") + +def stage_exact_candidate(candidate_zip:Path,temp:Path)->Path: + extract=temp/"DIM_R528_CANDIDATE" + need(not extract.exists() and not extract.is_symlink(),"TEMP_SOURCE_COLLISION") + extract.mkdir(mode=0o700) + with zipfile.ZipFile(candidate_zip) as z: + archive_verify(z,CANDIDATE_PREFIX,30) + manifest=json.loads(z.read(CANDIDATE_PREFIX+"PACKAGE_MANIFEST.json")) + need(manifest.get("version")=="1.5.7" and len(manifest.get("files",[]))==28, + "RC1_PACKAGE_VERSION_MANIFEST") + for f in manifest["files"]: + relative=f["file"] + need(PurePosixPath(relative).as_posix()==relative and + ".." not in PurePosixPath(relative).parts,"RC1_PACKAGE_PATH") + raw=z.read(CANDIDATE_PREFIX+relative) + need(len(raw)==f["bytes"] and sha(raw)==f["sha256"].lower(), + "RC1_PACKAGE_FILE_HASH") + z.extractall(extract) + src=extract/CANDIDATE_PREFIX.rstrip("/") + need(src.is_dir(),"CANDIDATE_EXTRACTION") + return src + +def seed_real_protected_root(stable_zip:Path,user,module): + # Pre-checked empty paths. The runner is disposable and this setup never + # alters a human workstation or another project. No service is started. + root=Path("/usr/lib/directinternetmethod") + root.mkdir(mode=0o755) + baseline={} + with zipfile.ZipFile(stable_zip) as z: + archive_verify(z,STABLE_PREFIX,24) + items=list(module.STATIC_ROOT.items()) + items.extend((row[2],row[3]) for row in module.FILES if row[3] is not None) + need(len(items)==11,"R528_BASELINE_FILES_CHANGED") + for path,want in items: + member=STABLE_PREFIX+MAPPING[Path(path).name] + raw=z.read(member) + need(sha(raw)==want.lower(),"PUBLISHED_BASELINE_MISMATCH_"+Path(path).name) + dest=Path("/")/path + need(not dest.exists() and not dest.is_symlink(), + "BASELINE_TARGET_ALREADY_EXISTS_"+dest.name) + dest.parent.mkdir(parents=True,exist_ok=True) + dest.write_bytes(raw) + mode=0o755 if dest.suffix==".sh" or dest.name in ("ctrld","nfqws","selective_dns.py") else 0o644 + dest.chmod(mode) + need(dest.stat().st_uid==0 and dest.stat().st_gid==0, + "REAL_NATIVE_ROOT_OWNERSHIP") + baseline[str(dest)]=sha(raw) + base=Path("/var/lib/directinternetmethod") + base.mkdir(mode=0o755) + meta=base/"install.json" + before={"schema":1,"version":"1.5.2","user":"runner","uid":user.pw_uid,"home":user.pw_dir} + meta_bytes=(json.dumps(before,sort_keys=True)+"\n").encode() + meta.write_bytes(meta_bytes) + meta.chmod(0o644) + uidfile=Path("/etc/directinternetmethod.uid") + need(not uidfile.exists() and not uidfile.is_symlink(),"UID_MARKER_PREEXISTS") + uidfile.write_text(str(user.pw_uid)+"\n") + uidfile.chmod(0o644) + baseline[str(meta)]=sha(meta_bytes) + baseline[str(uidfile)]=sha(uidfile.read_bytes()) + return baseline + +def run_native(): + stable,candidate,temp,user=prerequisites() + network_pre={ + "resolv":fsha(Path("/etc/resolv.conf")), + "resolvTarget":os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None, + "hosts":fsha(Path("/etc/hosts")), + } + m=module() + need(m.TARGET_VERSION=="1.5.7" and + m.CURRENT_BACKEND_VERSION=="1.5.2","NATIVE_PRODUCT_VERSION") + source=stage_exact_candidate(candidate,temp) + baseline=seed_real_protected_root(stable,user,m) + t=m.RootTransaction() # EXACT PRODUCT ABSOLUTE "/" ROOT, NOT PREFIX MOCK + need(t.native and t.root==Path("/"),"REAL_NATIVE_ROOT_MODE") + need(t.status()["status"]=="NO_PENDING_UPGRADE","ROOT_INITIAL_STATE") + need(t.stage(source)["status"]=="STAGED","ROOT_STAGE") + need(t.status()["status"]=="STAGED","ROOT_STAGE_STATUS") + for path,hash_expected in baseline.items(): + need(fsha(Path(path))==hash_expected,"ROOT_STAGE_MODIFIED_BASELINE") + pending=Path("/var/lib/directinternetmethod/upgrade-pending") + need(pending.is_dir() and pending.stat().st_uid==0 and + stat.S_IMODE(pending.stat().st_mode)==0o700,"ROOT_PENDING_OWNER_MODE") + need(t.apply()["status"]=="APPLIED","ROOT_APPLY") + need(t.status()["status"]=="APPLIED","ROOT_APPLY_STATUS") + for name,relative,target,old,new,mode in m.FILES: + file=Path("/")/target + need(file.is_file() and not file.is_symlink() and + fsha(file)==new.lower() and file.stat().st_uid==0 and + stat.S_IMODE(file.stat().st_mode)==mode, + "REAL_ROOT_UPGRADED_FILE_"+name) + current=json.loads(Path("/var/lib/directinternetmethod/install.json").read_text()) + need(current["version"]=="1.5.7","REAL_ROOT_METADATA_NOT_ACTIVATED") + try: + t.commit(temp/"NO_NATIVE_ACCEPTANCE_RECEIPT.json") + raise RuntimeError("R528_BAD_ACCEPTANCE_WAS_ALLOWED") + except RuntimeError as exc: + # R783: the disposable native runner puts its absent receipt under a + # runner-owned temporary ancestor. The single-descriptor receipt read + # must fail closed before opening that leaf; an untrusted parent and + # a missing trusted leaf are both narrowly acceptable reject causes. + need(("FAIL_CLOSED_NATIVE_ACCEPTANCE_MISSING" in str(exc) or + "FAIL_CLOSED_RECEIPT_PARENT_OWNER" in str(exc)), + "UNEXPECTED_ACCEPTANCE_REJECTION") + need(t.rollback()["status"]=="ROLLED_BACK","ROOT_ROLLBACK") + need(t.status()["status"]=="ROLLED_BACK","ROOT_ROLLBACK_STATE") + for path,want in baseline.items(): + need(fsha(Path(path))==want,"ROOT_ROLLBACK_BYTE_DRIFT_"+Path(path).name) + for name,relative,target,old,new,mode in m.FILES: + if old is None: + p=Path("/")/target + need(not p.exists() and not p.is_symlink(),"NEW_ROOT_FILE_LEFT_"+name) + for path in m.STATIC_ROOT: + need(fsha(Path("/")/path)==m.STATIC_ROOT[path].lower(), + "STATIC_BACKEND_CHANGED_"+Path(path).name) + need(fsha(Path("/etc/resolv.conf"))==network_pre["resolv"] and + fsha(Path("/etc/hosts"))==network_pre["hosts"] and + (os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None)==network_pre["resolvTarget"], + "NETWORK_BASELINE_CHANGED") + need(json.loads((BASE/"RELEASE.json").read_text())["version"]=="1.5.4", + "PUBLIC_RELEASE_METADATA_CHANGED") + report={ + "revision":"R528","status":"PASS_REAL_ROOT_STAGE_APPLY_ROLLBACK_ON_EPHEMERAL_GITHUB_VM", + "productionMachineTouched":False, + "nativeRoot":True,"effectiveUid":os.geteuid(),"protectedPreVersion":"1.5.2", + "protectedPostRollbackVersion":"1.5.2", + "protectedCandidateAppliedBeforeRollback":"1.5.7", + "publicStablePreserved":"v1.5.4","pinnedSource":CANDIDATE_SHA, + "pinnedPublishedStable":STABLE_SHA, + "rootOwnedFilesRestored":len(baseline), + "newRootTargetsRemoved":2, + "upgradeSlotRetainedForAudit":pending.exists(), + "metadataRollbackRestored":True, + "networkResolvAndHostsUnchanged":True, + "nativeEncryptedDnsAndGuiAcceptance":"NOT_TESTED", + } + print(json.dumps(report,sort_keys=True)) + return 0 + +if __name__=="__main__": + try:sys.exit(run_native()) + except Exception as e: + print(json.dumps({"revision":"R528","status":"FAIL_CLOSED","error":str(e)[:400]}),file=sys.stderr) + sys.exit(84) diff --git a/tests/r529_ephemeral_root_user_transaction.py b/tests/r529_ephemeral_root_user_transaction.py new file mode 100644 index 00000000..21e37f90 --- /dev/null +++ b/tests/r529_ephemeral_root_user_transaction.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""R529: native Root + unprivileged User transaction across one disposable VM. + +Not a physical network/GUI acceptance. Use exact v1.5.4 published ZIP user and +protected Root code, exact 1.5.7 candidate ZIP, unmodified RootTransaction("/") +and real subprocess-driven user transaction / inline INSTALL.json production +fragment. Both transactions must rollback byte-for-byte. NO live network or +systemd, No Tailscale changes, no installation onto a user's physical computer. +All operations are refused outside a clean GitHub-hosted Ubuntu VM. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import shutil +import stat +import subprocess +import sys +import zipfile + +BASE = Path(__file__).resolve().parents[1] +R528 = BASE / "tests/r528_ephemeral_native_root_cli_contract.py" +R526 = BASE / "tests/r526_installer_metadata_contract.py" +USER_TX_REL = "app/user_update_transaction.py" +HOME_REL = ".local/share/DirectInternetMethod" +USER_MUTABLE = ( + "settings.json", "strategy.txt", "scope.txt", "custom-hosts.txt", + "adult-enabled.txt", "adult-hosts.txt", "adult-hosts.meta.json", + "encrypted-dns-optin.txt", "selective-dns-optin.txt", +) +USER_MAP = ( + ("app/direct_internet_method.py", "direct_internet_method.py", 0o755), + ("app/router_gateway.py", "router_gateway.py", 0o755), + ("app/router_gateway_core.py", "router_gateway_core.py", 0o644), + ("app/direct-internet-method.svg", "direct-internet-method.svg", 0o644), + ("uninstall.sh", "uninstall.sh", 0o755), + ("app/user_uninstall_preserve.py", "user_uninstall_preserve.py", 0o755), + ("app/user_update_transaction.py", "user_update_transaction.py", 0o755), +) + +def require(ok, code): + if not ok: + raise RuntimeError("R529_FAIL_CLOSED_" + code) + +def digest(b): + return hashlib.sha256(b).hexdigest() + +def hash_file(p: Path): + h = hashlib.sha256() + with p.open("rb") as f: + for chunk in iter(lambda: f.read(1048576), b""): + h.update(chunk) + return h.hexdigest() + +def module(path: Path, label: str): + spec = importlib.util.spec_from_file_location(label, path) + require(spec is not None and spec.loader is not None, "IMPORT_" + label) + instance = importlib.util.module_from_spec(spec) + spec.loader.exec_module(instance) + return instance + +def ownership(path: Path, user, mode: int): + os.chown(path, user.pw_uid, user.pw_gid) + os.chmod(path, mode) + require(path.stat().st_uid == user.pw_uid, "USER_FILE_OWNER") + require(stat.S_IMODE(path.stat().st_mode) == mode, "USER_FILE_MODE") + +def write_owned(path: Path, body: bytes, user, mode: int = 0o600): + require(not path.exists() and not path.is_symlink(), "UNKNOWN_USER_TARGET_" + path.name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(body) + ownership(path, user, mode) + +def require_clean_home(user): + home = Path(user.pw_dir) + require(home == Path("/home/runner") and home.is_dir(), "RUNNER_HOME") + for rel in (HOME_REL, ".local/state/DirectInternetMethod/update-pending", + ".local/state/DirectInternetMethod"): + p = home / rel + require(not p.exists() and not p.is_symlink(), + "USER_PREEXISTING_STATE_" + p.name) + require(not (home / ".local/bin/direct-internet-method").exists(), "USER_LAUNCHER_PREEXISTS") + +def prepare_user_from_official(stable: Path, user): + home = Path(user.pw_dir) + app = home / HOME_REL + need_dirs = [ + home / ".local", home / ".local/share", app, + app / "router_gateway", + ] + for d in need_dirs: + if not d.exists(): + d.mkdir(mode=0o700) + ownership(d, user, 0o700) + require(d.is_dir() and not d.is_symlink(), "FOREIGN_DIR_" + d.name) + require(d.stat().st_uid == user.pw_uid or d == home, + "UNKNOWN_PARENT_OWNER_" + d.name) + original = {} + with zipfile.ZipFile(stable) as z: + pkg = "DirectInternetMethod_1.5.4_Linux_x86_64/" + for rel, installed, mode in USER_MAP: + raw = z.read(pkg + rel) + dest = app / installed + write_owned(dest, raw, user, mode) + original[str(dest)] = digest(raw) + vendor_provider = z.read(pkg + "app/router_gateway/providers.json") + custom = b'{"r529CustomProvider": true, "owner": "do-not-replace"}\n' + require(digest(custom) != digest(vendor_provider), "PROVIDER_IS_NOT_CUSTOM") + provider_path = app / "router_gateway/providers.json" + write_owned(provider_path, custom, user) + for name in USER_MUTABLE: + value = { + "settings.json": b'{"r529PrivateSettings":true}\n', + "strategy.txt": b"balanced\n", + "scope.txt": b"targeted\n", + "custom-hosts.txt": b"r529-private.example\n", + "adult-enabled.txt": b"0\n", + "adult-hosts.txt": b"r529-custom-adult-list\n", + "adult-hosts.meta.json": b'{"source":"private"}\n', + "encrypted-dns-optin.txt": b"0\n", + "selective-dns-optin.txt": b"0\n", + }[name] + write_owned(app / name, value, user) + record = { + "schema": 2, "product": "Direct Internet Method", + "version": "1.5.4", "platform": "linux-x86_64", + "protectedBackendVersion": "1.5.2", + "managedProviderSha256": digest(vendor_provider), + "managedCodeSha256": { + installed: original[str(app / installed)] + for _, installed, _ in USER_MAP + }, + "managedLaunchersSha256": {}, + "networkMutationOnInstall": False, + } + meta = app / "INSTALL.json" + old = (json.dumps(record, sort_keys=True, indent=2) + "\n").encode() + write_owned(meta, old, user) + original[str(meta)] = digest(old) + user_data = { + str(p): hash_file(p) + for p in [provider_path, *(app / n for n in USER_MUTABLE)] + } + return app, original, user_data + +def make_source_runner_owned(src: Path, user): + # The exact R528 archive extraction is from the verified SHA-pinned ZIP, + # into a disposable path; avoid broad filesystem mutations. + cur = src + while cur.name != "DIM_R528_CANDIDATE": + require(cur.parent != cur, "SOURCE_PATH_ESCAPES_TEMP") + cur = cur.parent + for item in sorted([cur, *cur.rglob("*")], key=lambda p: len(p.parts)): + require(not item.is_symlink(), "SOURCE_EXTRACT_SYMLINK") + os.chown(item, user.pw_uid, user.pw_gid) + require(src.stat().st_uid == user.pw_uid, "PINNED_SOURCE_USER_OWNERSHIP") + require(cur.stat().st_uid == user.pw_uid, "SOURCE_ROOT_USER_OWNERSHIP") + +def as_runner(user, program: Path, *args, stdin=None, ok=True): + require(program.is_file() and not program.is_symlink(), "UNSAFE_PROGRAM") + cmd = [ + "/usr/sbin/runuser", "-u", user.pw_name, "--", + "/usr/bin/env", "HOME=" + user.pw_dir, + "PYTHONDONTWRITEBYTECODE=1", "/usr/bin/python3", "-B", str(program), + *map(str,args) + ] + completed = subprocess.run( + cmd, input=stdin, text=True, + capture_output=True, timeout=30, check=False, + ) + if ok: + require(completed.returncode == 0, + "USER_COMMAND_FAILURE_" + completed.stderr[-500:].replace(" ", "_")) + else: + require(completed.returncode != 0 and + ("FAIL_CLOSED" in completed.stderr or "Traceback" in completed.stderr), + "USER_NEGATIVE_GUARD_NOT_ENFORCED") + return completed + +def as_runner_inline(user, code: str, target: Path, helper: Path): + cmd = [ + "/usr/sbin/runuser", "-u", user.pw_name, "--", + "/usr/bin/env", "HOME=" + user.pw_dir, + "PYTHONDONTWRITEBYTECODE=1", "/usr/bin/python3", "-B", "-", + str(target), str(helper) + ] + result = subprocess.run(cmd, input=code + "\n", text=True, + capture_output=True, timeout=30) + require(result.returncode == 0, "INLINE_METADATA_FAIL_" + result.stderr[-420:].replace(" ", "_")) + +def unchanged_records(files): + for name, expected in files.items(): + p=Path(name) + require(p.is_file() and hash_file(p)==expected, "USER_ORIGINAL_CHANGED_"+p.name) + +def execute(): + # R528's hard guard proves this is a fresh hosted GitHub VM, not a user + # machine. Production root and HOME must be absent before any writes. + old, candidate, temp, user = module(R528, "r529_r528_guard").prerequisites() + r528 = module(R528, "r529_r528_helpers") + r526 = module(R526, "r529_r526_metadata") + require_clean_home(user) + require(Path("/usr/sbin/runuser").is_file(), "RUNUSER_UNAVAILABLE") + before_network={ + p:hash_file(Path(p)) for p in ("/etc/resolv.conf", "/etc/hosts") + } + before_network_link=os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None + rootmod = r528.module() + extracted = r528.stage_exact_candidate(candidate, temp) + make_source_runner_owned(extracted, user) + # Byte-pinned user baseline from public v1.5.4 release, not fabricated + # executable code or old code hashes. User's private settings are seeded + # and checked independently from executable bytes. + app, old_user, private = prepare_user_from_official(old, user) + root_before = r528.seed_real_protected_root(old, user, rootmod) + tx_script = extracted / USER_TX_REL + pending = Path(user.pw_dir) / ".local/state/DirectInternetMethod/update-pending" + root_tx = rootmod.RootTransaction() + require(root_tx.native and root_tx.root == Path("/"), + "ROOT_TRANSACTION_IS_NOT_REAL_ABSOLUTE") + require(root_tx.status()["status"]=="NO_PENDING_UPGRADE", "ROOT_PRESTATE") + require(not pending.exists(), "USER_PRESTATE_PENDING") + # User-first staged transaction before any protected root mutation. + as_runner(user, tx_script, "--stage", "--source", extracted) + require((pending / "manifest.json").is_file(), "USER_PENDING_STAGE") + manifest=json.loads((pending / "manifest.json").read_text()) + require(manifest["version"]=="1.5.7" and + manifest["providerManaged"] is False and + manifest["providerSha"] is None, + "CUSTOM_USER_PROVIDER_OWNERSHIP") + unchanged_records(old_user) + unchanged_records(private) + # Protected absolute-root stage/apply with actual root-owned files. + require(root_tx.stage(extracted)["status"]=="STAGED", "ROOT_STAGE") + require(root_tx.apply()["status"]=="APPLIED", "ROOT_APPLY") + require(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.7", "ROOT_NEW_METADATA") + # Real unprivileged User transaction CLI replaces managed code only. + as_runner(user, tx_script, "--apply", "--source", extracted) + for rel,row in manifest["managed"].items(): + p=app / rel + require(p.is_file() and hash_file(p)==row["sha"], "USER_MANAGED_APPLY_"+rel) + unchanged_records(private) + # This must NOT be mistaken for completion: metadata not yet activated. + rejected = as_runner(user, tx_script, "--commit", ok=False) + require("INSTALLED_VERSION_METADATA_NOT_VALID" in rejected.stderr, "METADATA_COMMIT_GUARD_FAILED") + require((pending/"manifest.json").is_file(), "PREMATURE_PENDING_REMOVAL") + unchanged_records(private) + # Only now run EXACT, unmodified install.sh Python fragment, on real + # unprivileged user identity against real upgraded root metadata. + installer=extracted/"install.sh" + require(installer.is_file() and not installer.is_symlink(), "INSTALLER_INVALID") + source=installer.read_text(encoding="utf8") + marker=r526.BEGIN + end=r526.END + require(source.count(marker)==1, "INSTALL_METADATA_ENTRY_ANCHOR") + tail=source.split(marker,1)[1] + require(tail.count(end)==1, "INSTALL_METADATA_TERMINATOR") + inline=tail.split(end,1)[0] + require(len(inline.splitlines())==64 and + 'transaction_helper.atomic_bytes(' in inline and + 'assert protected.get("version") == "1.5.7"' in inline, + "INSTALLER_METADATA_PROVENANCE_CHANGED") + as_runner_inline(user,inline,app/"INSTALL.json",tx_script) + record=json.loads((app/"INSTALL.json").read_text()) + require(record["version"]=="1.5.7" and + record["protectedBackendVersion"]=="1.5.7", + "TWO_SIDED_VERSION_METADATA") + require(record["managedProviderSha256"] is None and + record["preservedCustomProviderData"] is True and + record["updateMode"]=="IN_PLACE_SINGLE_INSTALL_DATA_PRESERVED", + "CUSTOM_PROVIDER_OR_UPDATER_METADATA") + require(stat.S_IMODE((app/"INSTALL.json").stat().st_mode)==0o600, + "USER_METADATA_ATOMIC_MODE") + as_runner(user, tx_script, "--status") + unchanged_records(private) + # No release acceptance is claimed; deliberately roll back BOTH sides. + # User rollback runs first and is verified before protected root rollback. + as_runner(user, tx_script, "--rollback") + require(not pending.exists(),"USER_PENDING_LEFT_AFTER_ROLLBACK") + unchanged_records(old_user) + unchanged_records(private) + require(root_tx.rollback()["status"]=="ROLLED_BACK", "ROOT_ROLLBACK") + for name,expected in root_before.items(): + require(hash_file(Path(name))==expected, "PROTECTED_ROOT_BASELINE_DRIFT_"+Path(name).name) + for _,_,dest,old_hash,_,_ in rootmod.FILES: + if old_hash is None: + require(not (Path("/")/dest).exists(),"NEW_PROTECTED_FILE_LEFT") + for name,expected in before_network.items(): + require(hash_file(Path(name))==expected, "NETWORK_MUTATED_"+name) + require((os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None)==before_network_link, + "RESOLV_CONF_SYMLINK_MUTATED") + require(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.2", "ROOT_FINAL_OLD_VERSION") + require(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.4", "USER_FINAL_OLD_VERSION") + require(json.loads((BASE/"RELEASE.json").read_text())["version"]=="1.5.4", "PUBLIC_TAG_AUTHORITY") + print(json.dumps({ + "revision":"R529", "status":"PASS_HOSTED_ROOT_USER_ACTUAL_TRANSACTION", + "productionMachineTouched":False,"hostedRunner":True, + "publicStable":"v1.5.4","candidate":"1.5.7", + "userTransaction":"STAGE_APPLY_INLINE_ATOMIC_METADATA_ROLLBACK", + "protectedRoot":"ABSOLUTE_ROOT_STAGE_APPLY_ROLLBACK", + "preVersions":"user1.5.4/root1.5.2", + "appliedVersion":"both1.5.7", + "postRollbackVersions":"user1.5.4/root1.5.2", + "oldUserCodeFilesVerified":len(old_user), + "protectedRootFilesVerified":len(root_before), + "mutablePrivateHashesVerified":len(private), + "userCustomProviderPreserved":True, + "userCommitBeforeMetadataRejected":True, + "userPendingSlotCleaned":True, + "protectedRootSlotRetainedForAudit":root_tx.pending.exists(), + "networkResolverFilesUnchanged":True, + "realPhysicalISPEncryptedDNS_GUI_Tailscale":"UNTESTED", + },sort_keys=True)) + return 0 + +if __name__=="__main__": + try:sys.exit(execute()) + except Exception as exc: + print(json.dumps({"revision":"R529","status":"FAIL_CLOSED", + "reason":str(exc)[:700]},sort_keys=True),file=sys.stderr) + sys.exit(85) diff --git a/tests/r532_ephemeral_root_watchdog_contract.py b/tests/r532_ephemeral_root_watchdog_contract.py new file mode 100644 index 00000000..75e9c701 --- /dev/null +++ b/tests/r532_ephemeral_root_watchdog_contract.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""R532 one-shot hosted systemd root watchdog V&V. Execute 'arm', then 'verify'. + +Both invocations are separate CI steps on an ephemeral GitHub-hosted Ubuntu VM. +The first starts a detached systemd monotonic timer BEFORE any candidate file +mutation, leaves actual protected Root+unprivileged User 1.5.7 staged and +applied, and EXITs. The second waits for the independent timer and verifies +byte-exact restoration to 1.5.2 Root / 1.5.4 User, without running rollback. +All installed products on physical hosts and network services are untouched. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import shutil +import stat +import subprocess +import sys +import time + +BASE = Path(__file__).resolve().parents[1] +R528 = BASE / "tests/r528_ephemeral_native_root_cli_contract.py" +R529 = BASE / "tests/r529_ephemeral_root_user_transaction.py" +R526 = BASE / "tests/r526_installer_metadata_contract.py" +WORKER_SOURCE = BASE / "tests/r532_root_watchdog_worker.py" +GUARD = Path("/run/dim-r532") +BASELINE = GUARD / "baseline.json" +ROOT_COPIED = GUARD / "root_tx.py" +USER_COPIED = GUARD / "user_tx.py" +WORKER_COPIED = GUARD / "worker.py" +WORKER_RETURN = GUARD / "timer-return.json" +UNIT = "dim-r532-watchdog" +TIMER = UNIT + ".timer" +SERVICE = UNIT + ".service" +ROOT_SHA = "239a0c21eb4a9564799a97afe440c120668cedcd49412e33d0ac78c8866820e7" +USER_SHA = "69ae3a76cfcd3bb251546ccd5237f3af081ed0477323a8f95737c8a4bc87b6f8" + +def sha(p:Path)->str: + h=hashlib.sha256() + with p.open("rb") as f: + for part in iter(lambda:f.read(1048576),b""):h.update(part) + return h.hexdigest() + +def need(ok,why): + if not ok: + raise RuntimeError("R532_HOSTED_FAIL_CLOSED_"+why) + +def mod(path,name): + spec=importlib.util.spec_from_file_location(name,path) + need(spec is not None and spec.loader is not None,"IMPORT_"+name) + x=importlib.util.module_from_spec(spec) + spec.loader.exec_module(x) + return x + +def run(cmd,secs=15): + v=subprocess.run(cmd,capture_output=True,text=True,timeout=secs,check=False) + need(v.returncode==0,"SYSTEMD_CMD_"+v.stderr.strip()[-200:].replace(" ","_")) + return v.stdout.strip() + +def ensure_guard_absent(): + need(not GUARD.exists() and not GUARD.is_symlink(),"UNOWNED_OR_PREEXISTING_GUARD") + for u in (TIMER,SERVICE): + test=subprocess.run(["/usr/bin/systemctl","show",u,"-p","LoadState","--value"], + capture_output=True,text=True,timeout=5) + need(test.stdout.strip() in ("not-found",""),"FOREIGN_SYSTEMD_UNIT_"+u) + +def verify_host(): + need(sys.platform.startswith("linux") and os.geteuid()==0,"REAL_ROOT_GITHUB_VM_REQUIRED") + need(os.environ.get("CI")=="true" and os.environ.get("GITHUB_ACTIONS")=="true", + "GITHUB_CI_REQUIRED") + need(os.environ.get("RUNNER_ENVIRONMENT")=="github-hosted" and + os.environ.get("GITHUB_REPOSITORY")=="GOD13emad/DirectInternetMethod", + "EPHEMERAL_REPO_RUNNER_REQUIRED") + need(os.environ.get("GITHUB_WORKSPACE")==str(BASE) and + BASE==Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod"), + "EXACT_WORKSPACE") + need(os.environ.get("RUNNER_TEMP")=="/home/runner/work/_temp" and + Path("/home/runner/work/_temp").is_dir(),"RUNNER_TEMP_REQUIRED") + need(pwd.getpwnam("runner").pw_dir=="/home/runner","TEST_RUNNER_USER") + need(Path("/proc/1/comm").read_text().strip()=="systemd","INDEPENDENT_SYSTEMD_PID1") + need(WORKER_SOURCE.is_file() and not WORKER_SOURCE.is_symlink(),"GUARD_SOURCE_MISSING") + +def own_root_copy(src:Path,dest:Path,mode:int): + need(src.is_file() and not src.is_symlink(),"COPIED_SOURCE_UNSAFE") + need(not dest.exists() and not dest.is_symlink(),"COPIED_TARGET_EXISTS") + shutil.copyfile(src,dest) + os.chown(dest,0,0) + os.chmod(dest,mode) + need(dest.stat().st_uid==0 and stat.S_IMODE(dest.stat().st_mode)==mode, + "COPIED_ROOT_FILE_AUTHORITY") + need(sha(src)==sha(dest),"COPY_SHA_CHANGED") + +def save_manifest(doc): + raw=(json.dumps(doc,indent=2,sort_keys=True)+"\n").encode() + fd=os.open(BASELINE,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,"wb") as out: + os.fchmod(out.fileno(),0o600) + out.write(raw);out.flush();os.fsync(out.fileno()) + need(BASELINE.stat().st_uid==0 and stat.S_IMODE(BASELINE.stat().st_mode)==0o600, + "BASELINE_ROOT_OWNERSHIP") + +def prepare_guard(source,user,root_hashes,user_hashes,mutables): + need(sha(source/"system/protected_upgrade_transaction.py")==ROOT_SHA, + "CANDIDATE_ROOT_PIN") + need(sha(source/"app/user_update_transaction.py")==USER_SHA, + "CANDIDATE_USER_PIN") + GUARD.mkdir(mode=0o711) + os.chown(GUARD,0,0) + os.chmod(GUARD,0o711) + own_root_copy(source/"system/protected_upgrade_transaction.py",ROOT_COPIED,0o500) + own_root_copy(source/"app/user_update_transaction.py",USER_COPIED,0o644) + own_root_copy(WORKER_SOURCE,WORKER_COPIED,0o500) + network={"/etc/resolv.conf":sha(Path("/etc/resolv.conf")), + "/etc/hosts":sha(Path("/etc/hosts"))} + absent=[ + "/usr/lib/directinternetmethod/encrypted_selective_dns.py", + "/usr/lib/directinternetmethod/encrypted_selective_policy.py"] + need(all(not Path(a).exists() for a in absent),"NEW_PROTECTED_ROOT_NOT_ABSENT") + doc={ + "schema":1,"revision":"R532","host":os.uname().nodename, + "productionMachineTouched":False, + "ownerUser":"runner","ownerUid":user.pw_uid, + "rootHelperSHA256":ROOT_SHA,"userHelperSHA256":USER_SHA, + "workerSHA256":sha(WORKER_SOURCE), + "expectedRootVersion":"1.5.2","expectedUserVersion":"1.5.4", + "originalAbsentProtected":absent, + "protected":root_hashes,"userCode":user_hashes,"userMutable":mutables, + "network":network, + "resolvLink":os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None + } + save_manifest(doc) + return doc + +def exact_inline(r526,src): + raw=(src/"install.sh").read_text(encoding="utf8") + marker=r526.BEGIN + need(raw.count(marker)==1,"INSTALLED_INLINE_ENTRY_COUNT") + tail=raw.split(marker,1)[1] + need(tail.count(r526.END)==1,"INSTALLED_INLINE_TERMINATOR") + code=tail.split(r526.END,1)[0] + need('transaction_helper.atomic_bytes(' in code and len(code.splitlines())==64, + "INSTALLED_METADATA_FRAGMENT_DRIFT") + return code + +def arm(): + ensure_guard_absent() + r528=mod(R528,"r532_seed_root") + r529=mod(R529,"r532_seed_user") + r526=mod(R526,"r532_inline") + stable,candidate,temp,user=r528.prerequisites() + r529.require_clean_home(user) + need(Path("/usr/sbin/runuser").is_file(),"RUNUSER_REQUIRED") + extracted=r528.stage_exact_candidate(candidate,temp) + r529.make_source_runner_owned(extracted,user) + app,old_user,private=r529.prepare_user_from_official(stable,user) + module=r528.module() + root_before=r528.seed_real_protected_root(stable,user,module) + doc=prepare_guard(extracted,user,root_before,old_user,private) + run(["/usr/bin/systemd-run","--quiet","--unit="+UNIT, + "--on-active=35s","--timer-property=AccuracySec=1s", + "/usr/bin/python3","-B",str(WORKER_COPIED)]) + timer_state=run(["/usr/bin/systemctl","show",TIMER,"-p","ActiveState","--value"]) + need(timer_state=="active","INDEPENDENT_MONOTONIC_TIMER_NOT_ARMED") + trigger=run(["/usr/bin/systemctl","show",TIMER,"-p","Triggers","--value"]) + need(SERVICE in trigger,"TIMER_NOT_CONNECTED_TO_OWNED_WORKER") + # Everything below occurs AFTER detached root watchdog is independently armed. + user_script=extracted/"app/user_update_transaction.py" + root_tx=module.RootTransaction() + need(root_tx.native and root_tx.status()["status"]=="NO_PENDING_UPGRADE", + "PRE_MUTATION_ROOT_DIRTY") + need(not Path("/home/runner/.local/state/DirectInternetMethod/update-pending").exists(), + "PRE_MUTATION_USER_DIRTY") + r529.as_runner(user,user_script,"--stage","--source",extracted) + need(root_tx.stage(extracted)["status"]=="STAGED","ROOT_STAGE") + need(root_tx.apply()["status"]=="APPLIED","ROOT_APPLY") + r529.as_runner(user,user_script,"--apply","--source",extracted) + r529.as_runner_inline(user,exact_inline(r526,extracted), + app/"INSTALL.json",user_script) + need(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.7", + "NEW_USER_METADATA") + need(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.7", + "NEW_ROOT_METADATA") + need(root_tx.status()["status"]=="APPLIED","ROOT_APPLIED_PENDING") + need((Path("/home/runner/.local/state/DirectInternetMethod/update-pending/manifest.json")).is_file(), + "USER_SLOT_MISSING") + # Leave both sides unapplied to no other action; arm process exits now. + # The next CI step is a DIFFERENT process; only systemd timer may rollback. + print("PASS_R532_WATCHDOG_ARMED_BEFORE_MUTATION_THEN_MAIN_EXIT") + print("WORKER_ROOT_OWNED_SHA",doc["workerSHA256"]) + print("TIMER_UNIT",TIMER,"SERVICE_UNIT",SERVICE) + print("PRE_TIMER_VERSIONS", "root=1.5.7","user=1.5.7") + +def verify(): + need(GUARD.is_dir() and GUARD.stat().st_uid==0,"EXPECTED_GUARD_NOT_FOUND") + need(BASELINE.is_file() and BASELINE.stat().st_uid==0,"ROOT_BASELINE_MISSING") + check=json.loads(BASELINE.read_text()) + need(check["revision"]=="R532" and check["host"]==os.uname().nodename, + "BASELINE_DIFFERENT_RUN") + need(check["workerSHA256"]==sha(WORKER_COPIED),"WORKER_SOURCE_DRIFT") + need(sha(ROOT_COPIED)==ROOT_SHA and sha(USER_COPIED)==USER_SHA, + "OWNER_GUARD_COPY_SHA_DRIFT") + start=time.monotonic() + while not WORKER_RETURN.exists() and time.monotonic()-start<85: + time.sleep(1) + need(WORKER_RETURN.is_file() and not WORKER_RETURN.is_symlink(), + "SYSTEMD_TIMER_DID_NOT_ACTIVATE") + receipt=json.loads(WORKER_RETURN.read_text()) + need(receipt["status"]=="PASS_ROOT_USER_BYTE_EXACT_AUTONOMOUS_ROLLBACK", + "TIMER_WORKER_NOT_SUCCESS:"+str(receipt.get("failure","NONE"))) + need(receipt.get("actualEffectiveUid")==0 and receipt.get("recoveryAttempted"), + "ROOT_TIMER_EXECUTION_UNPROVEN") + need(receipt["userRollback"]=="PASS" and receipt["protectedRootRollback"]=="PASS", + "TWO_SIDED_ROLLBACK_MISSING") + need(receipt["restoredFiles"]["userMutable"]>=8, + "USER_MUTABLE_VERIFICATION_INSUFFICIENT") + need(receipt["restoredFiles"]["protected"]>=11, + "ROOT_BASELINE_VERIFICATION_INSUFFICIENT") + require_versions={ + "protected":json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"], + "user":json.loads(Path("/home/runner/.local/share/DirectInternetMethod/INSTALL.json").read_text())["version"], + } + need(require_versions=={"protected":"1.5.2","user":"1.5.4"}, + "LIVE_FINAL_VERSION_DRIFT") + for cls in ("protected","userCode","userMutable","network"): + for name,want in check[cls].items(): + need(sha(Path(name))==want,"POST_TIMER_SHA_DRIFT_"+cls) + need(not Path("/home/runner/.local/state/DirectInternetMethod/update-pending").exists(), + "USER_SLOT_NOT_CLEAN") + need(not Path("/etc/systemd/system/directinternetmethod-start.service").exists(), + "UNKNOWN_GLOBAL_PRODUCTION_SERVICE") + need(not Path("/run/directinternetmethod-encrypted").exists(), + "UNEXPECTED_ENCRYPTED_DNS_SESSION") + print("PASS_R532_INDEPENDENT_SYSTEMD_ROOT_TIMER_RESTORED_BOTH_SIDES") + print("WORKER_RECEIPT_SHA256",sha(WORKER_RETURN)) + print("RESTORED_SHA_GROUPS",json.dumps(receipt["restoredFiles"],sort_keys=True)) + print("PREVIOUS_PUBLIC_STABLE_UNCHANGED_v1.5.4") + +def main(): + need(len(sys.argv)==2 and sys.argv[1] in ("arm","verify"),"CLI_ACTION") + verify_host() + if sys.argv[1]=="arm":arm() + else:verify() + +if __name__=="__main__": + try: + main() + except BaseException as e: + print("R532_HOSTED_TIMER_FAIL_CLOSED",type(e).__name__,str(e)[:450],file=sys.stderr) + sys.exit(89) diff --git a/tests/r532_root_watchdog_worker.py b/tests/r532_root_watchdog_worker.py new file mode 100644 index 00000000..6b1cebb3 --- /dev/null +++ b/tests/r532_root_watchdog_worker.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""R532 independent root-owned timer worker on disposable GitHub Ubuntu VM. + +Copied to /run/dim-r532 with root ownership BEFORE candidate mutation. +Executes only when systemd transient one-shot monotonic timer fires. +Never changes network, resolver, service, Tailscale or user preferences. +Refuses foreign files; product user/root transactions do their own checks. +NOT A PRODUCTION WATCHDOG; only tests the release gate in an ephemeral VM. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import stat +import subprocess +import sys +import time + +GUARD = Path("/run/dim-r532") +MANIFEST = GUARD / "baseline.json" +RESULT = GUARD / "timer-return.json" +ROOT_HELPER = GUARD / "root_tx.py" +USER_HELPER = GUARD / "user_tx.py" +PIN_ROOT = "239a0c21eb4a9564799a97afe440c120668cedcd49412e33d0ac78c8866820e7" +PIN_USER = "69ae3a76cfcd3bb251546ccd5237f3af081ed0477323a8f95737c8a4bc87b6f8" + +def digest(path:Path): + h=hashlib.sha256() + with path.open("rb") as inp: + for p in iter(lambda:inp.read(1048576),b""): + h.update(p) + return h.hexdigest() + +def require(ok,where): + if not ok: + raise RuntimeError("R532_GUARD_FAIL_CLOSED_"+where) + +def valid_root_file(p:Path,mode=None): + require(p.is_file() and not p.is_symlink(),"FILE_TYPE_"+p.name) + s=p.stat() + require(s.st_uid==0 and s.st_gid==0 and stat.S_ISREG(s.st_mode), + "ROOT_OWNER_"+p.name) + if mode is not None: + require(stat.S_IMODE(s.st_mode)==mode,"MODE_"+p.name) + +def write_once(outcome:dict): + raw=(json.dumps(outcome,sort_keys=True,indent=2)+"\n").encode() + fd=os.open(RESULT,os.O_CREAT|os.O_EXCL|os.O_WRONLY|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,"wb") as handle: + os.fchmod(handle.fileno(),0o600) + handle.write(raw);handle.flush();os.fsync(handle.fileno()) + directory=os.open(GUARD,os.O_RDONLY|os.O_DIRECTORY) + try:os.fsync(directory) + finally:os.close(directory) + +def module(path:Path): + spec=importlib.util.spec_from_file_location("dim_r532_copied_protected_root",path) + require(spec is not None and spec.loader is not None,"PINNED_ROOT_IMPORT") + obj=importlib.util.module_from_spec(spec) + spec.loader.exec_module(obj) + return obj + +def verify_receipt(doc): + require(doc.get("revision")=="R532" and doc.get("schema")==1,"BASELINE_SCHEMA") + require(doc.get("host")==os.uname().nodename,"HOST_CHANGED") + require(doc.get("ownerUser")=="runner","UNEXPECTED_USER") + u=pwd.getpwnam("runner") + require(doc.get("ownerUid")==u.pw_uid and u.pw_dir=="/home/runner","USER_IDENTITY") + require(doc.get("productionMachineTouched") is False,"SANDBOX_AUTHORITY") + require(doc.get("rootHelperSHA256")==PIN_ROOT and + doc.get("userHelperSHA256")==PIN_USER,"SOURCE_PIN_DECLARATION") + require(doc.get("expectedRootVersion")=="1.5.2" and + doc.get("expectedUserVersion")=="1.5.4","EXPECTED_VERSIONS") + for key in ("protected","userCode","userMutable","network"): + require(isinstance(doc.get(key),dict) and doc[key],"MISSING_BASELINE_"+key) + for name,hashed in doc[key].items(): + path=Path(name) + require(path.is_absolute() and + (name.startswith("/home/runner/.local/share/DirectInternetMethod/") or + name.startswith("/usr/lib/directinternetmethod/") or + name in ("/var/lib/directinternetmethod/install.json", + "/etc/directinternetmethod.uid", + "/etc/resolv.conf","/etc/hosts")), + "BASELINE_PATH_OUTSIDE_ALLOWLIST") + require(isinstance(hashed,str) and len(hashed)==64,"BAD_BASELINE_SHA") + require(doc.get("resolvLink")==( + os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None), + "SYSTEM_RESOLVER_LINK_CHANGED") + return u + +def main(): + started=time.monotonic() + outcome={"revision":"R532","status":"FAIL_UNRESOLVED", + "mode":"SYSTEMD_TRANSIENT_INDEPENDENT_MONOTONIC_TIMER", + "utc":time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime()), + "actualEffectiveUid":os.geteuid(), + "productionMachineTouched":False, + "mutatedNetworkOrTailscale":False, + "recoveryAttempted":False} + try: + require(os.geteuid()==0 and sys.platform.startswith("linux"),"ROOT_LINUX") + require(GUARD.is_dir() and not GUARD.is_symlink() and + GUARD.stat().st_uid==0 and stat.S_IMODE(GUARD.stat().st_mode)==0o711, + "INDEPENDENT_ROOT_DIR") + require(not RESULT.exists() and not RESULT.is_symlink(),"ONCE_ONLY") + for p,m in ((Path(__file__),0o500),(MANIFEST,0o600),(ROOT_HELPER,0o500), + (USER_HELPER,0o644)): + valid_root_file(p,m) + require(digest(ROOT_HELPER)==PIN_ROOT and digest(USER_HELPER)==PIN_USER, + "ROOT_OWNED_PINNED_TX_CHANGED") + baseline=json.loads(MANIFEST.read_text(encoding="utf8")) + owner=verify_receipt(baseline) + src=module(ROOT_HELPER) + txn=src.RootTransaction() + require(txn.native and txn.root==Path("/"),"NATIVE_ROOT_LOCK_AUTHORITY") + pending=Path("/home/runner/.local/state/DirectInternetMethod/update-pending") + root_phase=txn.status().get("status") + outcome["preRollbackRootPhase"]=root_phase + require(root_phase in ("STAGED","APPLIED","DEGRADED","ROLLED_BACK"), + "ROOT_PENDING_MISSING_OR_ALREADY_COMMITTED") + require(pending.is_dir() and not pending.is_symlink(),"USER_PENDING_NOT_FOUND") + outcome["recoveryAttempted"]=True + # Rollback user-owned files first while original protected baseline + # still has an independently protected rollback slot. + user=subprocess.run( + ["/usr/sbin/runuser","-u",owner.pw_name,"--","/usr/bin/env", + "HOME=/home/runner","PYTHONDONTWRITEBYTECODE=1","/usr/bin/python3","-B", + str(USER_HELPER),"--rollback"], + capture_output=True,text=True,timeout=35,check=False) + require(user.returncode==0, + "USER_ROLLBACK_"+user.stderr[-180:].replace(" ","_")) + outcome["userRollback"]="PASS" + if root_phase in ("STAGED","APPLIED","DEGRADED"): + new=txn.rollback() + require(new.get("status")=="ROLLED_BACK","ROOT_ROLLBACK_NOT_ACCEPTED") + require(txn.status().get("status")=="ROLLED_BACK", + "ROOT_PENDING_PHASE_NOT_ROLLED_BACK") + outcome["protectedRootRollback"]="PASS" + require(not pending.exists(),"USER_SLOT_NOT_CLEANED") + require(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.2", + "ROOT_VERSION_NOT_RESTORED") + require(json.loads(Path("/home/runner/.local/share/DirectInternetMethod/INSTALL.json").read_text())["version"]=="1.5.4", + "USER_VERSION_NOT_RESTORED") + for kind in ("protected","userCode","userMutable","network"): + for name,baseline_sha in baseline[kind].items(): + target=Path(name) + require(target.is_file() and not target.is_symlink() or name=="/etc/resolv.conf" and target.is_symlink(), + "RESTORED_TYPE_"+target.name) + require(digest(target)==baseline_sha, + "RESTORED_SHA_"+kind+"_"+target.name) + for name in baseline.get("originalAbsentProtected",[]): + file=Path(name) + require(not file.exists() and not file.is_symlink(),"NEW_ROOT_BINARY_REMAINS") + require(not Path("/home/runner/.local/share/DirectInternetMethod/directmethod/state.json").exists(), + "OWNED_ENGINE_STATE_NOT_ABSENT") + outcome["restoredFiles"]={ + name:len(baseline[name]) for name in ("protected","userCode","userMutable","network")} + outcome["status"]="PASS_ROOT_USER_BYTE_EXACT_AUTONOMOUS_ROLLBACK" + except BaseException as ex: + outcome["failure"]=type(ex).__name__+":"+str(ex)[:350] + outcome["status"]="DEGRADED_UNRESOLVED" + finally: + outcome["durationSec"]=round(time.monotonic()-started,3) + try: + write_once(outcome) + except BaseException as ex: + print("R532_RECEIPT_FAILED",type(ex).__name__,str(ex)[:220],file=sys.stderr) + outcome["receiptWriteFailed"]=True + print(json.dumps(outcome,sort_keys=True)) + if outcome.get("receiptWriteFailed"): + return 91 + return 0 if outcome["status"]=="PASS_ROOT_USER_BYTE_EXACT_AUTONOMOUS_ROLLBACK" else 90 + +if __name__=="__main__": + sys.exit(main()) diff --git a/tests/r535_windows_candidate_hosted_build.ps1 b/tests/r535_windows_candidate_hosted_build.ps1 new file mode 100644 index 00000000..57bf095f --- /dev/null +++ b/tests/r535_windows_candidate_hosted_build.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param() +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$Root=$env:GITHUB_WORKSPACE +if($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/DirectInternetMethod'){throw 'R535_GITHUB_HOST_ONLY'} +if($env:GITHUB_REF -cnotin @('refs/heads/feature/r535-windows-155rc1','refs/heads/feature/r520-linux-155rc1-ci','refs/heads/security/r776-pinned-root-read-qa','refs/heads/prep/r805-windows-156','refs/heads/prep/r803-forward-156-native-gates') -or (git -C $Root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R535_BRANCH_HEAD_AUTHORITY'} +$r=Get-Content (Join-Path $Root 'windows\RELEASE.json') -Raw|ConvertFrom-Json +if($r.version -cne '1.5.6' -or $r.status -cne 'PRIVATE_WINDOWS_V156_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN'){throw 'R535_RC1_DECLARATION'} +$qa=Join-Path $env:RUNNER_TEMP 'DIM_R535_NATIVE' +$gstage=Join-Path $qa 'gui' +$sstage=Join-Path $qa 'service' +$gt=Join-Path $Root 'windows\app\DirectInternetMethod.exe' +$st=Join-Path $Root 'windows\app\DirectInternetMethod.Service.exe' +$installer=Join-Path $Root 'delivery\DirectInternetMethod_1.5.6_Windows_Setup.exe' +foreach($p in @($qa,$gt,$st,$installer)){if(Test-Path $p){throw ('R535_UNSAFE_RERUN_'+$p)}} +New-Item -ItemType Directory -Path $qa|Out-Null +$iscc=Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' +if(!(Test-Path $iscc)){$iscc=Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe'} +if(!(Test-Path $iscc)){throw 'R535_GITHUB_IMAGE_INNO_COMPILER_ABSENT'} +Set-Location $Root +& pwsh -NoProfile -NonInteractive -File tests/prepare_powershell_runtime.ps1 +if($LASTEXITCODE -ne 0){throw 'PWSH_BUNDLE_PREP_FAILED'} +if((Get-FileHash (Join-Path $Root 'windows\vendor\pwsh\pwsh.exe') -Algorithm SHA256).Hash -cne 'BFB46AF89433268872DDB43D1CA7A3F433452EE91ED356A9786940F90118E285'){throw 'PWSH_BUNDLE_SHA'} +& dotnet publish windows/gui/DirectInternetMethod.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:ContinuousIntegrationBuild=true -o $gstage +if($LASTEXITCODE -ne 0){throw 'GUI_PUBLISH_FAILED'} +& dotnet publish windows/service/DirectInternetMethod.Service.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:ContinuousIntegrationBuild=true -o $sstage +if($LASTEXITCODE -ne 0){throw 'SERVICE_PUBLISH_FAILED'} +$gb=Join-Path $gstage 'DirectInternetMethod.exe';$sb=Join-Path $sstage 'DirectInternetMethod.Service.exe' +if(!(Test-Path $gb) -or !(Test-Path $sb)){throw 'RC1_BINARIES_MISSING'} +foreach($name in @('Start-Direct.ps1','Stop-Direct.ps1','Recovery.ps1')){Copy-Item -LiteralPath (Join-Path $Root ('windows\app\'+$name)) -Destination (Join-Path $sstage $name)} +$test=Start-Process $sb -ArgumentList '--console-selftest' -Wait -PassThru -NoNewWindow +if($test.ExitCode -ne 0){throw ('SERVICE_SELFTEST_'+$test.ExitCode)} +Copy-Item $gb $gt +Copy-Item $sb $st +& python -B tests/build_windows_manifest.py +if($LASTEXITCODE -ne 0){throw 'MANIFEST_FAILED'} +& python -B tests/dotnet10_target_contract.py +if($LASTEXITCODE -ne 0){throw 'NET10_CONTRACT_FAILED'} +& python -B tests/windows_contract_audit.py | Out-Null +if($LASTEXITCODE -ne 0){throw 'WINDOWS_AUDIT_FAILED'} +$m=Get-Content (Join-Path $Root 'windows\manifest.json') -Raw|ConvertFrom-Json +if($m.version -cne '1.5.6' -or @($m.files).Count -ne 681){throw 'RC1_MANIFEST_IDENTITY'} +& $iscc /Qp (Join-Path $Root 'windows\installer\DirectInternetMethod.iss') +if($LASTEXITCODE -ne 0){throw 'INNO_COMPILATION_FAILED'} +if(!(Test-Path $installer) -or (Get-Item $installer).Length -lt 50000000){throw 'R535_INSTALLER_MISSING'} +if([string](Get-AuthenticodeSignature $installer).Status -ne 'NotSigned'){throw 'R535_SIGNATURE_POLICY_CHANGED'} +$report=[ordered]@{ + revision='R535';stage='BUILD';status='PASS_ISOLATED_HOSTED_V156_BUILD' + head=$env:GITHUB_SHA;version='1.5.6';stable='v1.5.4' + manifestCount=@($m.files).Count;manifestSha=(Get-FileHash (Join-Path $Root 'windows\manifest.json') -Algorithm SHA256).Hash + guiSha=(Get-FileHash $gt -Algorithm SHA256).Hash + serviceSha=(Get-FileHash $st -Algorithm SHA256).Hash + installerSha=(Get-FileHash $installer -Algorithm SHA256).Hash + installerBytes=(Get-Item $installer).Length;unsigned=$true + installation='NOT_TESTED_YET';publicReleaseUnchanged=$true +} +[IO.File]::WriteAllText((Join-Path $qa 'BUILD_R535.json'),(($report|ConvertTo-Json -Depth 5)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) +Write-Output ('R548_GUI_SHA256='+$report.guiSha) +Write-Output ('R548_SERVICE_SHA256='+$report.serviceSha) +Write-Output ('R548_MANIFEST_SHA256='+$report.manifestSha) +Write-Output ('PASS_R535_HOSTED_BUILD_SHA256='+$report.installerSha) diff --git a/tests/r535_windows_native_rollback.ps1 b/tests/r535_windows_native_rollback.ps1 new file mode 100644 index 00000000..a5ea6e7b --- /dev/null +++ b/tests/r535_windows_native_rollback.ps1 @@ -0,0 +1,125 @@ +[CmdletBinding()] +param() +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$Root=$env:GITHUB_WORKSPACE +$Dir=Join-Path $env:RUNNER_TEMP 'DIM_R535_NATIVE' +$Build=Join-Path $Dir 'BUILD_R535.json' +$Result=Join-Path $Dir 'NATIVE_R535.json' +$Old=Join-Path $Dir 'DirectInternetMethod_1.5.4_Windows_Setup.exe' +$New=Join-Path $Root 'delivery\DirectInternetMethod_1.5.6_Windows_Setup.exe' +$App=Join-Path $env:LOCALAPPDATA 'Programs\DirectInternetMethod' +$Priv=Join-Path $env:ProgramFiles 'DirectInternetMethod\Privileged' +$Config=Join-Path $env:ProgramData 'DirectInternetMethod\UserConfig\custom-hosts.txt' +$Provider=Join-Path $App 'app\router_gateway\providers.json' +$Evidence=Join-Path $App 'evidence\R535_KEEP.txt' +$Gui=Join-Path $App 'app\DirectInternetMethod.exe' +$Svc=Join-Path $Priv 'app\DirectInternetMethod.Service.exe' +$Key='HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}_is1' +$PublicSha='FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133' +$PublicGui='BE9C78C0B5C007B4A46480D423079C219006B730D9589279AA466B0F14BCF00F' +$PublicSvc='2AE7EA11A94BFA3187BC7CDF63A37CB5CD5B55C313C3966330FD280ECC9ECE41' +if($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/DirectInternetMethod'){throw 'R535_HOSTED_VM_ONLY'} +if($env:GITHUB_REF -cnotin @('refs/heads/feature/r535-windows-155rc1','refs/heads/feature/r520-linux-155rc1-ci','refs/heads/security/r776-pinned-root-read-qa','refs/heads/prep/r805-windows-156','refs/heads/prep/r803-forward-156-native-gates') -or (git -C $Root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R535_BRANCH_HEAD_DRIFT'} +$admin=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'R535_ADMIN_TOKEN_REQUIRED'} +if(!(Test-Path $Build) -or (Test-Path $Result) -or (Test-Path $Old)){throw 'R535_UNSAFE_REPLAY_OR_MISSING_BUILD'} +$build=Get-Content $Build -Raw |ConvertFrom-Json +if($build.status -cne 'PASS_ISOLATED_HOSTED_V156_BUILD' -or $build.head -cne $env:GITHUB_SHA){throw 'R535_BUILD_AUTHORITY'} +if((Get-FileHash $New -Algorithm SHA256).Hash -cne $build.installerSha){throw 'R535_RC1_INSTALLER_SHA'} +$report=[ordered]@{schema=1;revision='R535';status='FAIL_UNPROVEN';source=$env:GITHUB_SHA; + runner='github-hosted-windows-2025';privateInstallerSha=$build.installerSha; + publishedStableSha=$PublicSha;noReboot=$true;noPhysicalMachines=$true; + checks=@();errors=@()} +function Require([bool]$ok,[string]$name) { + if(!$ok){throw ('R535_FAIL_CLOSED_'+$name)} + $report.checks+=($name+':PASS') +} +function FileSha([string]$p){ (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash } +function Network { + [ordered]@{ + defaultRoutes=(@(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | + Select-Object InterfaceIndex,NextHop,RouteMetric|Sort-Object InterfaceIndex,NextHop)|ConvertTo-Json -Depth 5 -Compress) + dns=(@(Get-DnsClientServerAddress -AddressFamily IPv4| + Select-Object InterfaceIndex,ServerAddresses|Sort-Object InterfaceIndex)|ConvertTo-Json -Depth 5 -Compress)} +} +function Install([string]$exe,[string]$step) { + $args=@('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/NORESTARTAPPLICATIONS','/CLOSEAPPLICATIONS','/SP-') + $p=Start-Process -FilePath $exe -ArgumentList $args -Wait -PassThru -NoNewWindow + Require ($p.ExitCode -eq 0) ($step+'_EXIT') +} +function ExactRegisteredUninstaller([object]$d) { + # Inno Setup allocates uninsNNN per directory, not per version. Inspect + # the real AppId registration and require just one corresponding file pair. + $entries=@(Get-ChildItem -LiteralPath $App -Force -ErrorAction Stop | Where-Object { $_.Name -match '^unins[0-9]{3}[.](exe|dat|msg)$' }) + $exes=@($entries|Where-Object { $_.Extension -ieq '.exe' }) + $datas=@($entries|Where-Object { $_.Extension -ieq '.dat' }) + $msgs=@($entries|Where-Object { $_.Extension -ieq '.msg' }) + Require ($exes.Count -eq 1 -and $datas.Count -eq 1 -and $msgs.Count -le 1) 'ONE_UNINSTALLER' + $stem=[IO.Path]::GetFileNameWithoutExtension($exes[0].Name) + Require ($stem -ieq [IO.Path]::GetFileNameWithoutExtension($datas[0].Name)) 'MATCHED_UNINSTALL_LOG' + Require ($msgs.Count -eq 0 -or $stem -ieq [IO.Path]::GetFileNameWithoutExtension($msgs[0].Name)) 'MATCHED_UNINSTALL_MSG' + foreach($item in $entries) { + Require (!$item.PSIsContainer -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) 'UNINSTALLER_NODE_UNSAFE' + } + $path=[IO.Path]::GetFullPath($exes[0].FullName) + Require ([string]$d.UninstallString -ieq ('"'+$path+'"')) 'EXACT_UNINSTALL_PATH_OWNED' + return $path +} +function Registered([string]$version) { + Require (Test-Path $Key) 'SINGLE_HKLM_APPID' + $d=Get-ItemProperty -LiteralPath $Key + Require ($d.DisplayVersion -ceq $version) 'EXACT_REGISTERED_VERSION' + Require ($d.InstallLocation.TrimEnd('\') -ieq $App.TrimEnd('\')) 'SINGLE_INSTALL_PATH' + Require (!(Test-Path ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}_is1'))) 'NO_HKCU_DUPLICATE' + [void](ExactRegisteredUninstaller $d) + return $d +} +try { + Require (!(Test-Path $Key) -and !(Test-Path $App) -and !(Test-Path $Priv)) 'CLEAN_DISPOSABLE_VM' + $before=Network + $report.networkBefore=$before + Invoke-WebRequest -UseBasicParsing -Uri 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Windows_Setup.exe' -OutFile $Old -MaximumRedirection 5 -TimeoutSec 180 + Require ((Get-Item $Old).Length -eq 154270907 -and (FileSha $Old) -ceq $PublicSha) 'OFFICIAL_STABLE_SHA_PIN' + Install $Old 'STABLE_FRESH' + [void](Registered '1.5.4') + Require ((FileSha $Gui) -ceq $PublicGui -and (FileSha $Svc) -ceq $PublicSvc) 'OFFICIAL_STABLE_GUI_AND_SERVICE' + New-Item -ItemType Directory -Path (Split-Path $Config -Parent) -Force|Out-Null + Require (!(Test-Path $Config)) 'NO_PREEXISTING_PRIVATE_CONFIG' + [IO.File]::WriteAllText($Config,'R535_PRIVATE_CUSTOM_SITE_SENTINEL') + $configSha=FileSha $Config + Require (Test-Path $Provider) 'PROVIDER_EXISTS' + [IO.File]::AppendAllText($Provider,[Environment]::NewLine+' ') + $providerSha=FileSha $Provider + New-Item -ItemType Directory -Path (Split-Path $Evidence -Parent) -Force|Out-Null + [IO.File]::WriteAllText($Evidence,'R535_USER_UNKNOWN_EVIDENCE_KEEP') + $evidenceSha=FileSha $Evidence + Install $New 'RC1_IN_PLACE' + [void](Registered '1.5.6') + Require ((FileSha $Gui) -ceq $build.guiSha -and (FileSha $Svc) -ceq $build.serviceSha) 'RC1_GUI_SERVICE_PIN' + Require ((FileSha (Join-Path $App 'manifest.json')) -ceq $build.manifestSha) 'RC1_MANIFEST_PIN' + Require ((Get-Content (Join-Path $App 'RELEASE.json') -Raw|ConvertFrom-Json).version -ceq '1.5.6') 'RC1_INSTALLED_VERSION' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Provider) -ceq $providerSha -and (FileSha $Evidence) -ceq $evidenceSha) 'IN_PLACE_USER_DATA_PRESERVED' + $d=Registered '1.5.6' + $uninstaller=ExactRegisteredUninstaller $d + $u=Start-Process $uninstaller -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') -Wait -PassThru -NoNewWindow + Require ($u.ExitCode -eq 0) 'RC1_UNINSTALL_ZERO' + Require (!(Test-Path $Key)) 'ONE_UNINSTALLER_REMOVED_REGISTRY' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Evidence) -ceq $evidenceSha) 'UNKNOWN_USER_DATA_SURVIVED_UNINSTALL' + Install $Old 'STABLE_ROLLBACK_REINSTALL' + [void](Registered '1.5.4') + Require ((FileSha $Gui) -ceq $PublicGui -and (FileSha $Svc) -ceq $PublicSvc) 'ORIGINAL_STABLE_BINARY_HASHES_RESTORED' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Evidence) -ceq $evidenceSha) 'PRIVATE_CONFIG_PRESERVED_ROLLBACK' + $after=Network;$report.networkAfter=$after + Require ($before.defaultRoutes -ceq $after.defaultRoutes -and $before.dns -ceq $after.dns) 'NETWORK_BASELINE_EXACT' + $report.status='PASS_HOSTED_NATIVE_154_TO_156_AND_BACK_154' + $report.singleUninstaller=$true;$report.customProviderUpgradePreserved=$true;$report.dataPreserved=$true +} catch { + $report.status='DEGRADED_UNRESOLVED' + $report.errors+=([string]$_.Exception.Message) +} finally { + [IO.File]::WriteAllText($Result,(ConvertTo-Json $report -Depth 9)+[Environment]::NewLine,[Text.UTF8Encoding]::new($false)) + Write-Output ('R535_NATIVE_STATUS='+$report.status) + Write-Output ('R535_RECEIPT_SHA256='+((Get-FileHash $Result -Algorithm SHA256).Hash)) +} +if($report.status -ne 'PASS_HOSTED_NATIVE_154_TO_156_AND_BACK_154'){throw ('R535_NATIVE_NOT_ACCEPTED_'+($report.errors -join ';'))} diff --git a/tests/r537_gui_consent_status_contract.py b/tests/r537_gui_consent_status_contract.py new file mode 100644 index 00000000..485d883f --- /dev/null +++ b/tests/r537_gui_consent_status_contract.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""R537 negative-first GUI status/consent contract. No Gtk UI or live network. + +Execute exact AST of the pre-release GUI load_state() against disposable +settings and mocked *read-only* backend status. No actual user consent token is +written, and no subprocess, systemd, DNS, routes, VPN or privileged mutation. +""" +import ast +import json +import os +import pathlib +import tempfile +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +APP=ROOT/"linux/app/direct_internet_method.py" +SOURCE=APP.read_text(encoding="utf8") +TREE=ast.parse(SOURCE) +FN=next(x for x in TREE.body if isinstance(x,ast.FunctionDef) and x.name=="load_state") + +class ExplicitDoHStatus(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="r537_gui_state_",dir=str(ROOT)) + self.addCleanup(self.tmp.cleanup) + base=pathlib.Path(self.tmp.name) + self.scope=base/"scope.txt" + self.strategy=base/"strategy.txt" + self.scope.write_text("targeted\n") + self.strategy.write_text("balanced\n") + self.backend=True + self.old="OFF" + self.encrypted="OFF" + self.split=True + self.foreign=False + self.globals={ + "json":json,"os":os,"pathlib":pathlib, + "STATE":base/"missing-state.json", + "SCOPE_FILE":self.scope,"STRATEGY_FILE":self.strategy, + "_external_tunnel_active":lambda:self.foreign, + "_tailscale_split_safe":lambda:self.split, + "_coexistence_backend_ready":lambda:self.backend, + "selective_dns_optin_state":lambda:self.old, + "encrypted_dns_optin_state":lambda:self.encrypted, + } + exec(compile(ast.Module(body=[FN],type_ignores=[]), + str(APP), "exec"),self.globals) + self.load=self.globals["load_state"] + + def test_01_default_both_disabled(self): + state=self.load() + self.assertEqual(state["mode"],"OFF") + self.assertIn("both OFF",state["detail"]) + + def test_02_authenticated_encrypted_optin_shown_distinctly(self): + self.encrypted="ON" + state=self.load() + self.assertEqual(state["mode"],"OFF") + self.assertIn("Control D DoH",state["detail"]) + self.assertIn("OPTED IN",state["detail"]) + self.assertIn("HTTPS reachability is not guaranteed",state["detail"]) + + def test_03_invalid_encrypted_consent_disables_start(self): + self.encrypted="INVALID" + state=self.load() + self.assertEqual(state["mode"],"CONFIG_REQUIRED") + self.assertIn("invalid",state["detail"]) + + def test_04_conflicting_legacy_consent_invalid_disables_start(self): + self.old="ON" + self.encrypted="INVALID" + state=self.load() + self.assertEqual(state["mode"],"CONFIG_REQUIRED") + self.assertIn("conflicts",state["detail"]) + + def test_05_encrypted_requires_balanced(self): + self.encrypted="ON" + self.strategy.write_text("strong\n") + state=self.load() + self.assertEqual(state["mode"],"CONFIG_REQUIRED") + self.assertIn("Balanced",state["detail"]) + + def test_06_encrypted_requires_explicit_strategy_file(self): + self.encrypted="ON" + self.strategy.unlink() + state=self.load() + self.assertEqual(state["mode"],"CONFIG_REQUIRED") + + def test_07_invalid_all_sites_precedes_encrypted(self): + self.encrypted="ON" + self.scope.write_text("all-sites\n") + state=self.load() + self.assertEqual(state["mode"],"CONFIG_REQUIRED") + self.assertIn("All Sites",state["detail"]) + + def test_08_legacy_unencrypted_consent_distinguished(self): + self.old="ON" + state=self.load() + self.assertEqual(state["mode"],"OFF") + self.assertIn("UNENCRYPTED",state["detail"]) + self.assertNotIn("Control D DoH",state["detail"]) + + def test_09_legacy_invalid_consent_disables_start(self): + self.old="INVALID" + state=self.load() + self.assertEqual(state["mode"],"CONFIG_REQUIRED") + + def test_10_backend_not_updated_refuses_start_even_with_consent(self): + self.encrypted="ON" + self.backend=False + self.assertEqual(self.load()["mode"],"BACKEND_UPDATE_REQUIRED") + + def test_11_foreign_vpn_takes_precedence(self): + self.encrypted="ON" + self.foreign=True + self.assertEqual(self.load()["mode"],"BLOCKED") + + def test_12_no_mutation_syscalls_in_ui_status_function(self): + body=ast.get_source_segment(SOURCE,FN) + for token in ("subprocess.run(", "subprocess.Popen(", "os.system(", + "systemctl start", "resolvectl dns enp7s0", + "pkexec", "tailscale down", ".write_text(", ".unlink("): + self.assertNotIn(token,body) + self.assertIn('encrypted_dns_optin_state()',body) + +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run( + unittest.defaultTestLoader.loadTestsFromTestCase(ExplicitDoHStatus)) + print(f"R537_GUI_DOHSCOPE_STATUS cases={result.testsRun} failures={len(result.failures)} errors={len(result.errors)}") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r538_active_encrypted_status_contract.py b/tests/r538_active_encrypted_status_contract.py new file mode 100644 index 00000000..58c157c1 --- /dev/null +++ b/tests/r538_active_encrypted_status_contract.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""R538 negative-first: real RC1 active-state DoH and HTTPS verifier semantics. + +Execute just the actual production GUI load_state and verify_live functions +from pinned source AST with synthetic state and deliberately mocked command +outputs. No physical network, installed settings, privileges or GTK mutation. +""" +import ast +import json +import os +import pathlib +import subprocess +import tempfile +import types +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +APP=ROOT/"linux/app/direct_internet_method.py" +SOURCE=APP.read_text(encoding="utf-8") +TREE=ast.parse(SOURCE) +METHODS=[n for n in TREE.body if isinstance(n,ast.FunctionDef) + and n.name in ("load_state","verify_live")] +assert len(METHODS)==2, "R538_EXPECTED_TWO_PRODUCTION_FUNCTIONS" + +class ActiveEncryptedDoH(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="r538_active_doh_",dir=ROOT) + self.addCleanup(self.tmp.cleanup) + self.root=pathlib.Path(self.tmp.name) + self.statefile=self.root/"active.json" + self.calls=[] + self.remote_youtube_ok=True + self.backend_unit_active=True + self.external_tunnel=False + self.tailscale_safe=True + self.data={ + "schema":3,"status":"ACTIVE","uid":os.getuid(), + "dnsMode":"system-preserved","tailscaleCoexistence":True, + "selectiveDnsEncrypted":True,"selectiveDns":False, + "physicalInterface":"enp7s0","dnsInterface":"","dnsIp":"", + "ctrldPid":0,"ctrldUnit":None, + "nfqwsUnit":f"directinternetmethod-nfqws-{os.getuid()}.service", + "nfqwsPid":37531,"strategy":"balanced","scope":"targeted"} + self.store() + self.env={ + "json":json,"os":os,"pathlib":pathlib, + "STATE":self.statefile, + "subprocess":types.SimpleNamespace(run=self.fake_run), + "_external_tunnel_active":lambda:self.external_tunnel, + "_tailscale_split_safe":lambda:self.tailscale_safe, + "_top_default_iface":lambda:self.data["physicalInterface"], + "_systemd_unit_matches":lambda *a:self.backend_unit_active, + } + exec(compile(ast.Module(body=METHODS,type_ignores=[]),str(APP),"exec"),self.env) + self.show=self.env["load_state"] + self.verify=self.env["verify_live"] + + def store(self): + self.statefile.write_text(json.dumps(self.data)+"\n",encoding="utf8") + + def fake_run(self,argv,**kwargs): + program=argv[0] + self.assertIn(program,("getent","curl"),"R538_ATTEMPTED_REAL_COMMAND") + if program=="getent": + return types.SimpleNamespace(returncode=0,stdout="142.251.151.4 STREAM www.youtube.com\n",stderr="") + url=argv[-1] + self.calls.append(url) + mapping={ + "http://www.youtube.com/":"301|142.251.151.4|0.10", + "https://www.youtube.com/":"200|142.251.151.4|0.31", + # Deliberately incorrect endpoint for YouTube: using it would fail + # a healthy connection and false-negative the acceptance. + "https://www.youtube.com/generate_204":"404|142.251.151.4|0.25", + "https://api.openai.com/v1/models":"401|104.18.0.1|0.25", + "https://github.com/":"200|140.82.112.3|0.23", + "https://gemini.google.com/":"200|142.250.191.14|0.20" + } + self.assertIn(url,mapping,"R538_UNRECOGNIZED_PROBE") + if not self.remote_youtube_ok and url=="https://www.youtube.com/": + return types.SimpleNamespace(returncode=35,stdout="000||1.30",stderr="TLS reset") + return types.SimpleNamespace(returncode=0,stdout=mapping[url],stderr="") + + def test_01_active_encrypted_selected_shows_actual_doh(self): + v=self.show() + self.assertEqual(v["mode"],"ACTIVE") + self.assertIn("Encrypted targeted DoH ACTIVE",v["detail"]) + self.assertIn("MagicDNS",v["detail"]) + + def test_02_active_legacy_udp_remains_explicitly_unencrypted(self): + self.data["selectiveDnsEncrypted"]=False + self.data["selectiveDns"]=True + self.store() + v=self.show() + self.assertEqual(v["mode"],"ACTIVE") + self.assertIn("UNENCRYPTED",v["detail"]) + self.assertNotIn("Encrypted targeted DoH ACTIVE",v["detail"]) + + def test_03_active_without_selected_dns_shows_preserved_dns(self): + self.data["selectiveDnsEncrypted"]=False + self.store() + v=self.show() + self.assertEqual(v["mode"],"ACTIVE") + self.assertIn("DNS/MagicDNS preserved",v["detail"]) + self.assertNotIn("DoH ACTIVE",v["detail"]) + + def test_04_conflicting_active_state_fails_closed(self): + self.data["selectiveDns"]=True + self.store() + self.assertEqual(self.show()["mode"],"CONFLICT") + + def test_05_inactive_nfqws_stays_stale(self): + self.backend_unit_active=False + self.assertEqual(self.show()["mode"],"STALE") + + def test_06_unsupported_vpn_conflicts_even_with_valid_doh(self): + self.external_tunnel=True + self.assertEqual(self.show()["mode"],"CONFLICT") + + def test_07_selected_doh_live_probe_targets_real_homepage(self): + result=self.verify() + self.assertTrue(result["ok"]) + self.assertIn("https://www.youtube.com/",self.calls) + self.assertNotIn("https://www.youtube.com/generate_204",self.calls) + + def test_08_selected_doh_live_methods_include_targeted_doh(self): + result=self.verify() + self.assertIn("encrypted-selective-doh",result["directMethods"]) + self.assertNotIn("unencrypted-selective-udp",result["directMethods"]) + + def test_09_real_tls_failure_never_counted_as_success(self): + self.remote_youtube_ok=False + v=self.verify() + self.assertFalse(v["ok"]) + self.assertEqual(v["youtube"]["exit"],35) + + def test_10_legacy_udp_never_claims_encrypted_methods(self): + self.data["selectiveDnsEncrypted"]=False + self.data["selectiveDns"]=True + self.store() + v=self.verify() + self.assertIn("https://www.youtube.com/",self.calls) + self.assertNotIn("encrypted-selective-doh",v["directMethods"]) + + def test_11_mutually_selected_dns_modes_rejected_by_verifier(self): + self.data["selectiveDns"]=True + self.store() + v=self.verify() + self.assertFalse(v["ok"]) + self.assertEqual(v["error"],"CONFLICTING_SELECTIVE_DNS_STATE") + + def test_12_active_production_status_contains_no_network_mutations(self): + for n in METHODS: + source=ast.get_source_segment(SOURCE,n) + for k in ("resolvectl dns enp7s0","tailscale down", + "systemctl start","ip route add","iptables", + "nft add","pkexec","os.system("): + self.assertNotIn(k,source) + self.assertIn('s.get("selectiveDnsEncrypted")',SOURCE) + +if __name__=="__main__": + res=unittest.TextTestRunner(verbosity=2).run( + unittest.defaultTestLoader.loadTestsFromTestCase(ActiveEncryptedDoH)) + print("R538_ACTIVE_ENCRYPTED_DOH_AND_VERIFIER tests=",res.testsRun, + "failures=",len(res.failures),"errors=",len(res.errors)) + raise SystemExit(0 if res.wasSuccessful() else 1) diff --git a/tests/r541_route_interface_drift_contract.py b/tests/r541_route_interface_drift_contract.py new file mode 100644 index 00000000..0e743181 --- /dev/null +++ b/tests/r541_route_interface_drift_contract.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""R541 regression for route/interface drift after real network changes. + +Derived from the physical Emad Linux read-only finding: old owned nft output +rules match recorded Wi-Fi wlp2s0, while live Internet egress uses enp1s0. +No installed state, network, DNS, Tailscale, nfqueue, privileged actions or +user settings may be changed by these tests. +""" +import ast +import json +import os +import shlex +import subprocess +from pathlib import Path +import tempfile +import types +import unittest + +ROOT=Path(__file__).resolve().parents[1] +GUI=ROOT/"linux/app/direct_internet_method.py" +HELPER=ROOT/"linux/app/direct_method_helper.sh" +SOURCE=GUI.read_text(encoding="utf-8") +TREE=ast.parse(SOURCE) +FUNCS=[x for x in TREE.body if isinstance(x,ast.FunctionDef) + and x.name in ("load_state","verify_live")] +assert len(FUNCS)==2 + +class RouteDrift(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix="r541_route_test_",dir=ROOT) + self.addCleanup(self.temporary.cleanup) + self.state=Path(self.temporary.name)/"state.json" + self.egress="enp1s0" + self.interface="wlp2s0" + self.backend_running=True + self.calls=[] + self.row={ + "status":"ACTIVE","schema":3,"uid":os.getuid(), + "physicalInterface":self.interface, + "dnsMode":"system-preserved","tailscaleCoexistence":True, + "selectiveDns":False,"selectiveDnsEncrypted":False, + "ctrldPid":0,"ctrldUnit":None, + "nfqwsPid":5005,"nfqwsUnit":f"directinternetmethod-nfqws-{os.getuid()}.service" + } + self.write() + self.env={ + "json":json,"os":os,"pathlib":PathNamespace, + "STATE":self.state, + "_external_tunnel_active":lambda:False, + "_tailscale_split_safe":lambda:True, + "_top_default_iface":lambda:self.egress, + "_systemd_unit_matches":lambda *args:self.backend_running, + "subprocess":types.SimpleNamespace(run=self.fake_process), + } + exec(compile(ast.Module(body=FUNCS,type_ignores=[]),str(GUI),"exec"),self.env) + self.show=self.env["load_state"] + self.verify=self.env["verify_live"] + + def write(self): + self.row["physicalInterface"]=self.interface + self.state.write_text(json.dumps(self.row)+"\n",encoding="utf-8") + + def fake_process(self,*args,**kwargs): + self.calls.append(args[0]) + raise AssertionError("R541_ROUTE_DRIFT_MUST_ABORT_BEFORE_NETWORK_PROBES") + + def test_01_stale_wifi_rule_vs_ethernet_default(self): + s=self.show() + self.assertEqual(s["mode"],"STALE") + self.assertIn("wlp2s0",s["detail"]) + self.assertIn("enp1s0",s["detail"]) + self.assertIn("Stop",s["detail"]) + + def test_02_default_route_missing_does_not_trust_running_pid(self): + self.egress="" + self.assertEqual(self.show()["mode"],"STALE") + + def test_03_route_returns_to_installed_interface(self): + self.egress="wlp2s0" + self.assertEqual(self.show()["mode"],"ACTIVE") + + def test_04_https_probe_preflights_route_without_curl(self): + v=self.verify() + self.assertEqual(v["ok"],False) + self.assertEqual(v["error"],"PHYSICAL_INTERFACE_ROUTE_DRIFT") + self.assertEqual(v["recordedInterface"],"wlp2s0") + self.assertEqual(v["currentInterface"],"enp1s0") + self.assertEqual(self.calls,[]) + + def test_05_https_probe_no_default_route_fails_closed(self): + self.egress="" + v=self.verify() + self.assertEqual(v["error"],"PHYSICAL_INTERFACE_ROUTE_DRIFT") + self.assertEqual(self.calls,[]) + + def test_06_original_missing_nfqws_pid_still_stale(self): + self.backend_running=False + self.assertEqual(self.show()["mode"],"STALE") + + def test_07_tailscale_state_does_not_mask_route_drift(self): + self.row["selectiveDnsEncrypted"]=True + self.write() + s=self.show() + self.assertEqual(s["mode"],"STALE") + self.assertNotIn("DoH ACTIVE",s["detail"]) + + def test_08_shell_backend_refuses_false_already_active_without_mutating(self): + shell=HELPER.read_text(encoding="utf-8") + start=shell.split("\n start)\n",1)[1].split("\n if nft_table_presence;",1)[0] + self.assertIn('CURRENT_PHY="$(physical_iface)"',start) + self.assertIn('PREVIOUS_PHY="$(state_value physicalInterface)"',start) + self.assertIn('PHYSICAL_INTERFACE_ROUTE_DRIFT',start) + self.assertIn('trap - EXIT',start) + self.assertLess(start.index('PHYSICAL_INTERFACE_ROUTE_DRIFT'), + start.index('nft_table_owned "$(state_value physicalInterface)"')) + self.assertIn('exit 79',start) + self.assertIn('nft add rule inet "$TABLE" output oifname "$PHY"',shell) + + def test_09_shell_guard_is_before_any_cleanup(self): + shell=HELPER.read_text(encoding="utf-8") + start=shell.split("\n start)\n",1)[1] + self.assertLess(start.index('PHYSICAL_INTERFACE_ROUTE_DRIFT'),start.index('cleanup_state_owned')) + + + def _real_shell_route_preflight(self, previous, current, top): + """Run the exact installed Start preflight under unprivileged Bash stubs. + + This stops before CPID/readiness/cleanup, so no root, service, route, + DNS, Tailscale, nft or network action is possible. Only the protected + drift guard from the real helper is executed, not a rewritten model. + """ + shell=HELPER.read_text(encoding="utf-8") + start=shell.split("\n start)\n",1)[1].split("\n CPID=",1)[0] + self.assertIn("PHYSICAL_INTERFACE_ROUTE_DRIFT",start) + script=( + "set -euo pipefail\n" + f"STATE={shlex.quote(str(self.state))}\n" + f"REQ_PREVIOUS={shlex.quote(previous)}\n" + f"REQ_CURRENT={shlex.quote(current)}\n" + f"REQ_TOP={shlex.quote(top)}\n" + "require_root() { :; }\n" + "acquire_action_lock() { :; }\n" + "rollback_on_exit() { printf 'ROLLBACK_CALLED\\n'; }\n" + "state_value() { [[ \"$1\" == physicalInterface ]] && " + "printf '%s\\n' \"$REQ_PREVIOUS\"; }\n" + "physical_iface() { printf '%s\\n' \"$REQ_CURRENT\"; }\n" + "top_iface() { printf '%s\\n' \"$REQ_TOP\"; }\n" + "cleanup_state_owned() { printf 'CLEANUP_CALLED\\n'; }\n" + "ACTION=start\nSTART_COMMITTED=0\n" + +start+ + "\n fi\n" + "trap - EXIT\nprintf 'PRECHECK_MATCHED\\n'\n" + ) + return subprocess.run(["bash","-c",script],text=True,capture_output=True, + timeout=5,check=False) + + def test_10_executable_start_drift_refuses_without_cleanup(self): + for name,old,new,top in ( + ("wifi_to_ethernet","wlp2s0","enp1s0","enp1s0"), + ("default_route_lost","wlp2s0","",""), + ("top_route_conflict","wlp2s0","wlp2s0","tailscale0"), + ("recorded_interface_missing","","enp1s0","enp1s0"), + ): + with self.subTest(name=name): + p=self._real_shell_route_preflight(old,new,top) + self.assertEqual(p.returncode,79,p.stdout+p.stderr) + self.assertIn('"error":"PHYSICAL_INTERFACE_ROUTE_DRIFT"',p.stdout) + self.assertNotIn("CLEANUP_CALLED",p.stdout) + self.assertNotIn("ROLLBACK_CALLED",p.stdout) + self.assertNotIn("PRECHECK_MATCHED",p.stdout) + + def test_11_executable_start_matching_route_uses_safe_path(self): + p=self._real_shell_route_preflight("wlp2s0","wlp2s0","wlp2s0") + self.assertEqual(p.returncode,0,p.stdout+p.stderr) + self.assertIn("PRECHECK_MATCHED",p.stdout) + self.assertNotIn("CLEANUP_CALLED",p.stdout) + +class PathNamespace: + Path=Path + +if __name__=="__main__": + res=unittest.TextTestRunner(verbosity=2).run( + unittest.defaultTestLoader.loadTestsFromTestCase(RouteDrift)) + print("R541_ROUTE_DRIFT tests=",res.testsRun, + "failures=",len(res.failures),"errors=",len(res.errors)) + raise SystemExit(0 if res.wasSuccessful() else 1) diff --git a/tests/r545_periodic_gui_status_contract.py b/tests/r545_periodic_gui_status_contract.py new file mode 100644 index 00000000..a3d30fef --- /dev/null +++ b/tests/r545_periodic_gui_status_contract.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Isolated negative-first source-extracted GUI status timer regression. + +Does not import GTK or run a service, subprocess, DNS, route, network request, +user setting or privileged command. Only original AST methods are executed. +""" +import ast +from pathlib import Path +import types +import unittest + +ROOT=Path(__file__).resolve().parents[1] +SOURCE=(ROOT/"linux/app/direct_internet_method.py").read_text(encoding="utf-8") +TREE=ast.parse(SOURCE) +WINDOW=next(x for x in TREE.body if isinstance(x,ast.ClassDef) and x.name=="Window") +names={"refresh","periodic_state_tick","stop_status_timer"} +funcs=[n for n in WINDOW.body if isinstance(n,ast.FunctionDef) and n.name in names] +assert {f.name for f in funcs}==names +INIT=next(n for n in WINDOW.body if isinstance(n,ast.FunctionDef) and n.name=="__init__") +init=ast.get_source_segment(SOURCE,INIT) +assert 'GLib.timeout_add_seconds(15,self.periodic_state_tick)' in init +assert 'self.connect("close-request",self.stop_status_timer)' in init + +class Label: + def __init__(self,text=""):self.text=text + def get_text(self):return self.text + def set_text(self,text):self.text=text + def remove_css_class(self,*_):pass + def add_css_class(self,*_):pass + +class Button: + def __init__(self):self.enabled=None + def set_sensitive(self,v):self.enabled=v + def remove_css_class(self,*_):pass + def add_css_class(self,*_):pass + +class FakeGLib: + removed=[] + @classmethod + def source_remove(cls,i):cls.removed.append(i) + +class ForbiddenThread: + def __init__(self,*_,**__):raise AssertionError("PERIODIC_REFRESH_MUST_NOT_SPAWN_WEB_PROBES") + +_state=[{"mode":"ACTIVE","detail":"ok"}] +def read_state(): + if isinstance(_state[0],Exception):raise _state[0] + return _state[0] +env={"load_state":read_state,"GLib":FakeGLib,"threading":types.SimpleNamespace(Thread=ForbiddenThread)} +exec(compile(ast.Module(body=funcs,type_ignores=[]),"original_gui_methods","exec"),env) + +class Dummy: + def __init__(self,mode="ACTIVE",detail="ok"): + self.status=Label("Status: "+mode) + self.detail=Label(detail) + self.gemini_live=Label("Gemini: previous") + self.adult_live=Label("Adult coverage: previous") + self.live_check_generation=0 + self.adult_check_enabled=True + self.buttons={k:Button() for k in ("start","stop","recovery","refresh","update")} + self.refresh=types.MethodType(env["refresh"],self) + self.periodic_state_tick=types.MethodType(env["periodic_state_tick"],self) + self.stop_status_timer=types.MethodType(env["stop_status_timer"],self) + self._status_timer_id=0 + +class TimerContract(unittest.TestCase): + def test_01_stable_active_no_repeated_live_probe(self): + _state[0]={"mode":"ACTIVE","detail":"ok"} + d=Dummy() + self.assertTrue(d.periodic_state_tick()) + self.assertEqual(d.live_check_generation,0) + self.assertEqual(d.gemini_live.get_text(),"Gemini: previous") + def test_02_physical_route_drift_marks_stale_and_disables_start(self): + _state[0]={"mode":"STALE","detail":"wlp2s0 differs from enp1s0"} + d=Dummy() + self.assertTrue(d.periodic_state_tick()) + self.assertEqual(d.status.get_text(),"Status: STALE") + self.assertFalse(d.buttons["start"].enabled) + self.assertTrue(d.buttons["recovery"].enabled) + self.assertIn("enp1s0",d.detail.get_text()) + self.assertEqual(d.live_check_generation,1) + def test_03_stale_detail_change_does_not_start_probes(self): + _state[0]={"mode":"STALE","detail":"now no route"} + d=Dummy("STALE","old route") + self.assertTrue(d.periodic_state_tick()) + self.assertEqual(d.detail.get_text(),"now no route") + self.assertEqual(d.live_check_generation,0) + def test_04_auto_recovery_active_no_background_web_checks(self): + _state[0]={"mode":"ACTIVE","detail":"route recovered"} + d=Dummy("STALE","was stale") + self.assertTrue(d.periodic_state_tick()) + self.assertEqual(d.status.get_text(),"Status: ACTIVE") + self.assertIn("Press Refresh",d.gemini_live.get_text()) + self.assertFalse(d.buttons["start"].enabled) + def test_05_state_read_error_is_fail_closed(self): + _state[0]=RuntimeError("synthetic read failure") + d=Dummy() + self.assertTrue(d.periodic_state_tick()) + self.assertEqual(d.status.get_text(),"Status: STALE") + self.assertFalse(d.buttons["start"].enabled) + self.assertTrue(d.buttons["recovery"].enabled) + def test_06_close_removes_only_owned_timer_once(self): + FakeGLib.removed=[] + d=Dummy() + d._status_timer_id=4321 + self.assertFalse(d.stop_status_timer()) + self.assertFalse(d.stop_status_timer()) + self.assertEqual(FakeGLib.removed,[4321]) + self.assertEqual(d._status_timer_id,0) + +if __name__=="__main__": + res=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(TimerContract)) + print("R545_PERIODIC_GUI_STATUS cases=",res.testsRun,"failures=",len(res.failures),"errors=",len(res.errors)) + raise SystemExit(0 if res.wasSuccessful() else 1) diff --git a/tests/r552_native_upgrade_guard_contract.py b/tests/r552_native_upgrade_guard_contract.py new file mode 100644 index 00000000..f45bb74b --- /dev/null +++ b/tests/r552_native_upgrade_guard_contract.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""R552 source-extracted regression: worker/timer logic, ZERO installed actions.""" +import hashlib,importlib.util,json,os,pathlib,tempfile,types,unittest +SRC=pathlib.Path(__file__).resolve().parents[1]/"linux/system/upgrade_guard.py" +SOURCE_SHA="66B683E14FCDEC8D62CDFD2879AAB29D9DE0FD778DD67E0522BFB1218DC122D0" +assert hashlib.sha256(SRC.read_bytes()).hexdigest().upper()==SOURCE_SHA +spec=importlib.util.spec_from_file_location("r552_candidate",SRC) +g=importlib.util.module_from_spec(spec) +spec.loader.exec_module(g) +class FakeTx: + def __init__(self,phase): + self.native=True;self.phase=phase;self.calls=[] + def status(self):self.calls.append("status");return {"status":self.phase} + def rollback(self):self.calls.append("rollback");return {"status":"ROLLED_BACK"} +class Cases(unittest.TestCase): + def setUp(self): + self.dir=tempfile.TemporaryDirectory(prefix="DIM_R552_FAKE_") + self.addCleanup(self.dir.cleanup) + p=pathlib.Path(self.dir.name) + self.home=p/"home";self.home.mkdir(mode=0o700) + self.result=p/"result.json" + self.original={k:getattr(g,k) for k in + ("GUARD","RESULT","WORKER","SERVICE","TIMER","verify_guard_files", + "imported_root","verify_installed_after_restore","command","write_new")} + self.addCleanup(lambda:[setattr(g,k,v) for k,v in self.original.items()]) + g.GUARD=p;g.RESULT=self.result;g.WORKER=SRC.resolve() + # Preserve the production root-owner constraint; in a nonroot + # synthetic HOME intercept ONLY the test-owned receipt destination. + def synthetic_receipt(target,blob,mode): + self.assertEqual(target,self.result) + self.assertEqual(mode,0o600) + with target.open("xb") as stream:stream.write(blob) + target.chmod(0o600) + g.write_new=synthetic_receipt + g.SERVICE=p/"unit.service";g.TIMER=p/"unit.timer" + self.fake={"user":"runner","home":str(self.home),"uid":os.getuid(), + "versionFromRoot":g.BASELINE,"versionFromUser":g.OLD_USER, + "versionTo":g.VERSION} + g.verify_guard_files=lambda:self.fake + self.events=[] + g.verify_installed_after_restore=lambda j:self.events.append("validate_restored") + g.command=lambda args,timeout=15:self.events.append("user_command") or types.SimpleNamespace(returncode=0,stdout="",stderr="") + self.old_geteuid=g.os.geteuid + g.os.geteuid=lambda:0 + self.addCleanup(lambda:setattr(g.os,"geteuid",self.old_geteuid)) + def test_01_independent_timer_survives_reboot(self): + deadline=1900000000 + s,t=g.unit_templates(deadline) + self.assertIn(b"OnActiveSec=480s",t) + self.assertIn(b"OnCalendar=2030-03-17 17:46:40 UTC",t) + self.assertIn(b"Persistent=true",t) + self.assertIn(b"WantedBy=timers.target",t) + self.assertNotIn(b"OnBootSec=",t) + self.assertIn(b"User=root",s) + self.assertIn(b"rollback",s) + def test_13_calendar_deadline_fixed_across_timer_restart(self): + _,original=g.unit_templates(1900000000) + _,same=g.unit_templates(1900000000) + _,later=g.unit_templates(1900000060) + self.assertEqual(original,same) + self.assertNotEqual(original,later) + self.assertIn(b"Persistent=true",original) + self.assertNotIn(b"OnBootSec=",original) + def test_14_invalid_calendar_deadline_rejected(self): + for bad in ("1900000000",None,-1,0,True): + with self.assertRaises(RuntimeError):g.unit_templates(bad) + def test_02_no_pending_does_not_change_system(self): + tx=FakeTx("NO_PENDING_UPGRADE");g.imported_root=lambda:tx + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(json.loads(self.result.read_text())["status"],"NO_CHANGE_NO_PENDING") + self.assertNotIn("user_command",self.events) + self.assertEqual(tx.calls,["status"]) + def test_03_active_owned_engine_never_mutates_on_timeout(self): + tx=FakeTx("APPLIED");g.imported_root=lambda:tx + s=self.home/g.USER_REL/"directmethod/state.json" + s.parent.mkdir(parents=True);s.write_text("{}") + self.assertEqual(g.rollback_worker(),90) + self.assertIn("LIVE_ENGINE_REQUIRES_LOCAL_RECOVERY", + json.loads(self.result.read_text())["failure"]) + self.assertNotIn("user_command",self.events) + self.assertEqual(tx.calls,["status"]) + def test_04_staged_unprivileged_user_then_root_rollback(self): + tx=FakeTx("APPLIED");g.imported_root=lambda:tx + slot=self.home/g.PENDING_REL;slot.mkdir(parents=True) + self.assertEqual(g.rollback_worker(),0) + out=json.loads(self.result.read_text()) + self.assertEqual(out["status"],"PASS_AUTONOMOUS_TWO_SIDED_RESTORATION") + self.assertEqual(out["userRollback"],"PASS") + self.assertEqual(out["rootRollback"],"PASS") + self.assertEqual(self.events,["user_command","validate_restored"]) + self.assertEqual(tx.calls,["status","rollback"]) + def test_05_staged_user_only_restore(self): + tx=FakeTx("NO_PENDING_UPGRADE");g.imported_root=lambda:tx + slot=self.home/g.PENDING_REL;slot.mkdir(parents=True) + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(tx.calls,["status"]) + self.assertEqual(json.loads(self.result.read_text())["status"], + "PASS_AUTONOMOUS_TWO_SIDED_RESTORATION") + def test_06_rejected_user_rollback_never_attempts_root(self): + tx=FakeTx("APPLIED");g.imported_root=lambda:tx + slot=self.home/g.PENDING_REL;slot.mkdir(parents=True) + g.command=lambda args,timeout=15:types.SimpleNamespace(returncode=22,stdout="",stderr="fake rejection") + self.assertEqual(g.rollback_worker(),90) + self.assertEqual(tx.calls,["status"]) + self.assertIn("USER_ROLLBACK",json.loads(self.result.read_text())["failure"]) + def _accepted_root_fixture(self): + app=self.home/g.USER_REL + app.mkdir(parents=True,exist_ok=True) + meta=app/"INSTALL.json" + meta.write_text(json.dumps({"schema":2,"version":g.VERSION, + "protectedBackendVersion":g.VERSION})) + meta.chmod(0o600) + rootmeta=self.home/"root-meta.json" + rootmeta.write_text(json.dumps({"version":g.VERSION,"uid":os.getuid()})) + class CommittedRoot: + native=True + meta=rootmeta + calls=[] + def status(self):self.calls.append("status");return {"status":"COMMITTED"} + def manifest(self): + return {"phase":"COMMITTED","fromVersion":g.BASELINE, + "toVersion":g.VERSION,"uid":os.getuid(),"home":str(self_home), + "files":{"helper":{"new":"EXPECTED"}},"newMetadataSHA":"ROOT_META_SHA"} + def _validate_slot(self,m):self.calls.append("validate") + def _target_states(self,m):self.calls.append("target_states");return {"helper":"EXPECTED"},"ROOT_META_SHA" + def rollback(self):raise AssertionError("ACCEPTED_ROOT_MUST_NEVER_ROLLBACK") + self_home=self.home + tx=CommittedRoot() + g.imported_root=lambda:tx + return tx,meta + + def test_07_committed_does_not_rollback(self): + tx,_=self._accepted_root_fixture() + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(json.loads(self.result.read_text())["status"], + "NO_ROLLBACK_ALREADY_COMMITTED") + self.assertNotIn("user_command",self.events) + self.assertEqual(tx.calls,["status","validate","target_states"]) + def test_08_idempotent_second_run_denied_by_owned_receipt(self): + tx=FakeTx("NO_PENDING_UPGRADE");g.imported_root=lambda:tx + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(json.loads(self.result.read_text())["status"], + "NO_CHANGE_NO_PENDING") + # Note second call currently computes but does not overwrite receipt. + def test_15_interrupted_accepted_root_finishes_exact_user_only(self): + tx,_=self._accepted_root_fixture() + pending=self.home/g.PENDING_REL + pending.mkdir(parents=True) + calls=[] + def confirm(args,timeout=15): + calls.append(tuple(args)) + self.assertEqual(args[-1],"--commit") + self.assertEqual(timeout,45) + pending.rmdir() + return types.SimpleNamespace(returncode=0,stdout="USER_COMMIT",stderr="") + g.command=confirm + self.assertEqual(g.rollback_worker(),0) + out=json.loads(self.result.read_text()) + self.assertEqual(out["status"],"PASS_RESUMED_ACCEPTED_ROOT_USER_COMMIT") + self.assertEqual(out["userForwardCommit"],"PASS") + self.assertTrue(out["finalizationAttempted"]) + self.assertFalse(pending.exists()) + self.assertEqual(len(calls),1) + self.assertEqual(tx.calls,["status","validate","target_states"]) + def test_16_rejected_user_commit_never_rolls_back_accepted_root(self): + tx,_=self._accepted_root_fixture() + pending=self.home/g.PENDING_REL;pending.mkdir(parents=True) + g.command=lambda args,timeout=15:types.SimpleNamespace( + returncode=84,stdout="",stderr="synthetic tampered config") + self.assertEqual(g.rollback_worker(),90) + doc=json.loads(self.result.read_text()) + self.assertEqual(doc["status"],"DEGRADED_UNRESOLVED") + self.assertIn("USER_FORWARD_COMMIT",doc["failure"]) + self.assertTrue(pending.exists()) + self.assertEqual(tx.calls,["status","validate","target_states"]) + def test_17_root_binary_drift_blocks_forward_commit(self): + tx,_=self._accepted_root_fixture() + pending=self.home/g.PENDING_REL;pending.mkdir(parents=True) + tx._target_states=lambda m:({"helper":"FOREIGN"},"ROOT_META_SHA") + g.command=lambda args,timeout=15: (_ for _ in ()).throw( + AssertionError("USER_COMMIT_MUST_NOT_RUN")) + self.assertEqual(g.rollback_worker(),90) + doc=json.loads(self.result.read_text()) + self.assertIn("ACCEPTED_ROOT_BINARY_OR_METADATA_DRIFT",doc["failure"]) + self.assertTrue(pending.exists()) + def test_18_active_engine_allows_only_accepted_backup_cleanup(self): + tx,_=self._accepted_root_fixture() + pending=self.home/g.PENDING_REL;pending.mkdir(parents=True) + state=self.home/g.USER_REL/"directmethod/state.json" + state.parent.mkdir(parents=True);state.write_text("{}") + g.command=lambda args,timeout=15:( + pending.rmdir() or types.SimpleNamespace(returncode=0,stdout="",stderr="")) + self.assertEqual(g.rollback_worker(),0) + self.assertTrue(state.is_file()) + self.assertNotIn("user_command",self.events) + self.assertEqual(json.loads(self.result.read_text())["status"], + "PASS_RESUMED_ACCEPTED_ROOT_USER_COMMIT") + + def _terminal_rolledback_fixture(self): + """Synthetic exact root owner/perms, never real host Root/files.""" + self.fake["uid"]=os.getuid() + self.fake["oldRootMetaSha"]="ORIG_ROOT" + prior=g.regular + self.addCleanup(lambda:setattr(g,"regular",prior)) + def fake_regular(path,uid=None,mode=None): + if path==g.RESULT: + if not path.is_file() or path.is_symlink() or mode!=0o600: + raise RuntimeError("R751_TEST_RESULT_NOT_REGULAR") + return path.stat() + return prior(path,uid,mode) + g.regular=fake_regular + receipt={ + "revision":"R552", + "status":"PASS_AUTONOMOUS_TWO_SIDED_RESTORATION", + "uid":0, + "rootPhaseBefore":"APPLIED", + "rootRollback":"PASS", + "userRollback":"PASS", + "recoveryAttempted":True, + "networkMutationAttempted":False, + "whenUtc":"2026-10-17T22:39:13Z", + "durationSeconds":0.119, + } + self.result.write_bytes(g.canonical(receipt)) + self.result.chmod(0o600) + root_home=str(self.home) + uid=self.fake["uid"] + class RolledBack: + native=True + def __init__(self):self.calls=[] + def status(self): + self.calls.append("status") + return {"status":"ROLLED_BACK"} + def manifest(self): + self.calls.append("manifest") + return {"phase":"ROLLED_BACK","fromVersion":g.BASELINE, + "toVersion":g.VERSION,"uid":uid,"home":root_home, + "files":{"helper":{"old":"ORIGINAL"}},"oldMetadataSHA":"ORIG_ROOT"} + def _validate_slot(self,manifest): + self.calls.append("validate") + def _target_states(self,manifest): + self.calls.append("target_states") + return {"helper":"ORIGINAL"},"ORIG_ROOT" + def rollback(self): + raise AssertionError("ALREADY_ROLLED_BACK_MUST_NOT_ROLLBACK_AGAIN") + tx=RolledBack() + g.imported_root=lambda:tx + return tx,receipt + + def test_19_terminal_rolledback_second_timer_is_idempotent_and_retained(self): + tx,receipt=self._terminal_rolledback_fixture() + original=self.result.read_bytes() + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(self.result.read_bytes(),original) + self.assertIn("validate_restored",self.events) + self.assertNotIn("user_command",self.events) + self.assertEqual(tx.calls,["status","manifest","validate","target_states"]) + + def test_20_repeat_third_timer_keeps_original_receipt(self): + tx,_=self._terminal_rolledback_fixture() + original=self.result.read_bytes() + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(g.rollback_worker(),0) + self.assertEqual(self.result.read_bytes(),original) + self.assertEqual(self.events,["validate_restored","validate_restored"]) + + def test_21_rolledback_wrong_receipt_must_fail_closed(self): + tx,receipt=self._terminal_rolledback_fixture() + receipt["userRollback"]="FAIL" + self.result.write_bytes(g.canonical(receipt)) + self.assertEqual(g.rollback_worker(),90) + self.assertEqual(self.result.read_bytes(),g.canonical(receipt)) + self.assertNotIn("validate_restored",self.events) + self.assertNotIn("user_command",self.events) + + def test_22_rolledback_missing_receipt_must_fail_closed(self): + tx,_=self._terminal_rolledback_fixture() + self.result.unlink() + self.assertEqual(g.rollback_worker(),90) + self.assertNotIn("validate_restored",self.events) + self.assertNotIn("user_command",self.events) + + def test_23_rolledback_root_original_hash_drift_must_fail_closed(self): + tx,_=self._terminal_rolledback_fixture() + tx._target_states=lambda m:({"helper":"FOREIGN"},"ORIG_ROOT") + self.assertEqual(g.rollback_worker(),90) + self.assertNotIn("validate_restored",self.events) + + def test_24_rolledback_user_pending_must_not_report_clean(self): + tx,_=self._terminal_rolledback_fixture() + pending=self.home/g.PENDING_REL + pending.mkdir(parents=True) + self.assertEqual(g.rollback_worker(),90) + self.assertNotIn("validate_restored",self.events) + + def test_09_sources_are_not_host_install_commands(self): + src=SRC.read_text() + self.assertNotIn("sudo -S",src) + self.assertNotIn("tailscale down",src) + self.assertNotIn("nmcli con down",src) + self.assertIn("Path(__file__).resolve()==WORKER",src) + def _stage_fixture(self): + app=self.home/g.USER_REL + app.mkdir(parents=True) + installed=app/"INSTALL.json" + installed.write_text('{"version":"1.5.4"}') + slot=self.home/g.PENDING_REL + slot.mkdir(parents=True,mode=0o700) + slot.chmod(0o700) + names=("direct_internet_method.py","socket_fallback.py", + "router_gateway.py","router_gateway_core.py", + "direct-internet-method.svg","uninstall.sh", + "user_uninstall_preserve.py","user_update_transaction.py") + managed={k:{"sha":"a"*64,"mode":0o755} for k in names} + prior={k:{"exists":False,"sha":None} for k in managed} + prior["INSTALL.json"]={"exists":True,"sha":g.sha_file(installed).lower()} + mutable=("settings.json","strategy.txt","scope.txt","custom-hosts.txt", + "adult-enabled.txt","adult-hosts.txt","adult-hosts.meta.json", + "encrypted-dns-optin.txt","selective-dns-optin.txt") + row={"schema":1,"version":g.VERSION,"previousVersion":g.OLD_USER, + "managed":managed,"old":prior, + "providerManaged":False,"providerSha":None, + "mutable":{k:None for k in mutable}} + manifest=slot/"manifest.json" + manifest.write_text(json.dumps(row));manifest.chmod(0o600) + return installed,manifest,row + + def test_11_real_user_stage_schema_not_invented_phase(self): + installed,manifest,row=self._stage_fixture() + self.assertNotIn("phase",row) + accepted=g.staged_user_manifest(self.home,os.getuid(),installed) + self.assertEqual(accepted["version"],g.VERSION) + self.assertEqual(len(accepted["managed"]),8) + + def test_25_real_managed_provider_nine_members_accepted(self): + installed,manifest,row=self._stage_fixture() + provider="router_gateway/providers.json" + digest="b"*64 + row["managed"][provider]={"sha":digest,"mode":0o644} + row["old"][provider]={"exists":True,"sha":digest} + row["providerManaged"]=True + row["providerSha"]=digest + manifest.write_text(json.dumps(row)) + result=g.staged_user_manifest(self.home,os.getuid(),installed) + self.assertEqual(len(result["managed"]),9) + self.assertEqual(result["providerSha"],digest) + + def test_26_managed_provider_flag_or_digest_drift_refused(self): + installed,manifest,row=self._stage_fixture() + provider="router_gateway/providers.json" + def refuse(): + manifest.write_text(json.dumps(row)) + with self.assertRaisesRegex(RuntimeError,"USER_STAGE_FILESET"): + g.staged_user_manifest(self.home,os.getuid(),installed) + row["providerManaged"]=True + refuse() # Missing managed provider entry. + row["managed"][provider]={"sha":"a"*64,"mode":0o644} + row["old"][provider]={"exists":True,"sha":"a"*64} + row["providerSha"]="0"*64 + refuse() # SHA not the staged provider's digest. + row["providerSha"]="a"*64 + row["providerManaged"]=False + refuse() # Unmanaged means provider file must NOT be staged. + + def test_27_foreign_extra_managed_and_mutable_paths_refused(self): + installed,manifest,row=self._stage_fixture() + def refuse(): + manifest.write_text(json.dumps(row)) + with self.assertRaisesRegex(RuntimeError,"USER_STAGE_FILESET"): + g.staged_user_manifest(self.home,os.getuid(),installed) + row["managed"]["foreign_plugin.sh"]={"sha":"c"*64,"mode":0o700} + row["old"]["foreign_plugin.sh"]={"exists":False,"sha":None} + refuse() + del row["managed"]["foreign_plugin.sh"] + del row["old"]["foreign_plugin.sh"] + row["mutable"]["arbitrary-foreign-user-data"]="00" + refuse() + + def test_12_stage_schema_and_metadata_drift_rejected(self): + installed,manifest,row=self._stage_fixture() + def reject(modified): + manifest.write_text(json.dumps(modified)) + with self.assertRaises(RuntimeError):g.staged_user_manifest(self.home,os.getuid(),installed) + reject({**row,"previousVersion":"1.5.5-rc1"}) + reject({**row,"managed":{}}) + reject({**row,"old":{**row["old"],"INSTALL.json":{"exists":True,"sha":"0"*64}}}) + reject({**row,"mutable":{}}) + + def test_28_rc1_previous_stage_must_use_exact_declared_pair(self): + installed,manifest,row=self._stage_fixture() + installed.write_text('{"version":"1.5.5-rc1"}') + row["old"]["INSTALL.json"]["sha"]=g.sha_file(installed).lower() + row["previousVersion"]="1.5.5-rc1" + manifest.write_text(json.dumps(row)) + with self.assertRaisesRegex(RuntimeError,"USER_STAGE_IDENTITY"): + g.staged_user_manifest(self.home,os.getuid(),installed) + accepted=g.staged_user_manifest( + self.home,os.getuid(),installed,"1.5.5-rc1") + self.assertEqual(accepted["version"],"1.5.7") + self.assertEqual(accepted["previousVersion"],"1.5.5-rc1") + + def test_29_only_correlated_root_and_user_versions_allowed(self): + self.assertEqual( + g.ALLOWED_BASELINE_PAIRS, + (("1.5.2","1.5.4"),("1.5.5-rc1","1.5.5-rc1"),("1.5.6","1.5.6"))) + for mixed in (("1.5.2","1.5.5-rc1"), + ("1.5.5-rc1","1.5.4"), + ("1.5.3","1.5.4"), + ("1.5.5-rc1","1.5.6"), + ("1.5.6","1.5.5-rc1"), + ("1.5.6","1.5.4"), + ("1.5.7","1.5.7")): + self.assertNotIn(mixed,g.ALLOWED_BASELINE_PAIRS) + + def test_10_readiness_worker_does_not_query_elapsed_active_timer(self): + txt=SRC.read_text() + self.assertIn("j=verify_guard_files()",txt) + self.assertNotIn("j=verify_timer()\n home=Path(j",txt) +if __name__=="__main__": + t=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Cases)) + print("R552_SOURCE_EXTRACTED tests=",t.testsRun,"failures=",len(t.failures),"errors=",len(t.errors)) + raise SystemExit(0 if t.wasSuccessful() else 1) diff --git a/tests/r554_native_guard_ephemeral_contract.py b/tests/r554_native_guard_ephemeral_contract.py new file mode 100644 index 00000000..7c8c66b3 --- /dev/null +++ b/tests/r554_native_guard_ephemeral_contract.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +"""R554 exact product upgrade-guard native disposable systemd lifecycle proof. + +Never execute outside an *empty* GitHub-hosted ephemeral Ubuntu VM. Uses +official SHA-pinned v1.5.4 baseline and exact 27-file 1.5.5 RC archive. +Stages real unprivileged user data, arms actual root-owned systemd timer BEFORE +any protected modification; invokes the protected root stage/apply, applies User +code, verifies exact installer inline metadata, triggers the separately owned +systemd SERVICE (not a fake Python callback) and checks two-sided byte-exact +restoration. Native monotonic timer expiration is independently proven by R532. +No network/DNS/Tailscale/GUI Start/Stop action; not production acceptance. +""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import stat +import subprocess +import sys +import tempfile +import time + +R=Path(__file__).resolve().parents[1] +R528=R/"tests/r528_ephemeral_native_root_cli_contract.py" +R529=R/"tests/r529_ephemeral_root_user_transaction.py" +R526=R/"tests/r526_installer_metadata_contract.py" +ROOT=R/"linux/system/protected_upgrade_transaction.py" +GUARD=R/"linux/system/upgrade_guard.py" +PIN_PACKAGE="F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6" +PIN_ROOT="239A0C21EB4A9564799A97AFE440C120668CEDCD49412E33D0AC78C8866820E7" +PIN_GUARD="66B683E14FCDEC8D62CDFD2879AAB29D9DE0FD778DD67E0522BFB1218DC122D0" +PIN_USER="69AE3A76CFCD3BB251546CCD5237F3AF081ED0477323A8F95737C8A4BC87B6F8" +NAME="directinternetmethod-upgrade-rollback" +SEEN=Path("/var/lib/directinternetmethod/upgrade-guard/rollback-result.json") + +def need(ok,why): + if not ok:raise RuntimeError("R554_NATIVE_GUARD_FAIL_CLOSED_"+why) + +def sha(path): + h=hashlib.sha256() + with Path(path).open("rb") as f: + for piece in iter(lambda:f.read(1048576),b""):h.update(piece) + return h.hexdigest().upper() + +def module(path,modname): + spec=importlib.util.spec_from_file_location(modname,path) + need(spec is not None and spec.loader is not None,"IMPORT_"+modname) + obj=importlib.util.module_from_spec(spec) + spec.loader.exec_module(obj) + return obj + +def command(args,seconds=25): + proc=subprocess.run(args,capture_output=True,text=True,check=False,timeout=seconds) + need(proc.returncode==0,"COMMAND_"+str(proc.returncode)+"_"+proc.stderr[-200:].replace(" ","_")) + return proc.stdout.strip() + +def preflight_vm(): + need(os.geteuid()==0 and sys.platform.startswith("linux"),"NATIVE_ROOT") + need(os.environ.get("GITHUB_ACTIONS")=="true" and os.environ.get("CI")=="true" + and os.environ.get("RUNNER_ENVIRONMENT")=="github-hosted" + and os.environ.get("GITHUB_REPOSITORY")=="GOD13emad/DirectInternetMethod" + and os.environ.get("GITHUB_WORKSPACE")==str(R) + and R==Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod"), + "GITHUB_EPHEMERAL_ONLY") + need(Path("/proc/1/comm").read_text().strip()=="systemd","SYSTEMD_PID1") + runner=pwd.getpwnam("runner") + need(runner.pw_dir=="/home/runner" and runner.pw_uid>0,"RUNNER_IDENTITY") + for path in ("/usr/lib/directinternetmethod", + "/var/lib/directinternetmethod", + "/etc/directinternetmethod.uid", + "/etc/systemd/system/"+NAME+".service", + "/etc/systemd/system/"+NAME+".timer", + "/home/runner/.local/share/DirectInternetMethod", + "/home/runner/.local/state/DirectInternetMethod", + "/run/directinternetmethod-encrypted"): + p=Path(path) + need(not p.exists() and not p.is_symlink(),"FOREIGN_PREEXISTING_"+p.name) + need(sha(ROOT)==PIN_ROOT and sha(GUARD)==PIN_GUARD,"SOURCE_SHA_DRIFT") + need(sha(R/"linux/app/user_update_transaction.py")==PIN_USER,"USER_SOURCE_SHA_DRIFT") + return runner + +def run(): + runner=preflight_vm() + r528=module(R528,"r554_r528") + r529=module(R529,"r554_r529") + r526=module(R526,"r554_r526") + rootmod=r528.module() + guard=module(GUARD,"r554_real_guard") + stable,package,temp,user=r528.prerequisites() + need(user.pw_uid==runner.pw_uid,"USER_CHANGED") + need(sha(package)==PIN_PACKAGE,"PINNED_CANDIDATE") + r529.require_clean_home(user) + extracted=r528.stage_exact_candidate(package,temp) + r529.make_source_runner_owned(extracted,user) + # Installed user and root bytes come only from the public v1.5.4 ZIP. + app,original_user,private=r529.prepare_user_from_official(stable,user) + protected=r528.seed_real_protected_root(stable,user,rootmod) + baseline_network={ + p:sha(p) for p in ("/etc/resolv.conf","/etc/hosts") + } + before_link=(os.readlink("/etc/resolv.conf") + if Path("/etc/resolv.conf").is_symlink() else None) + before_user=sha(app/"INSTALL.json") + before_root=sha("/var/lib/directinternetmethod/install.json") + tx_user=extracted/"app/user_update_transaction.py" + need(sha(tx_user)==PIN_USER,"EXTRACTED_USER_SHA") + need(sha(extracted/"system/upgrade_guard.py")==PIN_GUARD,"EXTRACTED_GUARD_SHA") + need(sha(extracted/"system/protected_upgrade_transaction.py")==PIN_ROOT,"EXTRACTED_ROOT_SHA") + root_tx=rootmod.RootTransaction() + need(root_tx.native and root_tx.status()["status"]=="NO_PENDING_UPGRADE","ROOT_PRESTATE") + # Unprivileged stage creates only the bounded user rollback snapshot. + r529.as_runner(user,tx_user,"--stage","--source",extracted) + pending=Path("/home/runner/.local/state/DirectInternetMethod/update-pending") + need((pending/"manifest.json").is_file(),"USER_STAGE") + r529.unchanged_records(original_user) + r529.unchanged_records(private) + # Two separate disposable-VM paths: original direct product transaction + # and the ACTUAL production upgrade_system.sh wrapper and auto-arm. + # Both must install the same root-owned timer and restore exact baselines. + native_wrapper=os.environ.get("DIM_R555_EXACT_WRAPPER")=="true" + need(root_tx.status()["status"]=="NO_PENDING_UPGRADE","ROOT_MUTATED_BEFORE_ARM") + if native_wrapper: + wrapper=extracted/"system/upgrade_system.sh" + need(wrapper.is_file() and not wrapper.is_symlink(),"EXACT_WRAPPER") + out=command(["/bin/bash",str(wrapper),str(extracted),str(user.pw_uid)],80) + need("R553_ROOT_GUARD_ACTIVE_PRESTAGE_VERIFIED" in out + and "ROOT_R519_APPLIED_UNCOMMITTED" in out, + "PRODUCTION_WRAPPER_GUARD_ACCEPTANCE") + else: + armed=guard.arm(extracted,user.pw_uid) + need(armed["status"]=="ARMED_INDEPENDENT_ROOT_USER_WATCHDOG","WATCHDOG_ARM") + need(root_tx.status()["status"]=="NO_PENDING_UPGRADE","ROOT_MUTATED_BEFORE_ARM") + need(root_tx.stage(extracted)["status"]=="STAGED","ROOT_STAGE") + need(root_tx.apply()["status"]=="APPLIED","ROOT_APPLY") + present=guard.verify_timer() + need(present["uid"]==user.pw_uid and present["workerSha"]==sha(guard.WORKER), + "SYSTEMD_TIMER_GUARD") + # R559: the calendar deadline is immutable across timer restart. On + # reboot the original calendar event and Persistent=true restore the + # missed rollback, rather than restarting OnActiveSec from zero. + deadline=present.get("deadlineUnix") + need(type(deadline) is int and deadline>0,"PERSISTENT_DEADLINE_IN_BASELINE") + timestamp=guard.datetime.datetime.fromtimestamp( + deadline,guard.datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + timer_body=guard.TIMER.read_text("utf8") + need("OnCalendar="+timestamp+"\n" in timer_body and + "Persistent=true\n" in timer_body and + "OnActiveSec=480s\n" in timer_body,"PERSISTENT_WATCHDOG_TIMER_CONTENT") + command(["/usr/bin/systemd-analyze","calendar",timestamp],10) + next_cal_before=command(["/usr/bin/systemctl","show",NAME+".timer", + "-p","NextElapseUSecRealtime","--value"]) + need(next_cal_before and next_cal_before not in ("n/a","0"), + "CALENDAR_DEADLINE_NOT_SCHEDULED") + command(["/usr/bin/systemctl","restart",NAME+".timer"],12) + next_cal_after=command(["/usr/bin/systemctl","show",NAME+".timer", + "-p","NextElapseUSecRealtime","--value"]) + need(next_cal_after==next_cal_before,"RESTART_MOVED_ORIGINAL_DEADLINE") + guard.verify_timer() + need(root_tx.status()["status"]=="APPLIED","ROOT_PENDING") + r529.as_runner(user,tx_user,"--apply","--source",extracted) + manifest=json.loads((pending/"manifest.json").read_text()) + for rel,row in manifest["managed"].items(): + need(r529.hash_file(app/rel)==row["sha"],"USER_APPLY_SHA_"+rel) + r529.unchanged_records(private) + # Run the exact, unchanged production installer INSTALL.json fragment. + install=(extracted/"install.sh").read_text(encoding="utf8") + need(install.count(r526.BEGIN)==1,"INSTALL_METADATA_ANCHOR") + part=install.split(r526.BEGIN,1)[1] + need(part.count(r526.END)==1,"INSTALL_END") + inline=part.split(r526.END,1)[0] + need("transaction_helper.atomic_bytes(" in inline + and len(inline.splitlines())==64,"INSTALL_INLINE_IDENTITY") + r529.as_runner_inline(user,inline,app/"INSTALL.json",tx_user) + need(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.7", + "UPGRADED_USER_VERSION") + need(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.7", + "UPGRADED_ROOT_VERSION") + need(root_tx.status()["status"]=="APPLIED","ROOT_STILL_PENDING") + # R562 tests interruption AFTER a deliberately simulated protected Root + # acceptance and BEFORE User commit. NOT real owner/live acceptance: + # a marked synthetic fixture exercises the fail-closed crash window only. + synthetic_promotion=os.environ.get("DIM_R562_SYNTHETIC_PROMOTION")=="true" + synthetic_reject=os.environ.get("DIM_R565_SYNTHETIC_MUTABLE_DRIFT")=="true" + need(not synthetic_reject or synthetic_promotion, + "MUTABLE_NEGATIVE_REQUIRES_DISPOSABLE_ROOT_ACCEPTANCE_FIXTURE") + if synthetic_promotion: + m=root_tx.manifest() + fixture={ + "revision":"R519","status":"PASS_NATIVE_ROOT_UPGRADE", + "syntheticAcceptanceFixture":True, + "sourceRootSHA":rootmod.sha(rootmod.canonical(m["files"])), + "protectedMetadataSHA":m["newMetadataSHA"], + "transactionNonce":m["transactionNonce"], + "nativeStartStopRecovery":True, + "tailscalePreserved":True, + "physicalDNSPreserved":True + } + # Native Root now refuses receipts routed through user-owned parents. + # The old Path(temp) is deliberately runner-owned and must remain + # rejected. Generate this TEST-ONLY receipt in a private OS-root + # directory instead of weakening the production Root validation. + with tempfile.TemporaryDirectory(prefix="DIM_R562_ROOT_PRIVATE_RECEIPT_",dir="/tmp") as td: + acceptance_dir=Path(td) + st=acceptance_dir.stat() + need(st.st_uid==0 and stat.S_IMODE(st.st_mode)==0o700, + "TEST_RECEIPT_ROOT_PRIVATE_PARENT") + acceptance=acceptance_dir/"DIM_R562_TEST_ONLY_ACCEPTANCE.json" + need(not acceptance.exists() and not acceptance.is_symlink(), + "FOREIGN_TEST_ACCEPTANCE_EXISTS") + acceptance.write_text(json.dumps(fixture,sort_keys=True,indent=2)+"\n") + acceptance.chmod(0o600) + need(acceptance.stat().st_uid==0,"TEST_RECEIPT_ROOT_OWNER") + need(root_tx.commit(acceptance)["status"]=="COMMITTED", + "SYNTHETIC_ROOT_FINALIZATION") + need(root_tx.status()["status"]=="COMMITTED" and pending.is_dir(), + "ROOT_COMMITTED_USER_STILL_PENDING_WINDOW") + if synthetic_reject: + # The *only* deliberate user-data mutation happens on this disposable + # GitHub runner AFTER synthetically accepting Root and BEFORE user commit. + mutable=app/"settings.json" + old_mutable=mutable.read_bytes() + need(sha(mutable).lower()==private[str(mutable)], + "MUTABLE_TEST_PRESTATE_NOT_BASELINED") + mutable.write_bytes(old_mutable+b"R565_TEST_ONLY_MUTABLE_DRIFT\n") + need(sha(mutable).lower()!=private[str(mutable)], + "MUTABLE_DRIFT_FIXTURE_INEFFECTIVE") + # Actual independent root-owned systemd Service, rather than calling + # guard.rollback_worker in-process. R532 separately proves timed firing. + start=time.monotonic() + if synthetic_reject: + # A failure is expected here. Real systemd must propagate the owned + # worker's nonzero exit rather than silently report successful commit. + attempt=subprocess.run( + ["/usr/bin/systemctl","start",NAME+".service"], + capture_output=True,text=True,check=False,timeout=100) + need(attempt.returncode!=0,"UNSAFE_USER_COMMIT_FALSE_SUCCESS") + else: + command(["/usr/bin/systemctl","start",NAME+".service"],100) + need(SEEN.is_file() and not SEEN.is_symlink(),"SYSTEMD_RECEIPT_MISSING") + receipt=json.loads(SEEN.read_text()) + receipt_sha_before_retirement=sha(SEEN) + if synthetic_reject: + need(receipt.get("status")=="DEGRADED_UNRESOLVED" + and receipt.get("uid")==0 + and receipt.get("finalizationAttempted") is True + and receipt.get("recoveryAttempted") is False, + "FAILED_USER_FORWARD_COMMIT_WAS_NOT_RECORDED") + need("USER_FORWARD_COMMIT" in str(receipt.get("failure", "")) + and "MUTABLE_USER_DATA_CHANGED_CONCURRENTLY" in str(receipt.get("failure", "")), + "MUTABLE_DATA_DRIFT_WAS_NOT_REJECTED") + need(root_tx.status()["status"]=="COMMITTED" + and root_tx.pending.is_dir() and pending.is_dir(), + "ACCEPTED_ROOT_OR_USER_BACKUP_WAS_REVERTED") + need(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.7" + and json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.7", + "COMMITTED_VERSION_CHANGED_ON_FAILED_FINALIZATION") + for rel,row in manifest["managed"].items(): + dest=app/rel + need(dest.is_file() and sha(dest).lower()==row["sha"], + "USER_CODE_MUTATED_ON_REJECTED_COMMIT_"+rel) + for _,relative,target,oldsha,newsha,mode in rootmod.FILES: + dest=Path("/")/target + need(dest.is_file() and sha(dest)==newsha + and stat.S_IMODE(dest.stat().st_mode)==mode, + "PROTECTED_ROOT_MUTATED_ON_REJECTED_COMMIT_"+dest.name) + for rel,want in rootmod.STATIC_ROOT.items(): + need(sha(Path("/")/rel)==want, + "STATIC_ROOT_MUTATED_ON_REJECTED_USER_COMMIT_"+Path(rel).name) + for file,want in private.items(): + now=sha(file).lower() + if file==str(app/"settings.json"): + need(now!=want,"FIXTURE_MUTABLE_DRIFT_WAS_LOST") + else: + need(now==want,"UNRELATED_USER_MUTABLE_DRIFT_"+Path(file).name) + elif synthetic_promotion: + need(receipt.get("status")=="PASS_RESUMED_ACCEPTED_ROOT_USER_COMMIT" + and receipt.get("uid")==0 + and receipt.get("userForwardCommit")=="PASS" + and receipt.get("finalizationAttempted") is True, + "ACCEPTED_ROOT_USER_COMMIT_NOT_RECONCILED") + need(root_tx.status()["status"]=="COMMITTED","ACCEPTED_ROOT_PHASE_LOST") + need(not pending.exists(),"USER_BACKUP_NOT_RELEASED") + need(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.7", + "COMMITTED_USER_VERSION") + need(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.7", + "COMMITTED_ROOT_VERSION") + installed_manifest=json.loads((app/"INSTALL.json").read_text()) + for rel,row in manifest["managed"].items(): + dest=app/rel + need(dest.is_file() and sha(dest).lower()==row["sha"], + "COMMITTED_USER_CODE_DRIFT_"+rel) + for _,relative,target,oldsha,newsha,mode in rootmod.FILES: + dest=Path("/")/target + need(dest.is_file() and sha(dest)==newsha + and stat.S_IMODE(dest.stat().st_mode)==mode, + "COMMITTED_ROOT_CODE_DRIFT_"+dest.name) + for rel,want in rootmod.STATIC_ROOT.items(): + need(sha(Path("/")/rel)==want,"STATIC_ROOT_CHANGED_"+Path(rel).name) + need(root_tx.pending.is_dir(),"ROOT_AUDIT_SLOT_STILL_RETAINED") + else: + need(receipt.get("status")=="PASS_AUTONOMOUS_TWO_SIDED_RESTORATION" + and receipt.get("uid")==0 and receipt.get("userRollback")=="PASS" + and receipt.get("rootRollback")=="PASS" + and receipt.get("recoveryAttempted") is True, + "ROOT_OWNED_SERVICE_NOT_ACCEPTED") + need(root_tx.status()["status"]=="ROLLED_BACK","ROOT_PHASE_NOT_ROLLED_BACK") + need(not pending.exists(),"USER_PENDING_REMAINS") + need(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.4", + "USER_VERSION_NOT_RESTORED") + need(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.2", + "ROOT_VERSION_NOT_RESTORED") + need(sha(app/"INSTALL.json")==before_user + and sha("/var/lib/directinternetmethod/install.json")==before_root, + "ORIGINAL_METADATA_SHA_DRIFT") + for target,want in original_user.items(): + need(sha(target).lower()==want,"USER_FILE_RESTORATION_"+Path(target).name) + for target,want in protected.items(): + need(sha(target).lower()==want,"PROTECTED_FILE_RESTORATION_"+Path(target).name) + if not synthetic_reject: + r529.unchanged_records(private) + for p,want in baseline_network.items(): + need(sha(p)==want,"NETWORK_FILE_MUTATED") + need(before_link==(os.readlink("/etc/resolv.conf") + if Path("/etc/resolv.conf").is_symlink() else None),"RESOLVER_SYMLINK_MUTATED") + # R568 is an explicit root-authorized maintenance command AFTER verified + # terminal two-sided rollback; never invoked by a live timer or installer. + retire=os.environ.get("DIM_R568_TEST_RETIRE_ROLLED_BACK")=="true" + retired=False + if retire: + need(not synthetic_promotion and not synthetic_reject, + "RETIRE_TEST_NEEDS_REAL_ROLLBACK") + need(root_tx.status()["status"]=="ROLLED_BACK" + and root_tx.pending.is_dir() and guard.GUARD.is_dir(), + "RETIRE_TEST_MISSING_REAL_TERMINAL_SLOT") + sha_receipt=sha(SEEN) + cli=["/usr/bin/python3","-B",str(guard.WORKER), + "retire-rolledback","--receipt-sha256"] + denied=subprocess.run(cli+["0"*64],text=True,capture_output=True, + timeout=12,check=False) + need(denied.returncode!=0 and + "RETIRE_RECEIPT_SHA_CHANGED" in denied.stderr, + "RETIRE_BAD_EVIDENCE_NOT_REJECTED") + need(root_tx.pending.is_dir() and guard.GUARD.is_dir() + and guard.TIMER.exists(),"RETIRE_BAD_EVIDENCE_MUTATED_SLOT") + foreign=guard.GUARD/"r568-synthetic-foreign.txt" + need(not foreign.exists(),"RETIRE_SYNTHETIC_FOREIGN_PREEXISTS") + foreign.write_text("foreign-test") + foreign.chmod(0o600) + refused=subprocess.run(cli+[sha_receipt],text=True,capture_output=True, + timeout=12,check=False) + need(refused.returncode!=0 and + "RETIRE_FOREIGN_ENTRY_SET" in refused.stderr, + "RETIRE_FOREIGN_ENTRY_NOT_REJECTED") + need(root_tx.pending.is_dir() and guard.GUARD.is_dir() + and guard.TIMER.exists(),"RETIRE_FOREIGN_ENTRY_MUTATED_SLOT") + foreign.unlink() # remove ONLY our synthetic test-owned file + result=command(cli+[sha_receipt],40) + doc=json.loads(result.splitlines()[-1]) + need(doc["status"]=="PASS_AUTHORIZED_BOUNDED_ROLLBACK_RETIRE" + and doc["receiptSha256"]==sha_receipt, + "RETIRE_BOUNDED_RESULT_NOT_ACCEPTED") + need(root_tx.status()["status"]=="NO_PENDING_UPGRADE" + and not guard.GUARD.exists() and not guard.SERVICE.exists() + and not guard.TIMER.exists(),"RETIRE_LEFT_OWNED_ROOT_SLOT") + stamp=Path("/var/lib/systemd/timers/stamp-"+NAME+".timer") + need(not stamp.exists() and not stamp.is_symlink(), + "RETIRE_SYSTEMD_PERSISTENT_TIMER_STAMP_REMAINS") + pf=guard.preflight(extracted) + need(pf["status"]=="SAFE_SOURCE_AND_INSTALLED_BASELINE_ONLY", + "RETIRE_NEXT_SAFE_UPDATE_STILL_BLOCKED") + retired=True + # No reboot, no network/Tailscale manipulation, and no second install. + need(not (app/"directmethod/state.json").exists(),"ACTIVE_ENGINE_UNEXPECTED") + if not retired: + command(["/usr/bin/systemctl","disable","--now",NAME+".timer"]) + print(json.dumps({ + "syntheticRootAcceptanceOnly":synthetic_promotion, + "acceptedRootUserInterruptedResume":synthetic_promotion and not synthetic_reject, + "operatorAttestedRollbackSlotRetired":retired, + "repeatUpgradeSafelyUnblocked":retired, + "expectedFailClosedSyntheticMutableDrift":synthetic_reject, + "revision":"R554","status":( + "PASS_EPHEMERAL_SYNTHETIC_MUTABLE_DRIFT_FAIL_CLOSED_AFTER_ACCEPTED_ROOT" + if synthetic_reject else + "PASS_EPHEMERAL_SYNTHETIC_ROOT_ACCEPTANCE_USER_FINALIZATION" + if synthetic_promotion else + "PASS_EPHEMERAL_ACTUAL_SYSTEMD_OWNED_GUARD_ROOT_USER_ROLLBACK"), + "host":"ephemeral-github-ubuntu", + "nativeProtectedRoot":True,"rootAuthorizedInDisposableVM":True, + "guardArmedBeforeRootMutation":True, + "systemdServiceStartedInSeparateProcess":True, + "timerNativeLoadedAndEnabledBeforeMutation":True, + "calendarDeadlinePreservedAcrossTimerRestart":True, + "productionShellUpgradeWrapperExecuted":native_wrapper, + "rootAndUserByteExactRestoration":not synthetic_promotion, + "mutatedProductionHost":False,"modifiedRouteDnsOrTailscale":False, + "publishedStablePreserved":"v1.5.4", + "rootFileCount":len(protected), + "userFileCount":len(original_user), + "privateUserFileCount":len(private), + "serviceDurationSeconds":round(time.monotonic()-start,3), + "watchdogReceiptSHA256":receipt_sha_before_retirement},sort_keys=True)) +if __name__=="__main__": + try:run() + except BaseException as e: + print(json.dumps({"revision":"R554","status":"FAIL_CLOSED", + "reason":str(e)[:500]}),file=sys.stderr) + sys.exit(94) diff --git a/tests/r566_native_systemd_persistent_catchup.py b/tests/r566_native_systemd_persistent_catchup.py new file mode 100644 index 00000000..22c36e15 --- /dev/null +++ b/tests/r566_native_systemd_persistent_catchup.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""R566: systemd native missed-calendar catch-up, runner only. + +Exercises actual exact production timer directives OnActiveSec=480s, +OnCalendar= and Persistent=true. A different, uniquely named +synthetic one-shot systemd service writes only in /run on disposable +GitHub-hosted Ubuntu. No DIM installed application, network, Tailscale, +DNS, user-owned files, releases or root upgrade changes. +""" +from __future__ import annotations +import datetime as dt +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import sys +import tempfile +import time + +ROOT=Path(__file__).resolve().parents[1] +GUARD=ROOT/"linux/system/upgrade_guard.py" +PIN_GUARD="66B683E14FCDEC8D62CDFD2879AAB29D9DE0FD778DD67E0522BFB1218DC122D0" + +def need(value,reason): + if not value:raise RuntimeError("R566_SYSTEMD_FAIL_CLOSED_"+reason) + +# This disposable Root systemd test must not become a generic command +# launcher. Only known unit operations, under the one absolute OS-owned +# systemctl executable, are permitted; all variable unit IDs are numeric. +UNIT_RE=re.compile(r"dim-r566-catchup-[0-9]{1,29}\.(?:timer|service)\Z") + + +def run(*args,timeout=12): + need(len(args)>=2 and all(type(item) is str for item in args) and + args[0]=="/usr/bin/systemctl","UNTRUSTED_COMMAND_ORIGIN") + program=Path("/usr/bin/systemctl") + need(program.is_file() and not program.is_symlink() and + program.stat().st_uid==0 and not program.stat().st_mode & 0o022, + "UNTRUSTED_SYSTEMCTL_BINARY") + action=args[1] + if action=="daemon-reload": + need(args==("/usr/bin/systemctl","daemon-reload"), + "INVALID_DAEMON_RELOAD_ARGUMENTS") + elif action in ("start","stop"): + need(len(args)==3 and bool(UNIT_RE.fullmatch(args[2])), + "INVALID_SYSTEMD_UNIT_ARGUMENTS") + elif action=="show": + need(len(args)==6 and bool(UNIT_RE.fullmatch(args[2])) and + args[3:]==("-p","ActiveState","--value"), + "INVALID_SYSTEMD_SHOW_ARGUMENTS") + else: + need(False,"UNTRUSTED_SYSTEMCTL_ACTION") + p=subprocess.run(("/usr/bin/systemctl",*args[1:]),text=True, + capture_output=True,timeout=timeout,check=False) + need(p.returncode==0,"COMMAND_"+str(p.returncode)+"_"+p.stderr[-170:].replace(" ","_")) + return p.stdout.strip() + +def preflight(): + need(os.geteuid()==0 and sys.platform=="linux","ROOT") + env=os.environ + need(env.get("GITHUB_ACTIONS")=="true" and env.get("CI")=="true" and + env.get("RUNNER_ENVIRONMENT")=="github-hosted" and + env.get("GITHUB_REPOSITORY")=="GOD13emad/DirectInternetMethod" and + env.get("GITHUB_WORKSPACE")==str(ROOT) and + ROOT==Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod"), + "HOSTED_RUNNER_ONLY") + need(Path("/proc/1/comm").read_text().strip()=="systemd","SYSTEMD_PID_ONE") + need(hashlib.sha256(GUARD.read_bytes()).hexdigest().upper()==PIN_GUARD,"PINNED_GUARD") + spec=importlib.util.spec_from_file_location("r566_exact_production_guard",GUARD) + need(spec is not None and spec.loader is not None,"IMPORT") + x=importlib.util.module_from_spec(spec);spec.loader.exec_module(x) + return x + +def main(): + guard=preflight() + identity=os.environ["GITHUB_RUN_ID"] + need(identity.isdecimal() and len(identity)<30,"RUN_ID") + # Fixed, fail-closed unit ID on a fresh disposable VM; the GitHub run + # identifier is checked for provenance but never enters a privileged + # filesystem pathname or systemd unit name. + name="dim-r566-catchup-784" + service=Path("/etc/systemd/system")/(name+".service") + timer=Path("/etc/systemd/system")/(name+".timer") + for target in (service,timer): + need(not target.exists() and not target.is_symlink(),"FOREIGN_SYSTEMD_UNIT") + with tempfile.TemporaryDirectory(prefix="dim-r566-784-",dir="/run") as directory: + root=Path(directory) + os.chmod(root,0o700) + marker=root/"fired.json" + worker=root/"worker.py" + worker.write_text( + "from pathlib import Path\n" + "import json,os,time\n" + "out=Path("+repr(str(marker))+")\n" + "fd=os.open(out,os.O_CREAT|os.O_EXCL|os.O_WRONLY|os.O_NOFOLLOW,0o600)\n" + "with os.fdopen(fd,'w') as f:\n" + " f.write(json.dumps({'pid':os.getpid(),'utc':time.time()})+'\\n')\n" + " f.flush();os.fsync(f.fileno())\n", + encoding="utf-8") + os.chmod(worker,0o500) + deadline=int(time.time())+12 + original_service,original_timer=guard.unit_templates(deadline) + replacement=original_service.replace( + ("ExecStart=/usr/bin/python3 -B "+str(guard.WORKER)+" rollback").encode(), + ("ExecStart=/usr/bin/python3 -B "+str(worker)).encode()) + replacement_timer=original_timer.replace( + ("Unit="+guard.UNIT+".service").encode(),("Unit="+name+".service").encode()) + need(replacement!=original_service and replacement_timer!=original_timer, + "EXPECTED_TEST_ONLY_IDENTITY_REPLACEMENTS") + need(b"OnActiveSec=480s\n" in replacement_timer and + b"Persistent=true\n" in replacement_timer and + b"OnCalendar=" in replacement_timer,"PRODUCTION_TIMER_SEMANTICS") + service.write_bytes(replacement);timer.write_bytes(replacement_timer) + enabled=False + try: + run("/usr/bin/systemctl","daemon-reload") + run("/usr/bin/systemctl","start",name+".timer") + need(run("/usr/bin/systemctl","show",name+".timer", + "-p","ActiveState","--value")=="active","TIMER_STARTED") + need(not marker.exists(),"EARLY_TRIGGER") + time.sleep(1) + run("/usr/bin/systemctl","stop",name+".timer") + need(not marker.exists(),"TRIGGER_WHILE_INITIAL_TIMER_ACTIVE") + need(run("/usr/bin/systemctl","show",name+".timer", + "-p","ActiveState","--value")=="inactive","TIMER_STOPPED") + wait=deadline+2-time.time() + if wait>0:time.sleep(wait) + need(not marker.exists(),"TRIGGER_WHILE_TIMER_STOPPED") + started=time.monotonic() + run("/usr/bin/systemctl","start",name+".timer") + while time.monotonic()-started<8 and not marker.exists(): + time.sleep(0.15) + need(marker.is_file() and not marker.is_symlink(), + "PERSISTENT_MISSED_CALENDAR_CATCHUP_NOT_FIRED") + payload=json.loads(marker.read_text()) + need(type(payload.get("pid")) is int and type(payload.get("utc")) in (int,float), + "BAD_SERVICE_RECEIPT") + need(time.monotonic()-started<8,"MISSED_CATCHUP_DEADLINE") + print(json.dumps({ + "revision":"R566","status":"PASS_EPHEMERAL_SYSTEMD_PERSISTENT_MISSED_CALENDAR_CATCHUP", + "identity":"GITHUB_HOSTED_ONLY","productionGuardSha256":PIN_GUARD, + "productionUnitDirectiveIdentity":True, + "triggeredWhileInactive":False, + "triggeredAfterReactivation":True, + "elapsedSeconds":round(time.monotonic()-started,3), + "deadlineUnix":deadline, + "actualFullRebootTested":False, + "realUserMachineChanged":False, + "networkOrTailscaleChanged":False + },sort_keys=True)) + finally: + subprocess.run(["/usr/bin/systemctl","stop",name+".timer"], + capture_output=True,text=True,timeout=10,check=False) + subprocess.run(["/usr/bin/systemctl","stop",name+".service"], + capture_output=True,text=True,timeout=10,check=False) + for target,want in ((service,replacement),(timer,replacement_timer)): + if target.is_file() and not target.is_symlink() and target.read_bytes()==want: + target.unlink() + subprocess.run(["/usr/bin/systemctl","daemon-reload"], + capture_output=True,text=True,timeout=10,check=False) + +if __name__=="__main__": + try:main() + except BaseException as exc: + print(json.dumps({"revision":"R566","status":"FAIL_CLOSED", + "reason":str(exc)[:400]}),file=sys.stderr) + raise SystemExit(96) diff --git a/tests/r567_hosted_denied_polkit_rollback.py b/tests/r567_hosted_denied_polkit_rollback.py new file mode 100644 index 00000000..9f829762 --- /dev/null +++ b/tests/r567_hosted_denied_polkit_rollback.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""R567: exact production installer auth-denial branch with native User stage/rollback. + +Hosted ephemeral GitHub Ubuntu ONLY. Exercise original source snippets of +rollback_failed_install, backend_matches_current and the single Polkit gate in +a real unprivileged runner process. Native v1.5.4 user and protected v1.5.2 +baselines are bytes from the official published release. Polkit is a *shell +function stub that returns 126*; no admin prompt or privileged upgrade is +performed. A refused request MUST restore the staged User transaction exactly, +without a second prompt, protected source mutation, DNS, or user data changes. +""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import re +import subprocess +import sys +import textwrap +BASE=Path(__file__).resolve().parents[1] +R528=BASE/"tests/r528_ephemeral_native_root_cli_contract.py" +R529=BASE/"tests/r529_ephemeral_root_user_transaction.py" +ARCHIVE="F5506E1AE0092A20071B8D63E770ADBF6CDE3AA0701CC6A1B4F1E925C74CAFB6" + +def require(v,code): + if not v: raise RuntimeError("R567_DENIED_AUTH_FAIL_CLOSED_"+code) + +def sha(path): + h=hashlib.sha256() + with Path(path).open("rb") as f: + for b in iter(lambda:f.read(1048576),b""):h.update(b) + return h.hexdigest().upper() + +def imported(path,label): + spec=importlib.util.spec_from_file_location(label,path) + require(spec is not None and spec.loader is not None,"IMPORT_"+label) + out=importlib.util.module_from_spec(spec) + spec.loader.exec_module(out) + return out + +def excerpt(data,start,following): + require(data.count(start)==1,"EXPECTED_SINGLE_"+str(len(start))) + tail=data.split(start,1)[1] + require(tail.count(following)==1,"END_ANCHOR_"+str(len(following))) + return start+tail.split(following,1)[0] + +def hosted(): + require(os.geteuid()==0 and sys.platform=="linux","ROOT_HOSTED") + e=os.environ + require(e.get("CI")=="true" and e.get("GITHUB_ACTIONS")=="true" and + e.get("RUNNER_ENVIRONMENT")=="github-hosted" and + e.get("GITHUB_REPOSITORY")=="GOD13emad/DirectInternetMethod" and + e.get("GITHUB_WORKSPACE")==str(BASE) and + BASE==Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod"), + "FRESH_GITHUB_VM_ONLY") + runner=pwd.getpwnam("runner") + require(runner.pw_dir=="/home/runner" and runner.pw_uid>0,"RUNNER") + for p in ("/var/lib/directinternetmethod","/usr/lib/directinternetmethod", + "/home/runner/.local/share/DirectInternetMethod", + "/home/runner/.local/state/DirectInternetMethod"): + require(not Path(p).exists(),"INITIAL_FOREIGN_INSTALL_"+Path(p).name) + return runner + +def main(): + runner=hosted() + r528=imported(R528,"r567_r528") + r529=imported(R529,"r567_r529") + rootmod=r528.module() + stable,pkg,temp,user=r528.prerequisites() + require(user.pw_uid==runner.pw_uid and sha(pkg)==ARCHIVE,"PINNED_ARCHIVE") + r529.require_clean_home(user) + extracted=r528.stage_exact_candidate(pkg,temp) + r529.make_source_runner_owned(extracted,user) + app,original,priv=r529.prepare_user_from_official(stable,user) + protected=r528.seed_real_protected_root(stable,user,rootmod) + dns={name:sha(name) for name in ("/etc/resolv.conf","/etc/hosts")} + symlink=os.readlink("/etc/resolv.conf") if Path("/etc/resolv.conf").is_symlink() else None + user_tx=extracted/"app/user_update_transaction.py" + installer=extracted/"install.sh" + source=installer.read_text(encoding="utf8") + require(source.startswith("#!/usr/bin/env bash\nset -euo pipefail\n"), + "INSTALLER_IDENTITY") + rollback=excerpt(source,"rollback_failed_install() {","\n\nfor c in ") + backend=excerpt(source,"backend_matches_current() {", + "\n\n# R781: A privileged entry must be") + auth=excerpt(source,"# Administrative authorization is required only", + "\nbackend_matches_current || ") + require(auth.count('pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP"')==2 + and 'ROOT_UPGRADE_STAGED=1' in auth + and 'pkexec /bin/bash' not in auth,"POLKIT_BRANCH_PROVENANCE") + require("pkexec /usr/bin/python3" in rollback and + 'if [ "$ROOT_UPGRADE_STAGED" -eq 1 ]' in rollback, + "ROLLBACK_PROVENANCE") + require(rootmod.RootTransaction().status()["status"]=="NO_PENDING_UPGRADE", + "PROTECTED_PRESTATE") + r529.as_runner(user,user_tx,"--stage","--source",extracted) + pending=Path(user.pw_dir)/".local/state/DirectInternetMethod/update-pending" + require((pending/"manifest.json").is_file(),"USER_STAGE_MISSING") + r529.unchanged_records(original) + r529.unchanged_records(priv) + # Only Bash *functions* from original installed package are executed; + # the counterfeit "pkexec" function cannot invoke privileged actions. + script=( + "set -euo pipefail\n" + "HERE="+subprocess.list2cmdline([str(extracted)])+"\n" + "USER_TRANSACTION="+subprocess.list2cmdline([str(user_tx)])+"\n" + "ROOT_TRANSACTION="+subprocess.list2cmdline(["/var/lib/directinternetmethod/upgrade-guard/root_tx.py"])+"\n" + "ROOT_UPGRADE_STAGED=0\nTRANSACTION_ACTIVE=1\n" + # Scope R567 is auth DENIED *after* an independently installed + # bootstrap was validated. R781 tests separate missing-bootstrap case. + + "trusted_bootstrap_ready() { return 0; }\n" + + "TRUSTED_BOOTSTRAP=/usr/local/libexec/directinternetmethod/privileged_entry.py\n" + "pkexec() { printf 'R567_POLKIT_DENIED_BY_OWNER\\n' >&2; return 126; }\n" + +rollback+"\n\n"+backend+"\n\n" + "trap 'rollback_failed_install \"$?\"' ERR\n" + "trap 'rollback_failed_install 130' INT\n" + "trap 'rollback_failed_install 143' TERM\n" + "trap 'rollback_failed_install 129' HUP\n" + +auth+"\n" + "printf 'R567_DANGER_UNREACHABLE_AFTER_AUTH_DENIAL\\n'\n" + ) + process=subprocess.run( + ["/usr/sbin/runuser","-u",user.pw_name,"--","/usr/bin/env", + "HOME="+user.pw_dir,"PYTHONDONTWRITEBYTECODE=1", + "/bin/bash","-c",script],capture_output=True,text=True,timeout=30,check=False) + require(process.returncode==126,"WRONG_REFUSAL_EXIT_"+str(process.returncode)) + require(process.stderr.count("R567_POLKIT_DENIED_BY_OWNER")==1, + "MULTIPLE_PRIVILEGED_REQUESTS_ON_DENIAL") + require("R567_DANGER_UNREACHABLE_AFTER_AUTH_DENIAL" not in process.stdout, + "INSTALLER_IGNORED_AUTH_REFUSAL") + require(not pending.exists() and + rootmod.RootTransaction().status()["status"]=="NO_PENDING_UPGRADE", + "PENDING_LEFT_AFTER_REFUSED_AUTH") + r529.unchanged_records(original) + r529.unchanged_records(priv) + for name,expected in protected.items(): + require(sha(name).lower()==expected,"ROOT_MUTATION_"+Path(name).name) + for name,expected in dns.items(): + require(sha(name)==expected,"NETWORK_MUTATION_"+Path(name).name) + require((os.readlink("/etc/resolv.conf") if + Path("/etc/resolv.conf").is_symlink() else None)==symlink, + "RESOLVER_SYMLINK_MUTATION") + require(not Path("/var/lib/directinternetmethod/upgrade-guard").exists(), + "AUTH_REFUSAL_CREATED_ROOT_GUARD") + require(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.4", + "USER_VERSION_NOT_ROLLED_BACK") + require(json.loads(Path("/var/lib/directinternetmethod/install.json").read_text())["version"]=="1.5.2", + "ROOT_VERSION_UNEXPECTED") + print(json.dumps({"revision":"R567", + "status":"PASS_HOSTED_SINGLE_POLKIT_DENIAL_USER_ROLLBACK", + "productionInstallerOriginalAuthorizationBranch":True, + "nativeUnprivilegedTransactionStaged":True, + "officialStableRootAndUserBytesRestored":True, + "singleDeniedAuthorizationAttempt":True, + "correctDeniedExitCode":126, + "rootGuardAndRootPendingAbsent":True, + "privateSettingsPreserved":True, + "dnsAndHostsUnchanged":True, + "physicalOwnerHostUntouched":True, + "syntheticPolkitDenialOnly":True, + "actualAdminDialogOpened":False},sort_keys=True)) + +if __name__=="__main__": + try:main() + except BaseException as exc: + print(json.dumps({"revision":"R567","status":"FAIL_CLOSED", + "reason":str(exc)[:500]}),file=sys.stderr) + raise SystemExit(96) diff --git a/tests/r664_direct_update_monotonicity_contract.py b/tests/r664_direct_update_monotonicity_contract.py new file mode 100644 index 00000000..46281f04 --- /dev/null +++ b/tests/r664_direct_update_monotonicity_contract.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Private Windows RC1 -> future public Stable update release-path contract. + +Source-only, no OS/registry/network mutation. The current Windows private RC1 +pilot is installed as 1.5.5-rc1 with AssemblyVersion 1.5.5.0 on two hosts. +The original GUI/Service both compare GitHub vX.Y.Z numerically using .NET +System.Version; a published v1.5.5 therefore would NOT trigger a direct update. +""" +from __future__ import annotations +import ast +import json +import pathlib +import re + +ROOT = pathlib.Path(__file__).resolve().parents[1] +audit = ROOT / "tests" / "release_metadata_audit.py" +tree = ast.parse(audit.read_text(encoding="utf-8-sig")) +nodes = [x for x in tree.body if isinstance(x, ast.FunctionDef) + and x.name == "require_public_stable_forward_update_from_rc155"] +assert len(nodes) == 1, "MONOTONIC_RELEASE_GUARD_NOT_UNIQUE" +isolated = ast.Module(body=nodes, type_ignores=[]) +scope = {"re": re} +exec(compile(isolated, str(audit), "exec"), scope) +guard = scope["require_public_stable_forward_update_from_rc155"] + +for version in ("1.5.1", "1.5.3", "1.5.4", "1.5.6", "1.5.7", "2.0.0"): + guard(version) +for version in ("1.5.5", "1.5.5-rc1", "v1.5.5", "1.5.5.0", "1.5.5junk"): + try: + guard(version) + except SystemExit as exc: + assert str(exc), version + else: + raise AssertionError("UNSAFE_VERSION_WAS_NOT_REJECTED:" + version) + +win_release = json.loads((ROOT / "windows/RELEASE.json").read_text("utf-8-sig")) +if win_release.get("version") in ("1.5.5-rc1","1.5.6","1.5.7"): + expected_assembly = ("1.5.5.0" if win_release["version"]=="1.5.5-rc1" + else "1.5.7.0" if win_release["version"]=="1.5.7" else "1.5.6.0") + for rel in ("windows/gui/DirectInternetMethod.csproj", + "windows/service/DirectInternetMethod.Service.csproj"): + source = (ROOT / rel).read_text("utf-8-sig") + assert ""+expected_assembly+"" in source, rel + assert ""+expected_assembly+"" in source, rel + gui = (ROOT / "windows/gui/UpdateClient.cs").read_text("utf-8-sig") + service = (ROOT / "windows/service/Program.cs").read_text("utf-8-sig") + assert "latest <= current" in gui, "GUI_UPDATER_COMPARISON_CHANGED_REVALIDATE_GUARD" + assert "latest <= CurrentVersion" in service, "SERVICE_UPDATER_COMPARISON_CHANGED_REVALIDATE_GUARD" + assert 'Version.TryParse(clean, out var latest)' in gui + assert 'Version.TryParse(clean, out var latest)' in service +print("PASS_R664_PUBLIC_MONOTONIC_PROMOTION_GUARD_NEGATIVE_AND_POSITIVE_FIXTURES") \ No newline at end of file diff --git a/tests/r678_linux_gui_full_youtube_health_contract.py b/tests/r678_linux_gui_full_youtube_health_contract.py new file mode 100644 index 00000000..80574393 --- /dev/null +++ b/tests/r678_linux_gui_full_youtube_health_contract.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Native original Linux GUI Start acceptance negative-first regression. + +Only AST-extracted verify_live() and Window.worker() execute with mocked +filesystem and subprocess responses. No GTK, network, route, sudo, DNS, service +or installed-user app state is modified. DPI-only mode requires both +lightweight 204 and the real YouTube homepage; selected DNS retains the R538 page-only gate. +""" +from __future__ import annotations +import ast +import json +import pathlib +from types import SimpleNamespace +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = (ROOT/"linux/app/direct_internet_method.py").read_text("utf8") +TREE = ast.parse(SRC) +FUNCS = [n for n in TREE.body + if isinstance(n, ast.FunctionDef) and n.name=="verify_live"] +assert len(FUNCS)==1 +GUI = next(n for n in TREE.body if isinstance(n,ast.ClassDef) and n.name=="Window") +WORKER = next(n for n in GUI.body if isinstance(n,ast.FunctionDef) and n.name=="worker") + + +class FakeState: + def __init__(self,doc): self.doc=doc + def read_text(self,encoding="utf-8"): return json.dumps(self.doc) + + +def fixture(*, page="200", lightweight="204", selective=False, route="enp1s0", + google="200", external=False): + requested=[] + doc={"physicalInterface":"enp1s0","dnsMode":"system-preserved", + "selectiveDnsEncrypted":bool(selective),"selectiveDns":False} + answers={ + "http://www.youtube.com/":(0,"200"), + "https://www.youtube.com/":(0,page), + "https://www.youtube.com/generate_204":(0,lightweight), + "https://api.openai.com/v1/models":(0,"401"), + "https://github.com/":(0,google), + "https://gemini.google.com/":(0,"403") + } + def runner(args,**kw): + assert args[0] in ("curl","getent"),args + if args[0]=="getent": + return SimpleNamespace(returncode=0,stdout="1.2.3.4 STREAM\n",stderr="") + assert "--noproxy" in args and "--interface" in args, args + url=args[-1] + requested.append(url) + assert url in answers,url + code,status=answers[url] + if status=="000":code=35 + return SimpleNamespace(returncode=code,stdout=status+"|142.251.157.4|0.2", + stderr="reset" if code else "") + e={ + "json":json, "STATE":FakeState(doc), + "_top_default_iface":lambda:route, + "_external_tunnel_active":lambda:external, + "subprocess":SimpleNamespace(run=runner), + } + exec(compile(ast.Module(body=FUNCS,type_ignores=[]), + "", "exec"),e) + return e["verify_live"],requested + + +class Contract(unittest.TestCase): + def test_lightweight_success_does_not_hide_real_page_failure(self): + f,urls=fixture(page="000",lightweight="204") + result=f() + self.assertIn("https://www.youtube.com/",urls) + self.assertIn("https://www.youtube.com/generate_204",urls) + self.assertFalse(result["ok"]) + self.assertEqual(result["youtube"]["meta"].split("|")[0],"000") + + def test_real_page_success_does_not_hide_lightweight_failure(self): + f,urls=fixture(page="200",lightweight="000") + result=f() + self.assertFalse(result["ok"]) + self.assertIn("https://www.youtube.com/generate_204",urls) + + def test_both_youtube_targets_and_google_openai_github_pass(self): + f,urls=fixture(page="200",lightweight="204") + self.assertTrue(f()["ok"]) + self.assertEqual(urls.count("https://www.youtube.com/"),1) + self.assertEqual(urls.count("https://www.youtube.com/generate_204"),1) + + def test_selected_dns_real_page_succeeds_without_204_requirement(self): + # R538 proven: selected DNS can return 404 at generate_204 even + # when the main HTTPS homepage works; preserve that accepted behavior. + f,urls=fixture(page="200",lightweight="404",selective=True) + self.assertTrue(f()["ok"]) + self.assertIn("https://www.youtube.com/",urls) + self.assertNotIn("https://www.youtube.com/generate_204",urls) + + def test_selected_dns_rejects_broken_real_page_without_204_probe(self): + f,urls=fixture(page="000",lightweight="204",selective=True) + self.assertFalse(f()["ok"]) + self.assertNotIn("https://www.youtube.com/generate_204",urls) + + def test_route_drift_sends_no_http_or_dns(self): + f,urls=fixture(route="eth9") + got=f() + self.assertEqual(got["error"],"PHYSICAL_INTERFACE_ROUTE_DRIFT") + self.assertEqual(urls,[]) + + def test_other_core_site_failure_is_not_accepted(self): + f,_=fixture(google="000") + self.assertFalse(f()["ok"]) + + def test_gui_worker_rolls_back_a_failed_live_acceptance(self): + calls=[] + queued=[] + class GLib: + @staticmethod + def idle_add(fn,*args):queued.append((fn,args)) + def action(command): + calls.append(command) + return {"ok":True,"unit":"directinternetmethod-"+command} + f,_=fixture(page="000",lightweight="204") + e={"run_system_action":action,"verify_live":f,"GLib":GLib} + exec(compile(ast.Module(body=[WORKER],type_ignores=[]), + "", "exec"),e) + instance=SimpleNamespace(finish=lambda r:None) + e["worker"](instance,"start") + self.assertEqual(calls,["start","stop"]) + self.assertFalse(queued[0][1][0]["ok"]) + self.assertEqual(queued[0][1][0]["error"],"LIVE_VERIFY_FAILED_ROLLED_BACK") + + +if __name__ == "__main__": + result=unittest.main(verbosity=0,exit=False) + if not result.result.wasSuccessful():raise SystemExit(20) + print("R678_GUI_FULL_YOUTUBE_HTTPS_AND_204_NEGATIVE_FIRST_PASS") \ No newline at end of file diff --git a/tests/r685_windows_gui_real_youtube_contract.py b/tests/r685_windows_gui_real_youtube_contract.py new file mode 100644 index 00000000..ec245e0d --- /dev/null +++ b/tests/r685_windows_gui_real_youtube_contract.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Negative-first native Windows WPF GUI core-site health acceptance. + +The service intentionally allows a two-of-three transport quorum to keep the +engine available with PARTIAL health. This test ensures the GUI cannot display +HEALTHY when generate_204 succeeds but the real YouTube page does not. + +No installed services, registry, DNS, routes, browser profiles or network are +modified. Compile just the actual production C# predicate in a local temp dir. +""" +from __future__ import annotations +import pathlib +import re +import subprocess +import tempfile + +ROOT=pathlib.Path(__file__).resolve().parents[1] +GUI=(ROOT/"windows/gui/MainWindow.xaml.cs").read_text("utf-8-sig") +START=(ROOT/"windows/app/Start-Direct.ps1").read_text("utf-8-sig") + +def need(cond,label): + if not cond:raise SystemExit("R685_FAIL_"+label) + +need('StatusBadge.Text = "PARTIAL";' in GUI,"PRESERVE_PARTIAL_STATUS") +need("function Get-CoreSiteHealth($ytPage,$yt204,$oa,$gh)" in START and + "$httpsProbePassCount -lt 2" in START,"PRESERVE_TWO_OF_THREE_START") +need('var ytTask = ProbeAsync("https://www.youtube.com/", IpText.Text, new[] { "200" });' in GUI, + "REAL_YOUTUBE_PAGE_PROBE_MISSING") +need('var yt204Task = ProbeAsync("https://www.youtube.com/generate_204", IpText.Text, new[] { "204" });' in GUI, + "LIGHTWEIGHT_YOUTUBE_PROBE_MISSING") +need('var tasks = new List { ytTask, yt204Task, oaTask, ghTask, geminiTask };' in GUI, + "SINGLE_REFRESH_ASYNCHRONOUS_TASK_GATHER") +need('var youtubeProbe = await ytTask;' in GUI and + 'var youtube204Probe = await yt204Task;' in GUI,"BOTH_PROBES_NOT_AWAITED") +need("coreReachabilityProven = FullCoreReachability(youtubeProbe, youtube204Probe, openAiProbe, githubProbe);" in GUI, + "COMPLETE_REACHABILITY_NOT_USED") +need('YoutubeText.Text = $"Page {FmtHttp(youtubeProbe)} | 204 {FmtHttp(youtube204Probe)}";' in GUI, + "SITE_SPECIFIC_GUI_EVIDENCE_MISSING") +need('mode == "ACTIVE" && (!ownedHealthy || !coreReachabilityProven)' in GUI, + "PARTIAL_STATUS_NOT_DRIVEN_BY_ALL_SITES") +pat=r"static bool FullCoreReachability\s*\(\s*ProbeResult youtubePage,\s*ProbeResult youtube204,\s*ProbeResult openai,\s*ProbeResult github\)\s*=>\s*[^;]+;" +matches=re.findall(pat,GUI,re.S) +need(len(matches)==1,"PRODUCTION_CSHARP_PREDICATE_NOT_UNIQUE") +method=matches[0] +need("youtubePage.Ok" in method and "youtube204.Ok" in method and + "openai.Ok" in method and "github.Ok" in method,"FULL_CORE_MATRIX_SOURCES") + +with tempfile.TemporaryDirectory(prefix="dim_r685_native_core_site_") as temp: + work=pathlib.Path(temp) + (work/"check.csproj").write_text( + '' + 'Exenet10.0' + 'enableenable' + '',encoding="utf8") + source='''using System; +internal sealed record ProbeResult(bool Ok); +internal static class Product { +%s + internal static void Main(){ + for (int mask=0;mask<16;mask++){ + bool actual=FullCoreReachability( + new ProbeResult((mask&1)!=0), + new ProbeResult((mask&2)!=0), + new ProbeResult((mask&4)!=0), + new ProbeResult((mask&8)!=0)); + if(actual!=(mask==15))throw new Exception($"FALSE_CORE_SUCCESS_MASK_{mask}"); + } + Console.WriteLine("R685_REAL_PRODUCTION_GUI_PREDICATE_16_OF_16_NATIVE_DOTNET10_PASS"); + } +} +''' % method + (work/"Program.cs").write_text(source,encoding="utf8") + p=subprocess.run(["dotnet","run","--project",str(work/"check.csproj"), + "-c","Release","--no-launch-profile","--verbosity","quiet"], + cwd=work,capture_output=True,text=True,timeout=120) + need(p.returncode==0,"NATIVE_DOTNET_BUILD_OR_CORE_MATRIX_"+p.stderr[-400:]) + need("16_OF_16_NATIVE_DOTNET10_PASS" in p.stdout,"MATRIX_NOT_EXECUTED") + print(p.stdout.strip()) +print("R685_WPF_CORE_YOUTUBE_PAGE_AND_204_ACCURACY=PASS") \ No newline at end of file diff --git a/tests/r698_root_watchdog_firstsplit_integrity_contract.py b/tests/r698_root_watchdog_firstsplit_integrity_contract.py new file mode 100644 index 00000000..1a4716de --- /dev/null +++ b/tests/r698_root_watchdog_firstsplit_integrity_contract.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""R698 first-segment profile and independent Root/Watchdog integrity chain. + +Runs source inspection and an optional native unprivileged nfqws --dry-run. +No OS installation, admin/root privileges, packet filters, DNS or VPN mutation. +""" +from __future__ import annotations +import hashlib +import pathlib +import re +import subprocess +import sys +import tempfile +import shutil + +ROOT=pathlib.Path(__file__).resolve().parents[1] +def src(relative: str)->str: + return (ROOT/relative).read_text(encoding="utf-8-sig") +def sha(relative: str)->str: + return hashlib.sha256((ROOT/relative).read_bytes()).hexdigest().upper() +def need(ok,reason): + if not ok: raise AssertionError("R698_"+reason) + +helper=src("linux/app/direct_method_helper.sh") +split_head=helper.split(' NFQWS_ARGS=(--qnum="$QNUM")',1) +need(len(split_head)==2,"MISSING_NFQWS_PROFILE_START") +strategies=split_head[1].split(' compatibility)',1) +need(len(strategies)==2,"MISSING_COMPAT") +compat=strategies[1].split(' strong)',1) +need(len(compat)==2,"MISSING_STRONG") +profile=compat[0] +first=re.search( + r'NFQWS_ARGS\+=\(\s*--new\s+--filter-tcp=443\s+' + r'--hostlist-domains=youtube[.]com,youtu[.]be,googlevideo[.]com\s+' + r'--dpi-desync=multisplit\s+--dpi-desync-split-pos=1\s*\)', + profile,re.S) +need(first is not None,"YOUTUBE_FIRST_BYTE_PROFILE_MISSING") +generic='--new --filter-tcp=443 "${HOST_ARGS[@]}" --dpi-desync=multisplit --dpi-desync-split-pos=1,sniext+1,host+1,midsld,endhost-1' +need(generic in profile,"EXISTING_GENERAL_HTTPS_PROFILE_MISSING") +need(profile.index('--filter-tcp=80')prefix[0].index("umask 077") +assert 'pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP" --action upgrade' in installer,"PROTECTED_ROOT_AUTH_REMOVED" +assert 'pkexec /bin/bash "$HERE/system/' not in installer,"ROOT_ENTRYPOINT_MUST_NOT_BE_USER_WRITABLE" +assert 'R519_CANDIDATE_PENDING_NATIVE_ROOT_USER_COMMIT' in installer,"UNCOMMITTED_SAFETY_SEMANTICS_CHANGED" +assert 'python3 "$USER_TRANSACTION" --rollback' in installer,"USER_ROLLBACK_REMOVED" +if sys.platform.startswith("linux"): + with tempfile.TemporaryDirectory(prefix="dim-r709-no-bytecode-") as td: + path=Path(td) + module=path/"dim_r709_probe.py" + module.write_text("VALUE='ok'\n",encoding="utf-8") + pycode='import dim_r709_probe; assert dim_r709_probe.VALUE=="ok"' + script="set -euo pipefail\n"+prefix[0]+"\nPYTHONPATH="+shlex.quote(td)+" python3 -c "+shlex.quote(pycode)+"\n" + env=os.environ.copy() + env.pop("PYTHONDONTWRITEBYTECODE",None) + p=subprocess.run(["bash","-c",script],text=True,capture_output=True, + check=False,timeout=10,env=env) + assert p.returncode==0,(p.stdout,p.stderr) + assert not (path/"__pycache__").exists(),"CANDIDATE_EXTRACTED_PATH_WOULD_ACCUMULATE_PYC" + print("R709_NATIVE_INSTALLER_EXPORTED_ENV_IMPORT_NO_PYC_CACHE=PASS") +print("R709_INSTALLER_SOURCE_GUARDED_NO_BYTECODE_CACHE=PASS") diff --git a/tests/r711_tls_syn_fastpath_contract.py b/tests/r711_tls_syn_fastpath_contract.py new file mode 100644 index 00000000..8d76a7f6 --- /dev/null +++ b/tests/r711_tls_syn_fastpath_contract.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""R711 -> R742: original SYN must reach bounded NFQUEUE to seed nfqws conntrack. + +The old R711 optimization bypassed SYN, which upstream nfqws documents as +required for tracking TCP direction/sequence. Regression is source-only; +independent physical queue validation remains a separate release gate. +""" +from pathlib import Path +import re + +ROOT=Path(__file__).resolve().parents[1] +h=(ROOT/"linux/app/direct_method_helper.sh").read_text(encoding="utf8") +def verify(value,name): + if not value:raise AssertionError("R742_"+name) +def fragment(name): + results=re.findall(r'^'+re.escape(name)+r'\(\)\{\n.*?^\}\n', + h,flags=re.S|re.M) + verify(len(results)==1,"MISSING_UNIQUE_"+name) + return results[0] +syn='tcp flags & (syn | ack) == syn return' +verify(syn not in h,"ORIGINAL_TCP443_SYN_BYPASS_MUST_NOT_EXIST") +queue='nft add rule inet "$TABLE" output oifname "$PHY" tcp dport 443 ct original packets 1-6 queue num "$QNUM" bypass' +mark='nft "add rule inet $TABLE output meta mark & 0x40000000 == 0x40000000 return"' +tailscale='nft "add rule inet $TABLE output meta mark & 0xff0000 == 0x80000 return"' +verify(h.count(queue)==1 and h.count(mark)==1 and h.count(tailscale)==1, + "ONE_INJECTED_MARK_ONE_TAILSCALE_MARK_ONE_QUEUE") +verify(h.index(mark)len(patterns)' in trans and + 'not all(re.fullmatch(pattern,actual)' in trans, + "TRANSIENT_FOREIGN_RULE_FAIL_CLOSED") +verify('ROLLBACK_FOREIGN_NFT_PRESERVED' in h and + 'NFT_TABLE_OWNERSHIP_MISMATCH' in h,"SAFETY_REFUSES_FOREIGN_NFT") +for protocol in ('tcp dport 80','tcp dport 443','udp dport 443'): + verify(protocol+' ct original packets 1-6 queue num "$QNUM" bypass' in h, + "BOUND_AND_FAILOPEN_"+protocol) +print("R742_SYNS_SENT_IN_BOUNDED_NFQUEUE_STRICT_MARKED_TABLE_SOURCE_PASS") diff --git a/tests/r720_windows_start_youtube_truth_contract.py b/tests/r720_windows_start_youtube_truth_contract.py new file mode 100644 index 00000000..df316261 --- /dev/null +++ b/tests/r720_windows_start_youtube_truth_contract.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""R720: exact production Windows Start health must require YouTube's real page. + +Only extract and run the production Get-CoreSiteHealth AST function in pwsh. +Do not source the lifecycle script, send SCM actions, or change networking. +""" +from __future__ import annotations +import pathlib +import shutil +import subprocess +import sys +import tempfile + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SCRIPT=ROOT/"windows/app/Start-Direct.ps1" +text=SCRIPT.read_text(encoding="utf-8-sig") +required=( + "function Get-CoreSiteHealth($ytPage,$yt204,$oa,$gh)", + "if(-not ([bool]$ytPage.ok -and [bool]$yt204.ok)){[void]$failed.Add('youtube')}", + "$ytPage=Curl-Probe-Retry 'https://www.youtube.com/' $route.LocalIp '^200\\|' 8 2", + "$yt=Curl-Probe-Retry 'https://www.youtube.com/generate_204' $route.LocalIp '^204\\|' 8 2", + "$httpsHealth=Get-CoreSiteHealth $ytPage $yt $oa $gh", + "youtubePage=$ytPage;youtube204=$yt", + "required=2", + "passed=3-$failed.Count", + "reachability=if($failed.Count -eq 0){'HEALTHY'}else{'PARTIAL'}", +) +for key in required: + assert text.count(key)==1,("R720_MISSING_OR_DUPLICATE_PRODUCTION_GUARD",key) +assert "function Get-CoreSiteHealth($yt,$oa,$gh)" not in text +assert "$httpsHealth=Get-CoreSiteHealth $yt $oa $gh" not in text +assert "$httpsProbePassCount -lt 2" in text,"OLD_2_OF_3_ENGINE_PARTIAL_SEMANTICS_LOST" +assert "if(-not $dy.ok){throw 'YOUTUBE_DNS_NOT_CLEAN'}" in text +assert "Curl-Probe-Retry" in text and "--noproxy '*'" in text + +native=shutil.which("pwsh") +if sys.platform=="win32": + assert native is not None,"R720_NATIVE_WINDOWS_POWERSHELL_REQUIRED" +if native is not None: + code=r''' +$ErrorActionPreference='Stop' +$astTokens=$null +$astErrors=$null +$source=[System.Management.Automation.Language.Parser]::ParseFile( + $args[0],[ref]$astTokens,[ref]$astErrors) +if($astErrors.Count){throw 'R720_SOURCE_START_SCRIPT_PARSE_ERROR'} +$defs=@($source.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Get-CoreSiteHealth' +},$true)) +if($defs.Count -ne 1){throw 'R720_SOURCE_HEALTH_AST_NOT_UNIQUE'} +. ([scriptblock]::Create($defs[0].Extent.Text)) +for($mask=0;$mask -lt 16;$mask++){ + $page=[pscustomobject]@{ok=($mask -band 1) -ne 0} + $ping=[pscustomobject]@{ok=($mask -band 2) -ne 0} + $openai=[pscustomobject]@{ok=($mask -band 4) -ne 0} + $github=[pscustomobject]@{ok=($mask -band 8) -ne 0} + $truth=Get-CoreSiteHealth $page $ping $openai $github + $ytOk=[bool]($page.ok -and $ping.ok) + $expectedPassed=[int]$ytOk+[int]$openai.ok+[int]$github.ok + $expectedHealthy=$expectedPassed -eq 3 + $list=@($truth.unreachableCoreSites) + if([int]$truth.required -ne 2 -or + [int]$truth.passed -ne $expectedPassed -or + [bool]$truth.allCoreSitesOk -ne $expectedHealthy -or + ($truth.reachability -eq 'HEALTHY') -ne $expectedHealthy -or + ($list -contains 'youtube') -ne (-not $ytOk) -or + ($list -contains 'openai') -ne (-not $openai.ok) -or + ($list -contains 'github') -ne (-not $github.ok)){ + throw "R720_CORE_SITE_TRUTH_TABLE_MISMATCH_$mask" + } +} +Write-Output 'R720_PRODUCTION_NATIVE_POWERSHELL_16_OF_16_TRUTH_PASS' +''' + with tempfile.TemporaryDirectory(prefix="dim-r720-native-") as d: + p=pathlib.Path(d)/"verify.ps1" + p.write_text(code,encoding="utf-8") + result=subprocess.run([native,"-NoProfile","-NonInteractive","-File", + str(p),str(SCRIPT)],capture_output=True,text=True,timeout=45) + assert result.returncode==0,("R720_NATIVE_POWERSHELL_FAILED",result.stdout,result.stderr) + assert "16_OF_16_TRUTH_PASS" in result.stdout,"R720_NATIVE_SCRIPT_NOT_PROVEN" + print(result.stdout.strip()) +else: + print("R720_STATIC_GUARDS_PASS_NONWINDOWS_NATIVE_MATRIX_DEFERRED") +print("R720_WINDOWS_ENGINE_YOUTUBE_PAGE_AND_204_FULL_HEALTH_CONTRACT_PASS") diff --git a/tests/r726_windows_privileged_curl_provenance_contract.py b/tests/r726_windows_privileged_curl_provenance_contract.py new file mode 100644 index 00000000..caaed7c7 --- /dev/null +++ b/tests/r726_windows_privileged_curl_provenance_contract.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""R726: privileged Windows Start must never resolve curl.exe from PATH. + +Read-only native exercise of the production Curl-Probe function (via PowerShell AST). +The chosen target is closed IPv4 loopback port 1, with no SCM, engine, DNS or +network configuration changes. A genuine Microsoft executable copied under the +name curl.exe is placed first on PATH to prove that the system-owned curl wins. +""" +from __future__ import annotations + +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = ROOT / "windows/app/Start-Direct.ps1" +source = SOURCE.read_text(encoding="utf-8-sig") +functions = re.findall( + r"^function Curl-Probe\([^\r\n]*\)\{\s*\n.*?^\}", + source, + flags=re.MULTILINE | re.DOTALL, +) +assert len(functions) == 1, "R726_PRIVILEGED_CURL_FUNCTION_NOT_UNIQUE" +fn = functions[0] +assert fn.count("Join-Path ([Environment]::SystemDirectory) 'curl.exe'") == 1, \ + "R726_SYSTEM32_CURL_NOT_PINNED" +assert "if(-not [IO.File]::Exists($SystemCurl)){throw 'WINDOWS_SYSTEM_CURL_MISSING'}" in fn, \ + "R726_MISSING_FAIL_CLOSED_GUARD" +assert "$m=& $SystemCurl -4 --interface $LocalIp --noproxy '*'" in fn, \ + "R726_CURL_CALL_NOT_ABSOLUTE" +assert "& curl.exe" not in fn and "Invoke-Expression" not in fn, \ + "R726_PATH_OR_SHELL_RESOLUTION_FORBIDDEN" +assert "%{http_code}|%{remote_ip}|%{time_connect}|%{time_appconnect}|%{time_total}" in fn, \ + "R726_PROBE_OUTPUT_CONTRACT_CHANGED" +assert "$LASTEXITCODE" in fn and "--max-time $Timeout" in fn, \ + "R726_NATIVE_EXIT_AND_TIMEOUT_PRESERVATION" + +pwsh = shutil.which("pwsh") +if sys.platform == "win32": + assert pwsh, "R726_NATIVE_WINDOWS_PWSH_REQUIRED" + +if pwsh: + verify = r""" +$ErrorActionPreference='Stop' +$PSNativeCommandUseErrorActionPreference=$false +$native=$args[0] +$fixture=$args[1] +$windowsCurl=Join-Path ([Environment]::SystemDirectory) 'curl.exe' +$decoySource=Join-Path ([Environment]::SystemDirectory) 'whoami.exe' +if(-not (Test-Path -LiteralPath $windowsCurl -PathType Leaf)){throw 'R726_WINDOWS_CURL_NOT_PRESENT'} +if(-not (Test-Path -LiteralPath $decoySource -PathType Leaf)){throw 'R726_WINDOWS_DECOY_FIXTURE_MISSING'} +Copy-Item -LiteralPath $decoySource -Destination (Join-Path $fixture 'curl.exe') -ErrorAction Stop +$tokens=$null +$errors=$null +$ast=[System.Management.Automation.Language.Parser]::ParseFile($native,[ref]$tokens,[ref]$errors) +if($errors.Count){throw 'R726_PRODUCTION_SOURCE_PARSE_FAILED'} +$defs=@($ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Curl-Probe' +},$true)) +if($defs.Count -ne 1){throw 'R726_CURL_AST_NOT_UNIQUE'} +. ([scriptblock]::Create($defs[0].Extent.Text)) +$oldPath=$env:PATH +try { + $env:PATH=$fixture+';'+$oldPath + $resolution=(Get-Command -Name curl.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + if(-not [string]::Equals($resolution,(Join-Path $fixture 'curl.exe'),[StringComparison]::OrdinalIgnoreCase)){ + throw ('R726_PATH_DECOY_NOT_FIRST_'+$resolution) + } + # The production function must choose the OS-owned executable, not the decoy. + # Port 1 is local, closed, and never uses a real external network. + $probe=Curl-Probe 'https://127.0.0.1:1/' '127.0.0.1' 1 + if([int]$probe.exit -notin @(7,28)){ + throw ('R726_NATIVE_EXIT_NOT_SYSTEM_CURL_'+$probe.exit+'_'+$probe.meta) + } + if([string]$probe.meta -notmatch 'curl:.*\((7|28)\)'){ + throw ('R726_NATIVE_CURL_OUTPUT_MISSING_'+$probe.meta) + } + Write-Output 'R726_NATIVE_SYSTEM_CURL_PATH_DECOY_PASS' +} +finally { + $env:PATH=$oldPath +} +""" + with tempfile.TemporaryDirectory(prefix="dim-r726-curl-") as td: + script = pathlib.Path(td) / "verify.ps1" + script.write_text(verify, encoding="utf-8") + result = subprocess.run( + [pwsh, "-NoProfile", "-NonInteractive", "-File", str(script), str(SOURCE), td], + capture_output=True, + text=True, + timeout=35, + ) + assert result.returncode == 0, ( + "R726_NATIVE_PATH_DECOY_FAILED", + result.stdout, + result.stderr, + ) + assert "R726_NATIVE_SYSTEM_CURL_PATH_DECOY_PASS" in result.stdout, \ + "R726_NATIVE_ACCEPTANCE_RECEIPT_MISSING" + print(result.stdout.strip()) +else: + print("R726_STATIC_ONLY_NONWINDOWS_NATIVE_HOST_TEST_DEFERRED") + +print("R726_PRIVILEGED_START_SYSTEM_CURL_PIN_CONTRACT_PASS") diff --git a/tests/r735_linux_live_core_truth_contract.py b/tests/r735_linux_live_core_truth_contract.py new file mode 100644 index 00000000..98926978 --- /dev/null +++ b/tests/r735_linux_live_core_truth_contract.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""R735: the native Linux GUI must not turn green from engine ownership alone. + +Production AST-extracted Window methods are executed with synthetic health and +route state, never with real GTK, network, DNS, services or user settings. +""" +import ast +import concurrent.futures +import json +import pathlib +import threading +import types +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = (ROOT / "linux/app/direct_internet_method.py").read_text("utf-8") +TREE = ast.parse(SOURCE) +WINDOW = next(n for n in TREE.body if isinstance(n, ast.ClassDef) and n.name == "Window") +METHODS = {n.name: n for n in WINDOW.body if isinstance(n, ast.FunctionDef)} +NAMES = {"refresh", "periodic_state_tick", "finish_core_checks", "live_checks_worker"} +assert NAMES <= METHODS.keys(), "R735_PRODUCTION_GUI_CORE_SITE_HEALTH_METHODS_MISSING" +assert "R735_GUI_NO_UNVERIFIED_GREEN" in SOURCE, "R735_PRODUCTION_GREEN_GUARD_MISSING" + +class Label: + def __init__(self, text=""): + self.value = text + self.classes = set() + def set_text(self, text): + self.value = str(text) + def get_text(self): + return self.value + def remove_css_class(self, x): + self.classes.discard(x) + def add_css_class(self, x): + self.classes.add(x) + +class Button: + def __init__(self): + self.enabled = False + def set_sensitive(self, v): + self.enabled = bool(v) + def remove_css_class(self, *_): + pass + def add_css_class(self, *_): + pass + +STATE_DOC = [{"mode": "ACTIVE", "detail": "Backend owns nft"}] +ACTUAL = [{"physicalInterface": "enp1s0", "selectiveDns": False, "selectiveDnsEncrypted": False}] +CALLS = [] +QUEUED = [] +def fake_probe(url, iface, allowed): + CALLS.append((url, iface, tuple(sorted(allowed)))) + code = {"https://www.youtube.com/": "200", + "https://www.youtube.com/generate_204": "204", + "https://api.openai.com/v1/models": "401", + "https://github.com/": "200", + "https://gemini.google.com/": "200"}[url] + return {"ok": code in allowed, "reached": True, "code": code} + +class FakeStatePath: + def read_text(self, encoding="utf-8"): + return json.dumps(ACTUAL[0]) + +class GLib: + @staticmethod + def idle_add(fn, *args): + QUEUED.append((fn, args)) + return 1 + +class NoThread: + def __init__(self, *args, **kwargs): + raise AssertionError("R735_PERIODIC_BACKGROUND_WEB_PROBE_FORBIDDEN") + +def read_state(): + if isinstance(STATE_DOC[0], Exception): + raise STATE_DOC[0] + return STATE_DOC[0] + +env = { + "load_state": read_state, "STATE": FakeStatePath(), + "json": json, "GLib": GLib, "probe_url": fake_probe, + "format_probe": lambda name, result: name + ": " + ("PASS" if result["ok"] else "FAIL"), + "format_adult_probes": lambda results: "Adult coverage: disabled", + "threading": types.SimpleNamespace(Thread=NoThread), + "ThreadPoolExecutor": concurrent.futures.ThreadPoolExecutor, +} +exec(compile(ast.Module(body=[METHODS[n] for n in sorted(NAMES)], type_ignores=[]), + "r735_real_source_methods", "exec"), env) + +class Dummy: + def __init__(self, mode="ACTIVE"): + self.status = Label("Status: " + mode) + self.detail = Label("Backend owns nft") + self.gemini_live = Label("Gemini: unverified") + self.adult_live = Label("Adult coverage: Disabled") + self.core_live = Label("Core HTTPS: unverified") + self.live_check_generation = 17 + self.adult_check_enabled = False + self.finish_live_checks = lambda *args: False + self.buttons = {n: Button() for n in ("start", "stop", "recovery", "refresh", "update")} + self.buttons["stop"].enabled = True + for n in NAMES: + setattr(self, n, types.MethodType(env[n], self)) + +def core(*, home=True, light=True, openai=True, github=True, selected=False): + return { + "youtube": {"ok": home, "code": "200" if home else "000"}, + "youtube204": None if selected else {"ok": light, "code": "204" if light else "000"}, + "openai": {"ok": openai, "code": "401" if openai else "000"}, + "github": {"ok": github, "code": "200" if github else "000"}, + "selected_dns": selected, + } + +class Contract(unittest.TestCase): + def setUp(self): + STATE_DOC[0] = {"mode": "ACTIVE", "detail": "Backend owns nft"} + ACTUAL[0] = {"physicalInterface": "enp1s0", "selectiveDns": False, "selectiveDnsEncrypted": False} + CALLS.clear() + QUEUED.clear() + def test_01_engine_active_starts_unverified_not_green(self): + d=Dummy() + d.refresh(state=STATE_DOC[0], start_checks=False) + self.assertEqual(d.status.get_text(), "Status: ACTIVE") + self.assertNotIn("success", d.status.classes) + self.assertIn("warning", d.status.classes) + self.assertTrue(d.buttons["stop"].enabled) + self.assertFalse(d.buttons["start"].enabled) + def test_02_complete_core_https_proofs_required_for_green(self): + d=Dummy() + d.finish_core_checks(17, core()) + self.assertEqual(d.status.get_text(), "Status: ACTIVE") + self.assertIn("success", d.status.classes) + self.assertIn("healthy", d.core_live.get_text().lower()) + def test_03_lightweight_success_cannot_hide_youtube_home_failure(self): + d=Dummy() + d.finish_core_checks(17, core(home=False, light=True)) + self.assertEqual(d.status.get_text(), "Status: PARTIAL") + self.assertNotIn("success", d.status.classes) + self.assertIn("warning", d.status.classes) + self.assertTrue(d.buttons["stop"].enabled) + self.assertFalse(d.buttons["start"].enabled) + def test_04_page_success_cannot_hide_lightweight_failure(self): + d=Dummy() + d.finish_core_checks(17, core(home=True,light=False)) + self.assertEqual(d.status.get_text(), "Status: PARTIAL") + def test_05_selected_dns_preserves_original_page_only_gate(self): + d=Dummy() + d.finish_core_checks(17, core(selected=True)) + self.assertIn("success",d.status.classes) + def test_13_missing_or_malformed_lightweight_proof_is_partial(self): + d=Dummy() + payload=core() + payload["youtube204"]=None + d.finish_core_checks(17,payload) + self.assertEqual(d.status.get_text(),"Status: PARTIAL") + self.assertIn("warning",d.status.classes) + def test_14_missing_worker_proof_fails_closed(self): + d=Dummy() + d.finish_core_checks(17,{"error":"CORE_PROBE_FAILED"}) + self.assertEqual(d.status.get_text(),"Status: PARTIAL") + self.assertNotIn("success",d.status.classes) + def test_06_stale_generation_rejected(self): + d=Dummy() + d.finish_core_checks(16,core()) + self.assertEqual(d.status.get_text(), "Status: ACTIVE") + self.assertNotIn("success", d.status.classes) + def test_07_stale_after_stop_rejected(self): + d=Dummy() + STATE_DOC[0]={"mode":"OFF","detail":"Clean"} + d.finish_core_checks(17,core()) + self.assertNotIn("success",d.status.classes) + def test_08_partial_persists_periodic_observation_without_network(self): + d=Dummy() + d.finish_core_checks(17,core(home=False)) + self.assertEqual(d.status.get_text(),"Status: PARTIAL") + self.assertTrue(d.periodic_state_tick()) + self.assertEqual(d.status.get_text(),"Status: PARTIAL") + self.assertEqual(CALLS,[]) + def test_09_backend_off_overrides_partial_and_stop_disabled(self): + d=Dummy() + d.finish_core_checks(17,core(home=False)) + STATE_DOC[0]={"mode":"OFF","detail":"Clean"} + d.periodic_state_tick() + self.assertEqual(d.status.get_text(),"Status: OFF") + self.assertFalse(d.buttons["stop"].enabled) + self.assertTrue(d.buttons["start"].enabled) + def test_10_worker_checks_real_home_and_204_before_green(self): + d=Dummy() + d.live_checks_worker(17) + urls=[c[0] for c in CALLS] + self.assertIn("https://www.youtube.com/",urls) + self.assertIn("https://www.youtube.com/generate_204",urls) + self.assertIn("https://github.com/",urls) + self.assertIn("https://api.openai.com/v1/models",urls) + self.assertTrue(any(fn.__name__=="finish_core_checks" for fn,_ in QUEUED)) + def test_11_selected_dns_worker_page_only(self): + ACTUAL[0]={"physicalInterface":"enp1s0","selectiveDns":False,"selectiveDnsEncrypted":True} + d=Dummy() + d.live_checks_worker(17) + self.assertIn("https://www.youtube.com/",[c[0] for c in CALLS]) + self.assertNotIn("https://www.youtube.com/generate_204",[c[0] for c in CALLS]) + def test_12_empty_iface_fails_closed_without_network(self): + ACTUAL[0]={"physicalInterface":""} + d=Dummy() + d.live_checks_worker(17) + self.assertEqual(CALLS,[]) + self.assertTrue(any(fn.__name__=="finish_core_checks" for fn,_ in QUEUED)) + +if __name__ == "__main__": + result = unittest.main(verbosity=1, exit=False) + if not result.result.wasSuccessful(): + raise SystemExit(1) + print("R735_LINUX_GUI_CORE_HTTP_TRUTH_NEGATIVE_FIRST_14_CASES_PASS") diff --git a/tests/r739_linux_update_cache_lifecycle_contract.py b/tests/r739_linux_update_cache_lifecycle_contract.py new file mode 100644 index 00000000..5050affe --- /dev/null +++ b/tests/r739_linux_update_cache_lifecycle_contract.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""R739: verify production Linux update installer never leaves new cache or code debris. + +All HTTPS downloads and installer invocations are replaced by explicit in-memory +fixtures inside a disposable HOME; no real operating-system service, network, +release publication, user state or installer is touched. +""" +from __future__ import annotations + +import ast +import hashlib +import io +import json +import os +import pathlib +import stat +import tempfile +import types +import unittest +import zipfile +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SOURCE = (ROOT / "linux/app/direct_internet_method.py").read_text("utf-8-sig") +TREE = ast.parse(SOURCE) +METHOD = [n for n in TREE.body if isinstance(n, ast.FunctionDef) and + n.name == "download_and_install_update"] +assert len(METHOD) == 1, "R739_ONE_PRODUCTION_UPDATE_FUNCTION_REQUIRED" +fn_scope = { + "pathlib": pathlib, "tempfile": tempfile, "hashlib": hashlib, + "json": json, "zipfile": zipfile, "os": os, "stat": stat, + "re": __import__("re"), +} +exec(compile(ast.Module(body=METHOD, type_ignores=[]), "installed_linux_updater_real_function", "exec"), fn_scope) +install = fn_scope["download_and_install_update"] + +VERSION = "1.5.7" +ARCHIVE_NAME = f"DirectInternetMethod_{VERSION}_Linux_x86_64.zip" +PREFIX = ARCHIVE_NAME.removesuffix(".zip") + "/" + +def build_zip(kind="good"): + source = {"install.sh": b"#!/bin/bash\nexit 0\n"} + source.update({f"app/fixture{i:02d}.bin": f"fixture-{i}\n".encode() + for i in range(27)}) + if kind == "missing_installer": + source["another_installer.sh"] = source.pop("install.sh") + rows = [{"file": key, "bytes": len(raw), + "sha256": hashlib.sha256(raw).hexdigest().upper()} + for key, raw in source.items()] + manifest = {"schema": 1, "product": "Direct Internet Method", + "platform": "linux-x86_64", "version": VERSION, "files": rows} + memory = io.BytesIO() + with zipfile.ZipFile(memory, "w", compression=zipfile.ZIP_STORED) as z: + for key, data in source.items(): + zi = zipfile.ZipInfo(PREFIX + key) + if kind == "symlink" and key == "app/fixture00.bin": + zi.create_system = 3 + zi.external_attr = ((stat.S_IFLNK | 0o777) << 16) + z.writestr(zi, data) + z.writestr(PREFIX + "PACKAGE_MANIFEST.json", + json.dumps(manifest).encode()) + z.writestr(PREFIX + "SHA256SUMS.txt", + "".join(f"{r['sha256']} {r['file']}\n" for r in rows)) + if kind == "traversal": + z.writestr(PREFIX + "../escape.txt", b"should-never-extract") + if kind == "duplicate": + z.writestr(PREFIX + "install.sh", b"bad-extra-installer") + return memory.getvalue() + +class CacheTruth(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="r739-updater-real-fixture-") + self.addCleanup(self.temp.cleanup) + self.home = pathlib.Path(self.temp.name) / "home" + self.home.mkdir() + self.cache = self.home / ".cache/DirectInternetMethod/updates" + self.bad_kind = "good" + self.payload = build_zip() + self.sums_raw = hashlib.sha256(self.payload).hexdigest().upper() + " " + ARCHIVE_NAME + "\n" + self.sums = self.sums_raw.encode() + self.info = { + "version": VERSION, "tag": "v"+VERSION, + "asset": {"name": ARCHIVE_NAME, "url": "fixture://zip", + "digest": "sha256:" + hashlib.sha256(self.payload).hexdigest()}, + "sums": {"name": "SHA256SUMS.txt", "url": "fixture://sums", + "digest": "sha256:" + hashlib.sha256(self.sums).hexdigest()}, + } + self.install_calls = [] + def fake_download(url, path): + data = self.sums if url == "fixture://sums" else self.payload + if url not in ("fixture://sums", "fixture://zip"): + raise AssertionError("NON_FIXTURE_DOWNLOAD_ATTEMPT") + pathlib.Path(path).write_bytes(data) + def fake_run(argv, **kwargs): + self.install_calls.append(list(argv)) + target = pathlib.Path(argv[1]) + assert argv[0] == "bash" and target.is_file() + assert target.read_bytes().startswith(b"#!/bin/bash\n") + return types.SimpleNamespace(returncode=0, stdout="INSTALLER_MOCK_PASS\n", stderr="") + fn_scope["_download"] = fake_download + fn_scope["subprocess"] = types.SimpleNamespace(run=fake_run) + self.home_patch = mock.patch.object(pathlib.Path, "home", return_value=self.home) + self.home_patch.start() + self.addCleanup(self.home_patch.stop) + + def assert_no_new_residue(self): + self.assertFalse(self.cache.exists() and any(self.cache.iterdir()), + "R739_NEW_CACHE_DEBRIS_MUST_BE_EMPTY_AFTER_ANY_RESULT") + self.assertFalse(any(self.home.rglob("install.sh")), + "R739_EXTRACTED_INSTALLER_REMAINS") + + def test_01_good_release_installer_returns_and_cleans_all_cache(self): + result = install(self.info) + self.assertEqual(result, "INSTALLER_MOCK_PASS") + self.assertEqual(len(self.install_calls), 1) + self.assert_no_new_residue() + + def test_02_preexisting_unrelated_file_is_preserved(self): + self.cache.mkdir(parents=True) + keep = self.cache / "unrelated-user-file" + keep.write_bytes(b"KEEP_USER_DATA") + self.assertEqual(install(self.info), "INSTALLER_MOCK_PASS") + self.assertEqual(keep.read_bytes(), b"KEEP_USER_DATA") + self.assertEqual(sorted(x.name for x in self.cache.iterdir()), [keep.name]) + + def test_03_checksum_list_digest_mismatch_cleans(self): + self.sums += b"tampered\n" + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + def test_04_zip_sha256_mismatch_cleans(self): + self.payload += b"tamper" + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + def test_05_installer_failure_also_cleans(self): + def failed(argv, **kwargs): + return types.SimpleNamespace(returncode=1, stdout="", stderr="MOCK_INSTALL_FAILED") + fn_scope["subprocess"] = types.SimpleNamespace(run=failed) + with self.assertRaisesRegex(RuntimeError, "MOCK_INSTALL_FAILED"): + install(self.info) + self.assert_no_new_residue() + + def test_06_missing_expected_root_installer_refused(self): + self.payload = build_zip("missing_installer") + self.info["asset"]["digest"] = "sha256:" + hashlib.sha256(self.payload).hexdigest() + self.sums = (hashlib.sha256(self.payload).hexdigest().upper() + " " + ARCHIVE_NAME + "\n").encode() + self.info["sums"]["digest"] = "sha256:" + hashlib.sha256(self.sums).hexdigest() + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + def test_07_symlink_member_refused_even_with_correct_zip_sha(self): + self.payload = build_zip("symlink") + self.info["asset"]["digest"] = "sha256:" + hashlib.sha256(self.payload).hexdigest() + self.sums = (hashlib.sha256(self.payload).hexdigest().upper() + " " + ARCHIVE_NAME + "\n").encode() + self.info["sums"]["digest"] = "sha256:" + hashlib.sha256(self.sums).hexdigest() + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + def test_08_wrong_or_traversal_asset_name_rejected_before_download(self): + self.info["asset"]["name"] = "../unrelated-file.zip" + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + def test_09_bad_zip_member_traversal_refused(self): + self.payload = build_zip("traversal") + self.info["asset"]["digest"] = "sha256:" + hashlib.sha256(self.payload).hexdigest() + self.sums = (hashlib.sha256(self.payload).hexdigest().upper() + " " + ARCHIVE_NAME + "\n").encode() + self.info["sums"]["digest"] = "sha256:" + hashlib.sha256(self.sums).hexdigest() + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + @unittest.skipUnless(hasattr(os, "symlink"), "host cannot make symlinks") + def test_10_symlink_cache_root_refused_without_outside_writes(self): + outside = self.home / "outside" + outside.mkdir() + self.cache.parent.mkdir(parents=True) + try: + self.cache.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + self.skipTest("Host policy prohibits symlink creation; exercised on Linux CI") + with self.assertRaises(Exception): + install(self.info) + self.assertEqual(list(outside.iterdir()), []) + self.assertEqual(self.install_calls, []) + + def test_11_duplicate_zip_member_refused(self): + self.payload = build_zip("duplicate") + self.info["asset"]["digest"] = "sha256:" + hashlib.sha256(self.payload).hexdigest() + self.sums = (hashlib.sha256(self.payload).hexdigest().upper() + " " + ARCHIVE_NAME + "\n").encode() + self.info["sums"]["digest"] = "sha256:" + hashlib.sha256(self.sums).hexdigest() + with self.assertRaises(RuntimeError): + install(self.info) + self.assertEqual(self.install_calls, []) + self.assert_no_new_residue() + + def test_12_repeated_installs_do_not_accumulate_tmp_dirs(self): + for i in range(3): + self.assertEqual(install(self.info), "INSTALLER_MOCK_PASS") + self.assert_no_new_residue() + self.assertEqual(len(self.install_calls), 3) + +if __name__ == "__main__": + unittest.main(verbosity=1) + print("R739_REAL_PRODUCTION_LINUX_UPDATER_CACHE_SECURITY_12_TESTS_PASS") diff --git a/tests/r742_nfqws_conntrack_syn_first_packet_contract.py b/tests/r742_nfqws_conntrack_syn_first_packet_contract.py new file mode 100644 index 00000000..a77c08ed --- /dev/null +++ b/tests/r742_nfqws_conntrack_syn_first_packet_contract.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""R742 negative-first nfqws packet-0 conntrack and fail-closed nft ownership. + +Rootless: fakes nft output and validates exact production Bash ownership +function. Never touches real nftables/network, DNS, services or Tailscale. +""" +from __future__ import annotations +import json +import os +import pathlib +import re +import subprocess +import tempfile +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SOURCE=(ROOT/"linux/app/direct_method_helper.sh").read_text(encoding="utf-8") +OWNED_NAME="nft_table_owned" +TRANSIENT_NAME="nft_table_transient_owned" +SYN_FASTPATH='tcp flags & (syn | ack) == syn return' +MARK='meta mark & 0x40000000 == 0x40000000 return' +TS='meta mark & 0xff0000 == 0x80000 return' +QUEUE='tcp dport 443 ct original packets 1-6 queue num "$QNUM" bypass' +OWNED='nft_table_owned(){' +TRANSIENT='nft_table_transient_owned(){' + +def fragment(name): + matches=re.findall(r'^'+re.escape(name)+r'\(\)\{\n.*?^\}\n', + SOURCE,flags=re.M|re.S) + if len(matches)!=1: + raise AssertionError("R742_EXPECTED_UNIQUE_PRODUCTION_FUNCTION_"+name) + return matches[0] + +class ConntrackGuard(unittest.TestCase): + def test_01_no_pure_syn_exclusion_prior_to_nfqueue(self): + self.assertNotIn(SYN_FASTPATH,SOURCE, + "R742_SOURCE_BYPASSES_SYN_NFQWS_NEVER_CREATES_INTERNAL_CONNECTION") + self.assertIn('nft add rule inet "$TABLE" output oifname "$PHY" '+QUEUE, + SOURCE) + self.assertLess(SOURCE.index('meta mark & 0x40000000 == 0x40000000 return'), + SOURCE.index(QUEUE)) + self.assertLess(SOURCE.index('meta mark & 0xff0000 == 0x80000 return'), + SOURCE.index(QUEUE)) + + def test_02_stable_owned_full_nft_requires_same_exact_mark_order_and_counts(self): + own=fragment(OWNED_NAME) + self.assertIn('expected_count = 10 if coexist else 9',own) + self.assertNotIn('syn_rule',own) + self.assertNotIn('tcp flags',own) + self.assertIn('lines[offset + index]',own) + self.assertIn('len(lines) != expected_count',own) + + def test_03_transient_exact_owner_rejects_foreign_early_syn_rule(self): + fn=fragment(TRANSIENT_NAME) + self.assertNotIn('patterns.append(dev+r" tcp dport 443 tcp flags',fn) + self.assertIn('for proto,port in',fn) + self.assertIn('len(rules)>len(patterns)',fn) + + def test_04_output_has_no_unconditional_slowdown_or_tailscale_mutation(self): + self.assertIn('ct original packets 1-6 queue num "$QNUM" bypass',SOURCE) + self.assertIn('meta mark & 0xff0000 == 0x80000 return',SOURCE) + self.assertIn('meta mark & 0x40000000 == 0x40000000 return',SOURCE) + self.assertNotIn('ip route replace default',SOURCE) + self.assertIn('nft_table_owned "$phy"',SOURCE) + + def test_05_original_profile_and_user_whitelist_remain_targeted(self): + self.assertIn('--hostlist-domains=youtube.com,youtu.be,googlevideo.com',SOURCE) + self.assertIn('--dpi-desync=multisplit --dpi-desync-split-pos=1',SOURCE) + self.assertIn('TAILSCALE_ALL_SITES_UNSUPPORTED',SOURCE) + + def test_06_production_owner_semantics_actual_fake_full_and_bad_tables(self): + extract="\n".join(fragment(name) for name in + ('state_value','nft_table_owned','nft_table_transient_owned')) + full='''table inet directinternetmethod { +chain output { +type filter hook output priority mangle; policy accept; +meta mark & 0x40000000 == 0x40000000 return +meta mark & 0x00ff0000 == 0x00080000 return +oifname "enp1s0" tcp dport 80 ct original packets 1-6 queue flags bypass to 200 +oifname "enp1s0" tcp dport 443 ct original packets 1-6 queue flags bypass to 200 +oifname "enp1s0" udp dport 443 ct original packets 1-6 queue flags bypass to 200 +} +} +''' + tests=[ + ('owned',full,True,True), + ('old_fastpath_foreign',full.replace( + 'oifname "enp1s0" tcp dport 80', + 'oifname "enp1s0" tcp dport 443 '+SYN_FASTPATH+'\n' + 'oifname "enp1s0" tcp dport 80',1),False,False), + ('foreign_missing_injection_guard',full.replace( + 'meta mark & 0x40000000 == 0x40000000 return\n',''),False,False), + ('foreign_wrong_interface',full.replace( + 'oifname "enp1s0" tcp dport 443 ct', + 'oifname "wrong0" tcp dport 443 ct',1),False,False), + ('foreign_added_drop',full.replace('oifname "enp1s0" udp dport', + 'ip daddr 203.0.113.1 drop\n' + 'oifname "enp1s0" udp dport',1),False,False), + ] + with tempfile.TemporaryDirectory(prefix="r742-nfqws-owner-no-net-") as td: + base=pathlib.Path(td) + nft=base/"nft" + nft.write_text('#!/bin/sh\n[ "$*" = "list table inet directinternetmethod" ] || exit 12\n' + 'cat "$R742_MOCK_TABLE"\n',encoding="utf8") + nft.chmod(0o700) + table=base/"table" + state=base/"state.json" + state.write_text(json.dumps({"dnsMode":"system-preserved"}),encoding="utf8") + env=dict(os.environ,PATH=str(base)+os.pathsep+os.environ.get('PATH',''), + R742_MOCK_TABLE=str(table)) + for name,contents,owned,transient in tests: + table.write_text(contents,encoding="utf8") + code='set -u\nTABLE=directinternetmethod\nSTATE='+str(state)+'\n'+extract+''' +if nft_table_owned enp1s0;then echo OWNED=1;else echo OWNED=0;fi +if nft_table_transient_owned enp1s0 1;then echo TRANSIENT=1;else echo TRANSIENT=0;fi +''' + p=subprocess.run(['bash','-c',code],env=env, + text=True,capture_output=True,timeout=12) + self.assertEqual(p.returncode,0,(name,p.stdout,p.stderr)) + self.assertIn("OWNED="+str(int(owned)),p.stdout,(name,p.stdout)) + self.assertIn("TRANSIENT="+str(int(transient)),p.stdout,(name,p.stdout)) + +if __name__=="__main__": + unittest.main(verbosity=1) + print("R742_SYN_EARLY_NFQUEUE_AND_ROOTLESS_FAILCLOSED_OWNERSHIP_PASS") diff --git a/tests/r747_gui_local_socket_optin_contract.py b/tests/r747_gui_local_socket_optin_contract.py new file mode 100644 index 00000000..dfec2784 --- /dev/null +++ b/tests/r747_gui_local_socket_optin_contract.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""R747 GUI opt-in contract: real production Window methods, no GTK or network. + +The opt-in SOCKS process is never created by Application/Window constructor; +only a clicked Start action may create it. Close must dispose it and restore +control state. No system/root/Tailscale/browser proxy settings are touched. +""" +from __future__ import annotations + +import ast +import pathlib +import types +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SOURCE=(ROOT/"linux/app/direct_internet_method.py").read_text("utf-8") +APP=ast.parse(SOURCE) +WINDOW=next(n for n in APP.body if isinstance(n,ast.ClassDef) and n.name=="Window") +METHODS={n.name:n for n in WINDOW.body if isinstance(n,ast.FunctionDef)} +NEED=("on_local_socket_start","on_local_socket_stop","close_local_socket","on_action") +assert all(n in METHODS for n in NEED),"R747_REQUIRED_GUI_CONTROLS_MISSING" +BODY=ast.unparse(next(x for x in WINDOW.body if x.name=="__init__")) +assert "self.local_socket_bridge = None" in BODY +assert 'self.local_socket_start_button.connect(' in BODY and 'self.on_local_socket_start' in BODY +assert 'self.local_socket_stop_button.connect(' in BODY and 'self.on_local_socket_stop' in BODY +assert 'self.connect(' in BODY and 'self.close_local_socket' in BODY +assert "candidate.start()" not in BODY and "LocalSocketFallback()" not in BODY +assert "self.local_socket_bridge" in SOURCE +assert "SOCKS5 127.0.0.1" in SOURCE +assert "action == 'start'" in ast.unparse(METHODS["on_action"]) +assert "on_local_socket_start" in SOURCE + +class Button: + def __init__(self):self.enabled=None + def set_sensitive(self,v):self.enabled=bool(v) + +class Label: + def __init__(self):self.value="" + def set_text(self,s):self.value=str(s) + +class State: + symlink=False + present=False + def is_symlink(self):return self.symlink + def exists(self):return self.present + +class TestGUI(unittest.TestCase): + def setUp(self): + self.state=State() + self.tunnel=False + self.starts=0 + self.stops=0 + self.should_fail=False + testcase=self + class Local: + def __init__(self):pass + def start(self): + testcase.starts+=1 + if testcase.should_fail:raise RuntimeError("TEST_BIND_ERROR") + return 47321 + def stop(self):testcase.stops+=1 + env={"STATE":self.state,"_external_tunnel_active":lambda:self.tunnel, + "LocalSocketFallback":Local,"ui_test_log":lambda *args:None} + sub=ast.Module(body=[METHODS[n] for n in NEED],type_ignores=[]) + exec(compile(sub,"r747_actual_GUI_methods","exec"),env) + self.env=env + self.window=types.SimpleNamespace( + local_socket_bridge=None, + local_socket_start_button=Button(),local_socket_stop_button=Button(), + local_socket_status=Label(),result=Label(), + ) + for name in NEED: + setattr(self.window,name,types.MethodType(env[name],self.window)) + + def test_01_direct_user_click_is_only_activation(self): + self.assertEqual(self.starts,0) + self.window.on_local_socket_start() + self.assertEqual(self.starts,1) + self.assertIsNotNone(self.window.local_socket_bridge) + self.assertFalse(self.window.local_socket_start_button.enabled) + self.assertTrue(self.window.local_socket_stop_button.enabled) + self.assertIn("127.0.0.1:47321",self.window.local_socket_status.value) + self.assertIn("manual",SOURCE.lower()) + + def test_02_explicit_stop_disposes_single_instance(self): + self.window.on_local_socket_start() + self.window.on_local_socket_stop() + self.assertEqual(self.stops,1) + self.assertIsNone(self.window.local_socket_bridge) + self.assertTrue(self.window.local_socket_start_button.enabled) + self.assertFalse(self.window.local_socket_stop_button.enabled) + self.window.on_local_socket_stop() + self.assertEqual(self.stops,1) + + def test_03_close_disposes_session_without_admin(self): + self.window.on_local_socket_start() + self.assertIs(self.window.close_local_socket(),False) + self.assertEqual(self.stops,1) + self.assertIsNone(self.window.local_socket_bridge) + + def test_04_reentrant_start_does_not_create_duplicate_listener(self): + self.window.on_local_socket_start() + self.window.on_local_socket_start() + self.assertEqual(self.starts,1) + + def test_05_conflicting_main_engine_denies_fallback(self): + self.state.present=True + self.window.on_local_socket_start() + self.assertEqual(self.starts,0) + self.assertIn("OFF",self.window.local_socket_status.value) + + def test_06_symlink_engine_state_denies_fallback(self): + self.state.symlink=True + self.window.on_local_socket_start() + self.assertEqual(self.starts,0) + + def test_07_external_tunnel_denies_fallback(self): + self.tunnel=True + self.window.on_local_socket_start() + self.assertEqual(self.starts,0) + + def test_08_startup_error_does_not_leave_owned_listener(self): + self.should_fail=True + self.window.on_local_socket_start() + self.assertEqual(self.starts,1) + self.assertEqual(self.stops,1) + self.assertIsNone(self.window.local_socket_bridge) + self.assertIn("TEST_BIND_ERROR",self.window.local_socket_status.value) + + def test_09_normal_system_start_denied_while_local_proxy_active(self): + self.window.on_local_socket_start() + self.window.on_action(None,"start") + self.assertIn("Stop the optional local SOCKS5",self.window.result.value) + + def test_10_no_global_proxy_writes_or_automatic_third_party_bundle(self): + for bad in ("gsettings set org.gnome.system.proxy", + "network.proxy.socks_host =", + "xdg-settings set default-web-browser", + "pkexec /usr/bin","sudo /usr/bin", + "os.environ['ALL_PROXY']", + "os.environ['HTTPS_PROXY']"): + self.assertNotIn(bad,SOURCE) + self.assertIn("from socket_fallback import LocalSocketFallback",SOURCE) + +if __name__=="__main__": + unittest.main(verbosity=1) + print("R747_GUI_OPTIN_START_STOP_CLOSE_NO_SYSTEM_PROXY_10_TESTS_PASS") diff --git a/tests/r747_optin_local_socket_fallback_contract.py b/tests/r747_optin_local_socket_fallback_contract.py new file mode 100644 index 00000000..a98313dd --- /dev/null +++ b/tests/r747_optin_local_socket_fallback_contract.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""R747 local SOCKS opt-in: negative-first security & lifecycle; no real web. + +All network operations use disposable 127.0.0.1 listeners. Production code +permanently rejects private DNS; fixture supplies public fake resolution and +a test-only dialer redirected to a separate private mock listener. Never starts +the real Direct Method service, alters DNS, installs binary or changes proxy. +""" +from __future__ import annotations + +import importlib.util +import os +import pathlib +import socket +import socketserver +import ssl +import struct +import threading +import time +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SOURCE=ROOT/"linux/app/socket_fallback.py" +SPEC=importlib.util.spec_from_file_location("dim_r747_socket_fallback",SOURCE) +sockmod=importlib.util.module_from_spec(SPEC) +import sys +sys.modules[SPEC.name]=sockmod +SPEC.loader.exec_module(sockmod) + + +def hello(host="www.youtube.com"): + context=ssl.create_default_context() + context.set_alpn_protocols(["http/1.1"]) + rx,tx=ssl.MemoryBIO(),ssl.MemoryBIO() + tls=context.wrap_bio(rx,tx,server_hostname=host) + try: + tls.do_handshake() + except ssl.SSLWantReadError: + pass + result=tx.read() + assert result and result[0]==22 and result[5]==1 + return result + + +def read_n(sock,n): + out=b"" + while len(out) '"+str(marker)+"'\\n") + fake.chmod(0o700) + with patch.dict(os.environ, {"PATH":td+":"+os.environ.get("PATH","")}): + result=MOD.call("resolvectl","--version",seconds=4) + self.assertEqual(result.returncode, 0) + self.assertFalse(marker.exists()) + self.assertNotIn("POISON", result.stdout) + + def test_02_unknown_command_name_is_rejected_before_any_spawn(self): + with patch.object(MOD.subprocess, "run", side_effect=AssertionError("spawned")): + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNTRUSTED_COMMAND"): + MOD.call("malicious", "--do-not-run") + + def test_03_exact_absolute_argv_and_bounded_timeout(self): + with patch.object(MOD.subprocess, "run", + return_value=SimpleNamespace(stdout="ok",stderr="",returncode=0)) as run: + result=MOD.call("ip","-4","route","show","default",seconds=5) + argv=run.call_args.args[0] + self.assertEqual(argv[0], "/usr/bin/ip") + self.assertEqual(argv[1:], ("-4","route","show","default")) + self.assertEqual(run.call_args.kwargs["timeout"],5) + self.assertNotIn("shell",run.call_args.kwargs) + self.assertEqual(result.stdout,"ok") + + def test_04_every_allowed_binary_is_absolute_root_owned_when_installed(self): + # Hosted CI may not have optional Tailscale/dig tools. Their absence + # must fail closed on invocation, not reject testing the whole source. + found=0 + for label,name in MOD.TRUSTED_SYSTEM_BINARIES.items(): + path=pathlib.Path(name) + with self.subTest(label=label): + self.assertTrue(path.is_absolute()) + self.assertEqual(str(path),"/usr/bin/"+label) + if not path.exists(): + continue + self.assertTrue(path.is_file()) + self.assertFalse(path.is_symlink()) + st=path.stat() + self.assertEqual(st.st_uid,0) + self.assertFalse(st.st_mode & 0o022) + found+=1 + self.assertGreaterEqual(found,1) + + def test_05_missing_trusted_binary_fails_closed_before_spawn(self): + with patch.dict(MOD.TRUSTED_SYSTEM_BINARIES, + {"resolvectl":"/usr/bin/dim-r763-known-missing-tool"}): + with patch.object(MOD.subprocess,"run",side_effect=AssertionError("spawned")): + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_TRUSTED_TOOL_MISSING"): + MOD.call("resolvectl","--version") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/r764_parent_path_security_contract.py b/tests/r764_parent_path_security_contract.py new file mode 100644 index 00000000..5d131bce --- /dev/null +++ b/tests/r764_parent_path_security_contract.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""R764 nonprivileged negative-first parent-symlink path tests. +All stage/commit work uses R519's disposable fake filesystem under tests/. +""" +import importlib.util +import json +import pathlib +import unittest + +HERE=pathlib.Path(__file__).resolve().parent +spec=importlib.util.spec_from_file_location("r764_r519_fake_fixture", + HERE/"protected_upgrade_transaction_contract.py") +module=importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +class R764PathParents(unittest.TestCase): + def setUp(self): + self.fake=module.FakeFixture("test_success_stage_apply_rollback") + self.fake.setUp() + def tearDown(self): + self.fake.tearDown() + + def test_symlink_parent_of_source_package_is_rejected_before_stage(self): + actual=self.fake.root/"real-source-parent" + actual.mkdir() + real_package=actual/"package" + self.fake.pkg.rename(real_package) + alias=self.fake.root/"source-parent-alias" + alias.symlink_to(actual,target_is_directory=True) + sourced=alias/"package" + self.assertTrue(sourced.is_dir()) + self.assertFalse(sourced.is_symlink()) + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_PARENT_COMPONENT"): + self.fake.tx.stage(sourced) + self.assertFalse(self.fake.tx.pending.exists()) + + def test_symlink_parent_of_root_acceptance_receipt_is_rejected(self): + self.fake.tx.stage(self.fake.pkg) + self.fake.tx.apply() + doc=self.fake.tx.manifest() + root=self.fake.root/"actual-acceptance-parent" + root.mkdir() + receipt=root/"acceptance.json" + data={"revision":"R519","status":"PASS_NATIVE_ROOT_UPGRADE", + "sourceRootSHA":module.m.sha(module.m.canonical(doc["files"])), + "protectedMetadataSHA":doc["newMetadataSHA"], + "transactionNonce":doc["transactionNonce"], + "nativeStartStopRecovery":True, + "tailscalePreserved":True,"physicalDNSPreserved":True} + receipt.write_text(json.dumps(data)+"\n") + alias=self.fake.root/"receipt-parent-alias" + alias.symlink_to(root,target_is_directory=True) + bypass=alias/"acceptance.json" + self.assertTrue(bypass.is_file()) + self.assertFalse(bypass.is_symlink()) + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_PARENT_COMPONENT"): + self.fake.tx.commit(bypass) + self.assertEqual(self.fake.tx.status()["status"],"APPLIED") + + def test_untrusted_native_acceptance_owner_is_rejected(self): + # No privilege requested: fake-owned receipt under a user-owned test + # directory must not become a native Root acceptance proof. + f=self.fake.root/"user-owned-test-receipt.json" + f.write_text("{}") + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_RECEIPT_PARENT_OWNER"): + module.m.guarded_file_bytes(f,native_receipt=True,limit=65536) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r766_systemd_exec_origin_contract.py b/tests/r766_systemd_exec_origin_contract.py new file mode 100644 index 00000000..3f1922cc --- /dev/null +++ b/tests/r766_systemd_exec_origin_contract.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Unprivileged R766 hostile command-origin tests; never spawn systemctl.""" +import importlib.util +import pathlib +import unittest +from unittest.mock import patch +from types import SimpleNamespace + +TARGET=pathlib.Path(__file__).with_name("r566_native_systemd_persistent_catchup.py") +s=importlib.util.spec_from_file_location("dim_r766_sysctl",TARGET) +m=importlib.util.module_from_spec(s) +s.loader.exec_module(m) + +class TrustedSystemctlOnly(unittest.TestCase): + def _no_spawn(self,*args): + with patch.object(m.subprocess,"run",side_effect=AssertionError("UNEXPECTED_SUBPROCESS_EXECUTION")) as mocked: + with self.assertRaisesRegex(RuntimeError,"R566_SYSTEMD_FAIL_CLOSED_"): + m.run(*args) + mocked.assert_not_called() + + def test_non_systemctl_executable_never_spawns(self): + self._no_spawn("/bin/sh","-c","echo NOT_EXECUTED") + + def test_non_owned_target_unit_never_spawns(self): + self._no_spawn("/usr/bin/systemctl","start","../../other.service") + + def test_show_of_privileged_property_never_spawns(self): + self._no_spawn("/usr/bin/systemctl","show","dim-r566-catchup-123.timer", + "-p","ExecStart","--value") + + def test_valid_expected_commands_still_use_argv(self): + valid=[ + ("/usr/bin/systemctl","daemon-reload"), + ("/usr/bin/systemctl","start","dim-r566-catchup-123.timer"), + ("/usr/bin/systemctl","stop","dim-r566-catchup-123.timer"), + ("/usr/bin/systemctl","show","dim-r566-catchup-123.timer","-p","ActiveState","--value") + ] + with patch.object(m.subprocess,"run",return_value=SimpleNamespace(returncode=0,stdout="active\n",stderr="")) as mocked: + for args in valid: + self.assertEqual(m.run(*args),"active") + self.assertEqual(mocked.call_count,len(valid)) + for call,args in zip(mocked.call_args_list,valid): + self.assertEqual(call.args[0],args) + self.assertNotIn("shell",call.kwargs) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r767_consent_parent_nofollow_contract.py b/tests/r767_consent_parent_nofollow_contract.py new file mode 100644 index 00000000..ddb3c6a9 --- /dev/null +++ b/tests/r767_consent_parent_nofollow_contract.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Isolated negative-first consent-parent symlink path contract, no root/network.""" +from pathlib import Path +import importlib.util +import os +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[1] +s=importlib.util.spec_from_file_location("r767_dns_controller",ROOT/"linux/system/encrypted_selective_dns.py") +m=importlib.util.module_from_spec(s) +s.loader.exec_module(m) + +class ConsentPath(unittest.TestCase): + def test_user_home_private_consent_cannot_be_redirected_via_dotlocal_symlink(self): + with tempfile.TemporaryDirectory(prefix="r767-parent-",dir=str(ROOT)) as temp: + p=Path(temp);home=p/"fake-home";home.mkdir() + ext=p/"user-external-local";app=ext/"share/DirectInternetMethod" + app.mkdir(parents=True) + (home/".local").symlink_to(ext,target_is_directory=True) + (app/m.NEW_OPTIN).write_text(m.CONSENT+"\n") + (app/m.NEW_OPTIN).chmod(0o600) + (app/"scope.txt").write_text("targeted\n") + (app/"strategy.txt").write_text("balanced\n") + self.assertTrue((home/".local").is_symlink()) + self.assertFalse((home/".local/share/DirectInternetMethod"/m.NEW_OPTIN).is_symlink()) + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_CONSENT_PARENT_COMPONENT"): + m.require_consent(os.getuid(),str(home)) +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r769_root_file_descriptor_boundary_contract.py b/tests/r769_root_file_descriptor_boundary_contract.py new file mode 100644 index 00000000..5e4334c6 --- /dev/null +++ b/tests/r769_root_file_descriptor_boundary_contract.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Rootless R769 path-taint regression: no real installation, no privilege, no network.""" +import hashlib +import importlib.util +import pathlib +import tempfile +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SOURCE=ROOT/"linux/system/protected_upgrade_transaction.py" +SPEC=importlib.util.spec_from_file_location("r769_root_tx",SOURCE) +MOD=importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MOD) + +class SecureFileAccess(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory(prefix="r769-userowned-",dir=str(ROOT)) + self.root=pathlib.Path(self.temp.name) + self.trusted=self.root/"actual" + self.trusted.mkdir() + self.payload=self.trusted/"payload" + self.payload.write_bytes(b"FAKE_STATIC_ROOT_PAYLOAD") + self.alias=self.root/"redirected" + self.alias.symlink_to(self.trusted,target_is_directory=True) + self.target=self.alias/"payload" + self.assertTrue(self.target.is_file()) + self.assertFalse(self.target.is_symlink()) + + def tearDown(self): + self.temp.cleanup() + + def test_symlink_parent_hash_must_fail_closed(self): + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_PARENT_COMPONENT"): + MOD.hash_file(self.target) + + def test_symlink_parent_regular_stat_must_fail_closed(self): + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_PARENT_COMPONENT"): + MOD.no_symlink_regular(self.target,owner=False) + + def test_valid_private_file_preserves_hash_and_stat(self): + self.assertEqual(MOD.hash_file(self.payload),hashlib.sha256(self.payload.read_bytes()).hexdigest().upper()) + info=MOD.no_symlink_regular(self.payload,owner=False) + self.assertEqual(info.st_size,len(self.payload.read_bytes())) + + def test_large_root_static_binary_streams_safely(self): + # 8 MiB file exercises legitimate static 'ctrld' size without reading OS root. + large=self.root/"large-static" + with large.open("wb") as writer: + writer.write(b"X"*(8*1024*1024)) + self.assertEqual(MOD.hash_file(large),hashlib.sha256(large.read_bytes()).hexdigest().upper()) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r771_fifo_special_file_failclosed_contract.py b/tests/r771_fifo_special_file_failclosed_contract.py new file mode 100644 index 00000000..2a3733de --- /dev/null +++ b/tests/r771_fifo_special_file_failclosed_contract.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""R771: user-owned special package file must fail closed without hanging Root. + +The test only invokes pure source helpers with disposable FIFO/regular files +under this checkout. Each potentially blocking call runs in a bounded child. +No root privileges, real system services or network are involved. +""" +import hashlib +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SOURCE=ROOT/"linux/system/protected_upgrade_transaction.py" +CHILD=r''' +import importlib.util,sys +spec=importlib.util.spec_from_file_location("dim_r771_root",sys.argv[1]) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +name,path=sys.argv[2:] +try: + if name=="no_symlink_regular": + m.no_symlink_regular(path,owner=False) + else: + getattr(m,name)(path) +except RuntimeError as exc: + if "FAIL_CLOSED_UNSAFE_FILE_TYPE_OR_SIZE" in str(exc): + print("R771_REJECTED_NONREGULAR") + sys.exit(0) + print("R771_UNEXPECTED_ERROR",type(exc).__name__,str(exc)) + sys.exit(91) +except Exception as exc: + print("R771_UNEXPECTED_EXCEPTION",type(exc).__name__,str(exc)) + sys.exit(92) +sys.exit(93) +''' + +class R771SpecialFileNoHang(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="r771-user-fifo-",dir=str(ROOT)) + self.base=pathlib.Path(self.tmp.name) + self.fifo=self.base/"untrusted-package-member" + os.mkfifo(self.fifo,0o600) + self.regular=self.base/"regular-payload" + self.regular.write_bytes(b"PINNED_REGULAR_FILE") + + def tearDown(self): + self.tmp.cleanup() + + def _must_reject_fifo(self,name): + command=[sys.executable,"-B","-c",CHILD,str(SOURCE),name,str(self.fifo)] + try: + response=subprocess.run(command,capture_output=True,text=True,timeout=2.0) + except subprocess.TimeoutExpired: + self.fail("R771_FIFO_OPEN_BLOCKED_WAITING_FOR_NONEXISTENT_WRITER") + self.assertEqual(response.returncode,0,(name,response.stdout,response.stderr)) + self.assertIn("R771_REJECTED_NONREGULAR",response.stdout) + + def test_01_guarded_source_bytes_reject_fifo_without_hang(self): + self._must_reject_fifo("guarded_file_bytes") + + def test_02_regular_file_validation_reject_fifo_without_hang(self): + self._must_reject_fifo("no_symlink_regular") + + def test_03_hash_file_reject_fifo_without_hang(self): + self._must_reject_fifo("hash_file") + + def test_04_valid_regular_files_remain_readable(self): + import importlib.util + spec=importlib.util.spec_from_file_location("dim_r771_inline",SOURCE) + module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) + content=self.regular.read_bytes() + self.assertEqual(module.guarded_file_bytes(self.regular),content) + self.assertEqual(module.hash_file(self.regular),hashlib.sha256(content).hexdigest().upper()) + self.assertEqual(module.no_symlink_regular(self.regular,owner=False).st_size,len(content)) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r776_checked_fd_swap_regression.py b/tests/r776_checked_fd_swap_regression.py new file mode 100644 index 00000000..68d4c91b --- /dev/null +++ b/tests/r776_checked_fd_swap_regression.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""R776 negative-first deterministic metadata/manifest TOCTOU read regression. + +Scratch-owned fake root only; no elevated privileges, installed files or network. +A controlled file swap occurs immediately after an existing validity check. +""" +import importlib.util +from contextlib import contextmanager +import json +import pathlib +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "dim_r776_toc", ROOT / "linux/system/protected_upgrade_transaction.py") +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + +def write_json(p, data, mode=0o644): + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(data, sort_keys=True) + "\n", encoding="utf-8") + p.chmod(mode) + +class SwappedPathAfterCheck(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="r776-fakeroot-", dir=str(ROOT)) + self.home = pathlib.Path(self.tmp.name) + self.root = self.home / "fake" + self.tx = mod.RootTransaction(self.root) + self.meta = self.tx.meta + self.good = { + "schema": 1, "version": mod.CURRENT_BACKEND_VERSION, + "uid": 1000, "user": "trusted", "home": "/home/trusted" + } + self.evil = dict(self.good, user="attacker", home="/home/attacker") + write_json(self.meta, self.good) + self.malicious_meta = self.home / "user-controlled-meta.json" + write_json(self.malicious_meta, self.evil) + marker = self.root / "etc/directinternetmethod.uid" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("1000\n", encoding="utf-8") + self.manifest = self.tx.pending / "manifest.json" + self.good_manifest = { + "schema": mod.SCHEMA, "revision": "R519", + "fromVersion": mod.CURRENT_BACKEND_VERSION, + "toVersion": mod.TARGET_VERSION, "phase": "STAGED", + "files": {name: {} for name,*_ in mod.FILES}, + "transactionNonce": "0"*64, + } + self.evil_manifest = dict(self.good_manifest, phase="COMMITTED") + write_json(self.manifest, self.good_manifest, mode=0o600) + self.malicious_manifest = self.home / "user-controlled-manifest.json" + write_json(self.malicious_manifest, self.evil_manifest, mode=0o600) + + def tearDown(self): + self.tmp.cleanup() + + def swap_during_check(self, target, replacement): + original = mod.no_symlink_regular + called = [] + def check_and_swap(p, owner=True): + st = original(p, owner) + if pathlib.Path(p) == target: + called.append(True) + target.rename(target.with_name(target.name + ".original")) + target.symlink_to(replacement) + return st + mod.no_symlink_regular = check_and_swap + return original, called + + def test_legacy_metadata_mode_error_is_preserved(self): + self.meta.chmod(0o600) + with self.assertRaisesRegex(RuntimeError, "METADATA_MODE"): + self.tx.original_metadata() + def test_valid_unmodified_metadata(self): + self.assertEqual(self.tx.original_metadata(), self.good) + + def test_valid_unmodified_manifest(self): + self.assertEqual(self.tx.manifest()["phase"], "STAGED") + + def test_metadata_swap_does_not_accept_unvalidated_path(self): + original, called = self.swap_during_check(self.meta, self.malicious_meta) + try: + try: + result = self.tx.original_metadata() + except RuntimeError: + return + self.assertNotEqual(result["home"], "/home/attacker", + "R776_TOCTOU_WRONG_METADATA_ACCEPTED") + finally: + mod.no_symlink_regular = original + + def test_manifest_swap_does_not_accept_unvalidated_path(self): + original, called = self.swap_during_check(self.manifest, self.malicious_manifest) + try: + try: + result = self.tx.manifest() + except RuntimeError: + return + self.assertNotEqual(result["phase"], "COMMITTED", + "R776_TOCTOU_WRONG_MANIFEST_ACCEPTED") + finally: + mod.no_symlink_regular = original + + def swap_after_inode_is_open(self, target, replacement): + """Model a rename/symlink race AFTER the source inode is pinned.""" + original = mod.verified_regular_fd + swapped = [] + @contextmanager + def open_and_swap(path): + with original(path) as (fd, st): + if pathlib.Path(path) == target: + swapped.append(True) + target.rename(target.with_name(target.name + ".pinned")) + target.symlink_to(replacement) + yield fd, st + mod.verified_regular_fd = open_and_swap + return original, swapped + + def test_original_metadata_read_remains_on_pinned_inode(self): + original, switched = self.swap_after_inode_is_open(self.meta, self.malicious_meta) + try: + self.assertEqual(self.tx.original_metadata(), self.good) + self.assertEqual(switched, [True]) + finally: + mod.verified_regular_fd = original + + def test_manifest_read_remains_on_pinned_inode(self): + original, switched = self.swap_after_inode_is_open( + self.manifest, self.malicious_manifest) + try: + self.assertEqual(self.tx.manifest()["phase"], "STAGED") + self.assertEqual(switched, [True]) + finally: + mod.verified_regular_fd = original + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/r778_stage_backup_snapshot_race_contract.py b/tests/r778_stage_backup_snapshot_race_contract.py new file mode 100644 index 00000000..7ec70e31 --- /dev/null +++ b/tests/r778_stage_backup_snapshot_race_contract.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""R778 synthetic-fake-root negative-first stage snapshot race; never privileged. + +A controlled attacker-controlled symlink swap happens after accepted baseline +checks and before old backup bytes are captured. The intended security contract +is fail-closed before creating any pending transaction slot. +""" +import importlib.util +import json +import pathlib +import unittest + +BASE=pathlib.Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location("r778_original_fixture", + BASE/"tests/protected_upgrade_transaction_contract.py") +fixture_module=importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixture_module) + +class StageSnapshotRace(unittest.TestCase): + def setUp(self): + self.case=fixture_module.FakeFixture("test_success_stage_apply_rollback") + self.case.setUp() + + def tearDown(self): + self.case.tearDown() + + def _swap_after_baseline(self,target: pathlib.Path, content:bytes): + replacement=self.case.root/("untrusted-r778-"+target.name) + replacement.write_bytes(content) + old=self.case.tx._baseline + calls=[] + def switch(): + verified=old() + calls.append(True) + target.rename(target.with_name(target.name+".original-unchanged")) + target.symlink_to(replacement) + return verified + self.case.tx._baseline=switch + return calls,replacement + + def _must_reject_before_pending(self,calls): + with self.assertRaises((RuntimeError,OSError,ValueError)): + self.case.tx.stage(self.case.pkg) + self.assertEqual(calls,[True]) + self.assertFalse(self.case.tx.pending.exists(), + "R778_UNVERIFIED_PROTECTED_BACKUP_SLOT_CREATED") + + def test_01_valid_no_swap_stage_is_unmodified(self): + self.assertEqual(self.case.tx.stage(self.case.pkg)["status"],"STAGED") + self.case.all_old() + self.assertTrue(self.case.tx.pending.is_dir()) + + def test_02_old_protected_file_swapped_to_symlink_after_baseline(self): + row=next(x for x in self.case.files if x[3]) + target=self.case.root/row[2] + calls,_=self._swap_after_baseline(target,b"ATTACKER_CONTROLLED_UNPINNED_OLD_BYTES") + self._must_reject_before_pending(calls) + + def test_03_root_metadata_swapped_after_baseline(self): + meta=self.case.meta + attacker=dict(self.case.orig,home="/home/attacker") + data=(json.dumps(attacker,sort_keys=True)+"\n").encode() + calls,_=self._swap_after_baseline(meta,data) + self._must_reject_before_pending(calls) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r779_root_guard_source_copy_identity_contract.py b/tests/r779_root_guard_source_copy_identity_contract.py new file mode 100644 index 00000000..eb491bb7 --- /dev/null +++ b/tests/r779_root_guard_source_copy_identity_contract.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""R779 Rootless negative-first source-to-root-guard byte identity regression. + +Only disposable user-owned fixture paths. Never calls pkexec, systemctl, Root +CLI, or host networking. The guard functions are imported, not invoked as CLI. +""" +from __future__ import annotations +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import pathlib +import shutil +import tempfile +import unittest +from unittest.mock import patch + +ROOT=pathlib.Path(__file__).resolve().parents[1] +SRC=ROOT/"linux/system/upgrade_guard.py" +spec=importlib.util.spec_from_file_location("r779_guard",SRC) +g=importlib.util.module_from_spec(spec) +spec.loader.exec_module(g) + +class GuardSourceIdentity(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="r779-owned-",dir=str(ROOT)) + self.base=pathlib.Path(self.tmp.name) + self.src=self.base/"DirectInternetMethod_1.5.7_Linux_x86_64" + (self.src/"system").mkdir(parents=True) + (self.src/"app").mkdir() + self.root_tx=self.src/"system/protected_upgrade_transaction.py" + self.user_tx=self.src/"app/user_update_transaction.py" + shutil.copyfile(ROOT/"linux/system/protected_upgrade_transaction.py",self.root_tx) + shutil.copyfile(ROOT/"linux/app/user_update_transaction.py",self.user_tx) + self.assertEqual(hashlib.sha256(self.root_tx.read_bytes()).hexdigest().upper(),g.PIN_ROOT) + self.assertEqual(hashlib.sha256(self.user_tx.read_bytes()).hexdigest().upper(),g.PIN_USER) + rows=[{"file":"system/protected_upgrade_transaction.py","sha256":g.PIN_ROOT}, + {"file":"app/user_update_transaction.py","sha256":g.PIN_USER}] + rows.extend({"file":f"unrelated-{i:02d}","sha256":"0"*64} for i in range(26)) + (self.src/"PACKAGE_MANIFEST.json").write_text( + json.dumps({"schema":1,"version":g.VERSION,"files":rows})+"\n") + self.guard_dir=self.base/"guard" + self.home=self.base/"home" + self.home.mkdir() + self.um=self.home/"INSTALL.json" + self.um.write_text('{"version":"1.5.4"}\n') + self.rm=self.base/"root-install.json" + self.rm.write_text('{"version":"1.5.2"}\n') + self.write_calls=[] + + def tearDown(self): + self.tmp.cleanup() + + def fake_write_new(self,path,data,mode): + path=pathlib.Path(path) + path.parent.mkdir(exist_ok=True,parents=True) + self.assertFalse(path.exists(),"No writes to existing fixture files") + path.write_bytes(data) + path.chmod(mode) + self.write_calls.append(str(path)) + + def arm_fixture_without_privileges(self): + with patch.object(g,"GUARD",self.guard_dir), \ + patch.object(g,"ROOT_TX",self.guard_dir/"root_tx.py"), \ + patch.object(g,"USER_TX",self.guard_dir/"user_tx.py"), \ + patch.object(g,"WORKER",self.guard_dir/"worker.py"), \ + patch.object(g,"BASELINE_FILE",self.guard_dir/"baseline.json"), \ + patch.object(g,"write_new",side_effect=self.fake_write_new), \ + patch.object(g.os,"chown",return_value=None): + return g.guard_files(self.src,1000,"fixture",self.home,self.um,self.rm) + + def test_01_correct_pinned_source_can_be_staged(self): + self.assertTrue(g.validate_source(self.src)) + doc=self.arm_fixture_without_privileges() + self.assertEqual(doc["rootTransactionSha"],g.PIN_ROOT) + self.assertEqual((self.guard_dir/"root_tx.py").read_bytes(),self.root_tx.read_bytes()) + self.assertEqual((self.guard_dir/"user_tx.py").read_bytes(),self.user_tx.read_bytes()) + self.assertEqual(len(self.write_calls),4) + + def test_02_changed_root_script_after_validation_never_installed_in_guard(self): + self.assertTrue(g.validate_source(self.src)) + self.root_tx.write_bytes(b"print('UNTRUSTED_ROOT_CODE')\n") + with self.assertRaises(RuntimeError): + self.arm_fixture_without_privileges() + self.assertFalse(self.guard_dir.exists(),"Changed root source must fail before guard creation") + + def test_03_changed_user_script_after_validation_never_installed_in_guard(self): + self.assertTrue(g.validate_source(self.src)) + self.user_tx.write_bytes(b"print('UNTRUSTED_USER_CODE')\n") + with self.assertRaises(RuntimeError): + self.arm_fixture_without_privileges() + self.assertFalse(self.guard_dir.exists(),"Changed user source must fail before guard creation") + + def test_04_fifo_source_never_blocks_root_before_sha_check(self): + self.root_tx.unlink() + os.mkfifo(self.root_tx,0o600) + code=""" +import importlib.util,sys +spec=importlib.util.spec_from_file_location("r779_child",sys.argv[1]) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +try: + m.pinned_package_bytes(__import__("pathlib").Path(sys.argv[2]), + "system/protected_upgrade_transaction.py",m.PIN_ROOT) +except RuntimeError: + sys.exit(0) +sys.exit(44) +""" + child=subprocess.run( + [sys.executable,"-B","-c",code,str(SRC),str(self.src)], + capture_output=True,text=True,timeout=3) + self.assertEqual(child.returncode,0,(child.stdout,child.stderr)) + + def test_05_parent_directory_symlink_cannot_redirect_pinned_source(self): + alias=self.base/"source-alias" + alias.symlink_to(self.src,target_is_directory=True) + with self.assertRaisesRegex(RuntimeError,"PINNED_SOURCE_OPEN"): + g.pinned_package_bytes(alias,"system/protected_upgrade_transaction.py",g.PIN_ROOT) + + def test_06_privileged_wrapper_executes_verified_root_owned_code_only(self): + script=(ROOT/"linux/system/upgrade_system.sh").read_text() + self.assertNotIn('python3 "$SRC/system/upgrade_guard.py" arm',script) + self.assertNotIn('python3 "$SRC/system/protected_upgrade_transaction.py" stage',script) + self.assertIn('mktemp -d /run/directinternetmethod-guard-XXXXXXXX',script) + self.assertIn('install -m 0500 -- "$SRC/system/upgrade_guard.py"',script) + self.assertIn('sha256sum "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py"',script) + launch='python3 -B "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py" arm' + self.assertIn(launch,script) + self.assertLess(script.index('sha256sum "$GUARD_BOOTSTRAP_DIR/upgrade_guard.py"'), + script.index(launch)) + self.assertIn('GUARD_ROOT_TX="/var/lib/directinternetmethod/upgrade-guard/root_tx.py"',script) + for verb in ("stage","apply","status"): + self.assertIn('python3 -B "$GUARD_ROOT_TX" '+verb,script) + + def test_07_installer_rollback_uses_immutable_owned_root_transaction(self): + src=(ROOT/"linux/install.sh").read_text() + self.assertNotIn('ROOT_TRANSACTION="$HERE/system/protected_upgrade_transaction.py"',src) + self.assertIn('ROOT_TRANSACTION="/var/lib/directinternetmethod/upgrade-guard/root_tx.py"',src) + self.assertIn('pkexec /usr/bin/python3 "$ROOT_TRANSACTION" rollback',src) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r781_privileged_entry_contract.py b/tests/r781_privileged_entry_contract.py new file mode 100644 index 00000000..fe69d22c --- /dev/null +++ b/tests/r781_privileged_entry_contract.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""R781 rootless negative-first trust bootstrap contracts; no Root or GUI use.""" +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +ROOT=Path(__file__).resolve().parents[1] +SRC=ROOT/"tools/security/privileged_entry.py" +SPEC=importlib.util.spec_from_file_location("dim_r781",SRC) +m=importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(m) +PKG="DirectInternetMethod_1.5.7_Linux_x86_64" + +class RootlessTrustedEntry(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="r781-only-user-",dir=str(ROOT)) + self.base=Path(self.tmp.name) + self.src=self.base/"source"/PKG + self.src.parent.mkdir() + with zipfile.ZipFile(ROOT/"delivery"/(PKG+".zip")) as z: + names=z.namelist() + self.assertEqual(len(names),30) + for name in names: + self.assertTrue(name.startswith(PKG+"/")) + self.assertNotIn("..",Path(name).parts) + z.extractall(self.src.parent) + self.private=self.base/"private" + self.private.mkdir(mode=0o700) + self.user=os.geteuid() + self.assertNotEqual(self.user,0,"Do not run this rootless suite as Root.") + + def tearDown(self): + self.tmp.cleanup() + + def candidate(self): + return m.freeze_package(self.src,self.private,self.user) + + def test_01_valid_unchanged_exact_manifest(self): + fd=m.open_pinned_directory(self.src) + try: + data, rows=m.package_inventory(fd) + self.assertEqual(m.sha(data),m.MANIFEST_SHA256) + self.assertEqual(len(rows),28) + finally: + os.close(fd) + + def test_02_integrity_frozen_canonical_archive(self): + actual=self.candidate() + self.assertEqual(actual.name,PKG) + self.assertTrue(actual.is_dir()) + self.assertEqual(actual.stat().st_mode&0o777,0o500) + for path in ("system/upgrade_system.sh","system/install_system.sh", + "system/upgrade_guard.py","system/protected_upgrade_transaction.py", + "app/user_update_transaction.py","runtime/ctrld"): + before=self.src/path + after=actual/path + self.assertEqual(hashlib.sha256(before.read_bytes()).hexdigest(), + hashlib.sha256(after.read_bytes()).hexdigest()) + self.assertEqual(len([x for x in actual.rglob("*") if x.is_file()]),30) + + def test_03_modified_manifest_rejected(self): + p=self.src/"PACKAGE_MANIFEST.json" + p.write_bytes(p.read_bytes()+b" ") + with self.assertRaisesRegex(m.AdmissionError,"TRUSTED_MANIFEST_DIGEST"): + self.candidate() + self.assertFalse((self.private/PKG).exists()) + + def test_04_modified_payload_does_not_stage_valid_version(self): + p=self.src/"system/upgrade_system.sh" + p.write_bytes(b"print('untrusted')\n") + with self.assertRaisesRegex(m.AdmissionError,"PINNED_PAYLOAD_SHA"): + self.candidate() + + def test_05_manifest_duplicate_key_rejected(self): + with self.assertRaisesRegex(m.AdmissionError,"DUPLICATE_JSON_FIELD"): + m.strict_json(b'{"schema":1,"schema":1}') + + def test_06_linked_parent_is_not_followed(self): + alias=self.base/"bad_symlink" + alias.symlink_to(self.src.parent,target_is_directory=True) + with self.assertRaisesRegex(m.AdmissionError,"SOURCE_DIR_NOFOLLOW"): + m.freeze_package(alias/PKG,self.private,self.user) + + def test_07_payload_symlink_never_followed(self): + p=self.src/"system/upgrade_system.sh" + p.unlink();p.symlink_to(self.src/"system/install_system.sh") + with self.assertRaisesRegex(m.AdmissionError,"MEMBER_FILE_NOFOLLOW"): + self.candidate() + + def test_08_payload_fifo_never_blocks(self): + p=self.src/"system/upgrade_system.sh" + p.unlink();os.mkfifo(p,0o600) + program=""" +import importlib.util,sys,os +sp=importlib.util.spec_from_file_location("tested",sys.argv[1]) +m=importlib.util.module_from_spec(sp);sp.loader.exec_module(m) +fd=m.open_pinned_directory(__import__('pathlib').Path(sys.argv[2])) +try: + m.read_pinned(fd,"system/upgrade_system.sh",100000) +except m.AdmissionError: + sys.exit(0) +finally: + os.close(fd) +sys.exit(99) +""" + r=subprocess.run([sys.executable,"-B","-c",program,str(SRC),str(self.src)], + capture_output=True,text=True,timeout=4) + self.assertEqual(r.returncode,0,r.stderr) + + def test_09_midstage_replacement_detected(self): + original=m.package_inventory + p=self.src/"system/upgrade_system.sh" + def swap(fd): + info=original(fd) + p.rename(p.with_name(p.name+".pinned")) + p.symlink_to(self.src/"system/install_system.sh") + return info + with patch.object(m,"package_inventory",side_effect=swap): + with self.assertRaisesRegex(m.AdmissionError,"MEMBER_FILE_NOFOLLOW"): + self.candidate() + + def test_10_untrusted_cli_without_root_refuses(self): + r=subprocess.run([sys.executable,"-B",str(SRC),"--action","upgrade", + "--source",str(self.src)], + capture_output=True,text=True,timeout=4) + self.assertEqual(r.returncode,86,(r.stdout,r.stderr)) + self.assertIn("ROOT_REQUIRED",r.stderr) + self.assertFalse((self.private/PKG).exists()) + + def test_12_no_pkexec_user_owned_code_and_missing_anchor_fails_closed(self): + installer=(ROOT/"linux/install.sh").read_text() + self.assertNotIn('pkexec /bin/bash "$HERE/system/',installer) + self.assertEqual(installer.count( + 'pkexec /usr/bin/python3 -I "$TRUSTED_BOOTSTRAP"'),2) + self.assertIn('R781_TRUSTED_BOOTSTRAP_NOT_PROVISIONED',installer) + self.assertIn('trusted_bootstrap_ready()',installer) + self.assertIn('rollback_failed_install 87',installer) + self.assertIn('TRUSTED_BOOTSTRAP="/usr/local/libexec/directinternetmethod/privileged_entry.py"',installer) + + def test_14_fresh_install_anchor_does_not_create_primary_backend(self): + installer=(ROOT/"linux/install.sh").read_text() + anchor="/usr/local/libexec/directinternetmethod/privileged_entry.py" + self.assertIn('TRUSTED_BOOTSTRAP="'+anchor+'"',installer) + self.assertNotIn('TRUSTED_BOOTSTRAP="/usr/lib/directinternetmethod/',installer) + self.assertNotIn("pkexec /bin/bash",installer) + self.assertIn('[ ! -e /usr/lib/directinternetmethod ]',installer) + self.assertIn("--action install --source",installer) + self.assertIn("--action upgrade --source",installer) + helper=(ROOT/"tools/security/privileged_entry.py").read_text() + self.assertIn('TRUSTED_ENTRY = Path("'+anchor+'")',helper) + self.assertIn('Path("/usr/local/libexec/directinternetmethod")',helper) + + def test_13_missing_root_provisioning_is_not_emulated(self): + # A root-owned helper may legitimately be present on a CI host. + # This user-only test verifies non-root invocation fails regardless. + with self.assertRaisesRegex(m.AdmissionError,"ROOT_REQUIRED"): + m.check_trusted_entry() + + def test_11_source_from_other_uid_rejected(self): + with self.assertRaisesRegex(m.AdmissionError,"PACKAGE_SOURCE_OWNER"): + m.freeze_package(self.src,self.private,self.user+1) + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r782_hosted_trusted_entry_ephemeral.py b/tests/r782_hosted_trusted_entry_ephemeral.py new file mode 100644 index 00000000..a76afa64 --- /dev/null +++ b/tests/r782_hosted_trusted_entry_ephemeral.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""R782 real-UID0/bootstrap content test on disposable original GitHub runner ONLY. + +Never execute product install/upgrade/rollback, pkexec, systemctl, DNS or network +commands. PKEXEC_UID is simulated solely for ownership validation: this is +NOT native Polkit authentication acceptance. +""" +from __future__ import annotations +import hashlib +import importlib.util +import os +from pathlib import Path +import pwd +import shutil +import stat +import subprocess +import sys +import zipfile + +BASE = Path(__file__).resolve().parents[1] +PIN_ENTRY = "D0181C15C54B90A3B6E03FBFBE32148341037AB60CF4D6788B6A72140304340F" +PIN_ZIP = "A8356107C5DE6529E8270D1EE404A7D8A629040BD0D17F3F83C61C390790655D" +ENTRY = Path("/usr/local/libexec/directinternetmethod/privileged_entry.py") +SOURCE = BASE / "tools/security/privileged_entry.py" +PKG = "DirectInternetMethod_1.5.6_Linux_x86_64" +ARCHIVE = BASE / "delivery" / (PKG + ".zip") +# This is a disposable original-repository GitHub VM, not an arbitrary path +# derived from RUNNER_TEMP or another user-provided environment variable. +EXPECTED_WORKSPACE = Path("/home/runner/work/DirectInternetMethod/DirectInternetMethod") +OWNED_SCRATCH = Path("/run/dim-r782-one-time-trusted-root-fixture") + +def need(yes: bool, code: str): + if not yes: + raise RuntimeError("R782_FAIL_CLOSED_" + code) + +def sha_file(p: Path): + return hashlib.sha256(p.read_bytes()).hexdigest().upper() + +def guard(): + e = os.environ + need(sys.platform == "linux" and os.geteuid() == 0, "REAL_ROOT_REQUIRED") + need(e.get("CI") == "true" and e.get("GITHUB_ACTIONS") == "true" and + e.get("GITHUB_REPOSITORY") == "GOD13emad/DirectInternetMethod" and + e.get("RUNNER_ENVIRONMENT") == "github-hosted", "ORIGINAL_HOSTED_ONLY") + need(e.get("GITHUB_REF") == "refs/heads/prep/r803-forward-156-native-gates" and + e.get("GITHUB_SHA") and len(e["GITHUB_SHA"]) == 40, + "REVIEW_ONLY_BRANCH_AND_EXACT_COMMIT") + need(e.get("GITHUB_WORKSPACE") == str(EXPECTED_WORKSPACE) and + BASE == EXPECTED_WORKSPACE and BASE.is_dir(), + "WORKSPACE_NOT_OWNED") + commit = subprocess.run(["/usr/bin/git", "-C", str(BASE), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip() + need(commit == e["GITHUB_SHA"], "CHECKED_OUT_COMMIT_DIFFERENT") + need(Path("/run").is_dir() and Path("/run").lstat().st_uid == 0 and + not (Path("/run").lstat().st_mode & 0o022), "ROOT_RUN_PARENT") + need(not OWNED_SCRATCH.exists() and not OWNED_SCRATCH.is_symlink(), + "NO_FOREIGN_SCRATCH") + temp = OWNED_SCRATCH + runner = pwd.getpwnam("runner") + need(runner.pw_uid > 0 and runner.pw_dir == "/home/runner", "REAL_HOSTED_RUNNER") + need(not ENTRY.exists() and not ENTRY.is_symlink(), "FOREIGN_ENTRY_PRESENT") + need(not ENTRY.parent.exists() and not ENTRY.parent.is_symlink(), + "FOREIGN_TRUST_DIRECTORY") + for candidate in ("/usr/lib/directinternetmethod", + "/var/lib/directinternetmethod/install.json", + "/var/lib/directinternetmethod/upgrade-guard"): + need(not Path(candidate).exists(), "NOT_DISPOSABLE_FRESH_BACKEND") + need(SOURCE.is_file() and ARCHIVE.is_file() and + sha_file(SOURCE) == PIN_ENTRY and sha_file(ARCHIVE) == PIN_ZIP, + "PINNED_QA_SOURCE_DIFFERENT") + return temp, runner.pw_uid + +def trusted_provision(): + # Only on a guarded disposable GitHub VM, not a personal Linux host. + upper = ENTRY.parent.parent + created_upper = False + if not upper.exists(): + upper.mkdir(mode=0o755) + created_upper = True + for part in (Path("/usr"), Path("/usr/local"), upper): + st = part.lstat() + need(stat.S_ISDIR(st.st_mode) and st.st_uid == 0 and st.st_gid == 0 and + not (st.st_mode & 0o022), "UNTRUSTED_HOSTED_ANCESTOR") + ENTRY.parent.mkdir(mode=0o755) + fd = os.open(ENTRY, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o500) + with os.fdopen(fd, "wb") as stream: + stream.write(SOURCE.read_bytes()) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), 0o500) + st = ENTRY.lstat() + need(stat.S_ISREG(st.st_mode) and st.st_uid == 0 and st.st_gid == 0 and + st.st_nlink == 1 and stat.S_IMODE(st.st_mode) == 0o500 and + sha_file(ENTRY) == PIN_ENTRY, "NATIVE_OWNED_EXECUTABLE_IDENTITY") + return created_upper + +def package_fixture(temp: Path, uid: int): + d = temp / "dim-r782-exact-reviewed-fixture" + need(not d.exists() and not d.is_symlink(), "EXISTING_TEMP_FIXTURE") + d.mkdir(mode=0o700) + with zipfile.ZipFile(ARCHIVE) as z: + records = z.infolist() + need(len(records) == 30, "ARCHIVE_INVENTORY") + for item in records: + parts = item.filename.split("/") + need(parts[0] == PKG and ".." not in parts and + not stat.S_ISLNK(item.external_attr >> 16), "ARCHIVE_MEMBER_UNSAFE") + z.extractall(d) + package = d / PKG + for path in package.rglob("*"): + os.chown(path, uid, -1) + os.chown(package, uid, -1) + need(package.stat().st_uid == uid, "REAL_PACKAGE_USER_OWNER") + return d, package + +def native_root_snapshot(helper, package: Path, uid: int, temp: Path): + owned = temp / "dim-r782-frozen-root-only" + need(not owned.exists() and not owned.is_symlink(), "FOREIGN_ROOT_STAGING") + owned.mkdir(mode=0o700) + try: + freeze = helper.freeze_package(package, owned, uid) + need(freeze.stat().st_uid == uid and + stat.S_IMODE(freeze.stat().st_mode) == 0o500, "STAGED_LEAF_IDENTITY") + for name in ("system/upgrade_system.sh", "system/install_system.sh", + "system/upgrade_guard.py", "app/user_update_transaction.py"): + path = freeze / name + need(path.stat().st_uid == 0 and sha_file(path) == sha_file(package / name), + "STAGED_PRIVILEGED_CODE_BYTE_MISMATCH") + print("R782_ROOT_OWNED_PINNED_STAGING_PASS_NO_INSTALL") + finally: + shutil.rmtree(owned) + +def reject_drifted_manifest(package: Path, uid: int): + manifest = package / "PACKAGE_MANIFEST.json" + manifest.write_bytes(manifest.read_bytes() + b"\n") + old_dirs = set(Path("/run").glob("dim-trusted-root-*")) + env = {"PKEXEC_UID": str(uid), "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "HOME": "/root", "LANG": "C", "PYTHONDONTWRITEBYTECODE": "1"} + outcome = subprocess.run(["/usr/bin/python3", "-I", str(ENTRY), + "--action", "upgrade", "--source", str(package)], + env=env, cwd="/", text=True, capture_output=True, + timeout=10, check=False) + need(outcome.returncode == 86 and + "R781_REJECT_TRUSTED_MANIFEST_DIGEST" in outcome.stderr, + "SIMULATED_AUTH_CORRUPTED_MANIFEST_FAILED_OPEN") + need(set(Path("/run").glob("dim-trusted-root-*")) == old_dirs, + "TEMP_ROOT_DIR_LEAKED") + need(not Path("/var/lib/directinternetmethod/install.json").exists(), + "REAL_BACKEND_TOUCHED") + print("R782_REAL_ROOT_ENTRY_CORRUPTED_MANIFEST_FAIL_CLOSED") + +def main(): + temp, uid = guard() + scratch = None + created_upper = False + parent_created = False + private_created = False + try: + temp.mkdir(mode=0o700) + private_created = True + st = temp.lstat() + need(st.st_uid == 0 and stat.S_IMODE(st.st_mode) == 0o700 and + stat.S_ISDIR(st.st_mode), "OWNED_SCRATCH_IDENTITY") + created_upper = trusted_provision() + parent_created = True + os.environ["PKEXEC_UID"] = str(uid) # SIMULATION; no native auth conclusion + spec = importlib.util.spec_from_file_location("reviewed_root_entry", ENTRY) + assert spec and spec.loader + helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(helper) + helper.check_trusted_entry() + scratch, package = package_fixture(temp, uid) + native_root_snapshot(helper, package, uid, temp) + reject_drifted_manifest(package, uid) + print("R782_EPHEMERAL_ROOT_OWNERSHIP_AND_MANIFEST_GATE_PASS") + finally: + if scratch is not None: + need(scratch.parent == temp and scratch.name == "dim-r782-exact-reviewed-fixture", + "SCRATCH_SCOPE") + shutil.rmtree(scratch) + if parent_created: + need(ENTRY.is_file() and ENTRY.lstat().st_uid == 0 and + sha_file(ENTRY) == PIN_ENTRY, "TRUSTED_ENTRY_CLEANUP_IDENTITY") + ENTRY.unlink() + ENTRY.parent.rmdir() + if created_upper: + ENTRY.parent.parent.rmdir() + if private_created: + st = temp.lstat() + need(temp == OWNED_SCRATCH and st.st_uid == 0 and + stat.S_ISDIR(st.st_mode) and stat.S_IMODE(st.st_mode) == 0o700, + "CLEANUP_EXACT_OWNED_SCRATCH") + temp.rmdir() + need(not ENTRY.exists() and not temp.exists() and + not Path("/var/lib/directinternetmethod/install.json").exists(), + "POST_CLEANUP_NO_PRODUCT_MUTATION") + +if __name__ == "__main__": + main() diff --git a/tests/r789_receipt_replay_synthetic_probe.py b/tests/r789_receipt_replay_synthetic_probe.py new file mode 100644 index 00000000..1b9d0d4e --- /dev/null +++ b/tests/r789_receipt_replay_synthetic_probe.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Defensive read-only-source regression: acceptance receipt replay across synthetic upgrade attempts. + +Only R519 FakeFixture disposable user-owned roots; NEVER invokes pkexec, +real RootTransaction CLI, installed services or network. Evidence only. +""" +import importlib.util +import json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location("r789_fixture",ROOT/"tests/protected_upgrade_transaction_contract.py") +fixture=importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixture) + +def staged_fixture(): + obj=fixture.FakeFixture("test_success_stage_apply_rollback") + obj.setUp() + assert obj.tx.stage(obj.pkg)["status"]=="STAGED" + assert obj.tx.apply()["status"]=="APPLIED" + return obj + +one=staged_fixture() +try: + one_m=one.tx.manifest() + prior={ + "revision":"R519", + "status":"PASS_NATIVE_ROOT_UPGRADE", + "sourceRootSHA":fixture.m.sha(fixture.m.canonical(one_m["files"])), + "protectedMetadataSHA":one_m["newMetadataSHA"], + "transactionNonce":one_m["transactionNonce"], + "nativeStartStopRecovery":True, + "tailscalePreserved":True, + "physicalDNSPreserved":True + } + first_file=one.root/"first_attempt_receipt.json" + first_file.write_text(json.dumps(prior)+"\n") + assert one.tx.commit(first_file)["status"]=="COMMITTED" +finally: + one.tearDown() + +two=staged_fixture() +try: + two_m=two.tx.manifest() + assert prior["sourceRootSHA"]==fixture.m.sha(fixture.m.canonical(two_m["files"])) + assert prior["protectedMetadataSHA"]==two_m["newMetadataSHA"] + replay=two.root/"STALE_previous_attempt.json" + replay.write_text(json.dumps(prior)+"\n") + # No fresh acceptance exists yet. Reusing an older success receipt + # from an identical baseline MUST be rejected. + assert prior["transactionNonce"] != two_m["transactionNonce"] + try: + two.tx.commit(replay) + except RuntimeError as exc: + assert "FAIL_CLOSED_NATIVE_ACCEPTANCE_NOT_PROVEN" in str(exc),str(exc) + else: + raise AssertionError("R789_STALE_ACCEPTANCE_REPLAYED") + assert two.tx.status()["status"] == "APPLIED" + # A genuine fresh transaction-specific receipt still permits commit. + fresh=dict(prior,transactionNonce=two_m["transactionNonce"]) + fresh_file=two.root/"current_attempt_receipt.json" + fresh_file.write_text(json.dumps(fresh)+"\n") + outcome=two.tx.commit(fresh_file) + assert outcome["status"] == "COMMITTED" + print(json.dumps({ + "schema":1,"scenario":"two_identical_synthetic_upgrade_attempts", + "stale_receipt_rejected":True, + "fresh_receipt_committed":True, + "scope":"FAKE_ROOT_ONLY; native owner/mode and Polkit not tested" + },sort_keys=True)) +finally: + two.tearDown() diff --git a/tests/r795_leaf_filename_contract.py b/tests/r795_leaf_filename_contract.py new file mode 100644 index 00000000..2d9c8494 --- /dev/null +++ b/tests/r795_leaf_filename_contract.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""R795 lexical input guard for pinned file leaf. Isolated, unprivileged, no network.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sp=importlib.util.spec_from_file_location("dim_r795",ROOT/"linux/system/protected_upgrade_transaction.py") +m=importlib.util.module_from_spec(sp) +sp.loader.exec_module(m) + +class FilenameContract(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix="dim-r795-user-",dir=str(ROOT)) + self.d=Path(self.tmp.name) + def tearDown(self): + self.tmp.cleanup() + def test_normal_known_name_still_opened(self): + p=self.d/"protected_install.json" + p.write_bytes(b"source identity") + with m.verified_regular_fd(p) as (fd,stat_result): + self.assertEqual(stat_result.st_size,15) + import os + self.assertEqual(os.read(fd,15),b"source identity") + def test_hostile_or_ambiguous_leaf_rejected(self): + for name in ("hidden file", ".private", "contains\nnewline", + "semicolon;name", "dollar$name", "wild*name", + "other\\slash", "unicodé", "a"*129): + with self.subTest(name=repr(name)): + p=self.d/name + p.write_bytes(b"foreign file") + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_FILE_COMPONENT"): + with m.verified_regular_fd(p): + self.fail("UNSAFE_LEAF_OPENED") + self.assertEqual(p.read_bytes(),b"foreign file") + def test_regex_valid_but_non_allowlisted_name_rejected(self): + p=self.d/"unexpected-but-valid-name.txt" + p.write_bytes(b"foreign contents") + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_FILE_COMPONENT"): + with m.verified_regular_fd(p): + self.fail("UNALLOWLISTED_FILE_OPENED") + self.assertEqual(p.read_bytes(),b"foreign contents") + def test_symlink_parent_still_rejected(self): + t=self.d/"actual" + t.mkdir() + p=t/"allowed_file" + p.write_bytes(b"okay") + link=self.d/"linkdir" + link.symlink_to(t,target_is_directory=True) + with self.assertRaisesRegex(RuntimeError,"FAIL_CLOSED_UNSAFE_PARENT_COMPONENT"): + with m.verified_regular_fd(link/"allowed_file"): + self.fail("SYMLINK_PARENT_FOLLOWED") + +if __name__=="__main__": + unittest.main(verbosity=2) diff --git a/tests/r803_dual_baseline_forward_contract.py b/tests/r803_dual_baseline_forward_contract.py new file mode 100644 index 00000000..f0068688 --- /dev/null +++ b/tests/r803_dual_baseline_forward_contract.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""R803: negative-first future Stable 1.5.6 migration from BOTH accepted Linux roots. + +Only disposable user-owned fake-root trees. Never runs pkexec, native service, +installed backend, DNS, Tailscale, package installer or public publication. +""" +import importlib.util +import json +import os +import pathlib +import unittest +from unittest.mock import patch + +HERE=pathlib.Path(__file__).resolve().parents[1] +old=HERE/"tests/protected_upgrade_transaction_contract.py" +spec=importlib.util.spec_from_file_location("r803_existing_fake_fixture",old) +assert spec and spec.loader +oldtests=importlib.util.module_from_spec(spec) +spec.loader.exec_module(oldtests) +m=oldtests.m + +class DualBaselineForwardMigration(oldtests.FakeFixture): + def setUp(self): + super().setUp() + self.patch_target=patch.object(m,"TARGET_VERSION","1.5.6") + self.patch_target.start() + self.addCleanup(self.patch_target.stop) + def rc1_as_current_backend(self): + for name,rel,target,old_sha,new_sha,mode in m.FILES: + dest=self.root/target + dest.parent.mkdir(parents=True,exist_ok=True) + dest.write_bytes((self.pkg/rel).read_bytes()) + dest.chmod(mode) + self.assertEqual(m.hash_file(dest),new_sha) + meta=dict(self.orig,version="1.5.5-rc1") + self.meta.write_text(json.dumps(meta,sort_keys=True)+"\n") + self.meta.chmod(0o644) + def test_public_old_root_to_stable_then_rollback(self): + initial=self.root_sha() + result=self.tx.stage(self.pkg) + self.assertEqual(result["status"],"STAGED") + self.assertEqual(self.tx.manifest()["fromVersion"],"1.5.2") + self.assertEqual(self.tx.manifest()["toVersion"],"1.5.6") + self.assertEqual(self.tx.apply()["status"],"APPLIED") + self.assertEqual(self.tx.rollback()["status"],"ROLLED_BACK") + self.assertEqual(self.root_sha(),initial) + self.assertEqual(json.loads(self.meta.read_text())["version"],"1.5.2") + def test_private_rc1_root_to_stable_then_rollback(self): + self.rc1_as_current_backend() + initial=self.root_sha() + self.assertEqual(len(initial),4) + self.assertEqual(self.tx.stage(self.pkg)["status"],"STAGED") + doc=self.tx.manifest() + self.assertEqual(doc["fromVersion"],"1.5.5-rc1") + self.assertEqual(doc["toVersion"],"1.5.6") + for name,rel,target,old_sha,new_sha,mode in m.FILES: + self.assertEqual(doc["files"][name]["old"],new_sha) + self.assertEqual(self.tx.apply()["status"],"APPLIED") + self.assertEqual(json.loads(self.meta.read_text())["version"],"1.5.6") + self.assertEqual(self.tx.rollback()["status"],"ROLLED_BACK") + self.assertEqual(json.loads(self.meta.read_text())["version"],"1.5.5-rc1") + self.assertEqual(self.root_sha(),initial) + def test_private_rc1_root_tampered_source_fails_before_slot(self): + self.rc1_as_current_backend() + p=self.root/m.FILES[0][2] + p.write_bytes(b"tampered previously installed backend") + with self.assertRaisesRegex(RuntimeError,"OLD_FILE_SHA|BASELINE"): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + def test_unknown_old_root_version_rejected(self): + data=dict(self.orig,version="1.5.3") + self.meta.write_text(json.dumps(data)+"\n") + with self.assertRaisesRegex(RuntimeError,"OLD_ROOT_VERSION"): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + +if __name__=="__main__": + suite=unittest.TestSuite(unittest.defaultTestLoader.loadTestsFromName("DualBaselineForwardMigration.test_"+x,__name__) for x in ()) + suite=unittest.TestSuite([ + DualBaselineForwardMigration("test_public_old_root_to_stable_then_rollback"), + DualBaselineForwardMigration("test_private_rc1_root_to_stable_then_rollback"), + DualBaselineForwardMigration("test_private_rc1_root_tampered_source_fails_before_slot"), + DualBaselineForwardMigration("test_unknown_old_root_version_rejected") + ]) + r=unittest.TextTestRunner(verbosity=2).run(suite) + print("R803_DUAL_BASELINE_FORWARD_CONTRACT",r.testsRun,"PASS" if r.wasSuccessful() else "FAIL") + raise SystemExit(0 if r.wasSuccessful() else 1) diff --git a/tests/r803_user_from_rc1_forward_contract.py b/tests/r803_user_from_rc1_forward_contract.py new file mode 100644 index 00000000..c10a35fb --- /dev/null +++ b/tests/r803_user_from_rc1_forward_contract.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""R803: real user transaction from accepted private 1.5.5-rc1 to 1.5.7. + +Strictly user-owned temporary HOME; no physical install, pkexec, network, +systemd, DNS, Tailscale, external browser, or release publication. +""" +from pathlib import Path +from tempfile import TemporaryDirectory +import hashlib,json,os,subprocess,sys +ROOT=Path(__file__).resolve().parents[1] +TX=ROOT/"linux/app/user_update_transaction.py" +PACKAGE_SOURCE=ROOT/"linux" +KNOWN=("direct_internet_method.py","socket_fallback.py","router_gateway.py", + "router_gateway_core.py","direct-internet-method.svg","uninstall.sh", + "user_uninstall_preserve.py","user_update_transaction.py") +MUTABLE=("settings.json","strategy.txt","scope.txt","custom-hosts.txt", + "adult-enabled.txt","adult-hosts.txt","adult-hosts.meta.json", + "encrypted-dns-optin.txt","selective-dns-optin.txt") +H=lambda b:hashlib.sha256(b).hexdigest() +def need(x,why): + if not x:raise AssertionError(why) +def invoke(home,cmd,expected=True): + env=dict(os.environ,HOME=str(home),PYTHONDONTWRITEBYTECODE="1") + args=[sys.executable,"-B",str(TX),cmd] + if cmd in ("--stage","--apply"):args+=["--source",str(PACKAGE_SOURCE)] + p=subprocess.run(args,env=env,capture_output=True,text=True,timeout=12) + need((p.returncode==0)==expected,cmd+" "+p.stderr[-350:]) + return p +def initialize(home,manage_provider): + app=home/".local/share/DirectInternetMethod";app.mkdir(parents=True) + (app/"router_gateway").mkdir() + managed={} + for i,name in enumerate(KNOWN): + raw=("EXISTING_RC1_CODE_"+str(i)+"\n").encode() + (app/name).write_bytes(raw);managed[name]=H(raw) + provider=app/"router_gateway/providers.json" + provider.write_bytes(b'{"previousProvider":"keep"}\n') + md5=H(provider.read_bytes()) if manage_provider else None + preserved={} + for i,name in enumerate(MUTABLE): + raw=("PRIVATE_SETTINGS_"+str(i)+"\n").encode() + if i%3: (app/name).write_bytes(raw);preserved[name]=H(raw) + else:preserved[name]=None + metadata={"schema":2,"product":"Direct Internet Method", + "version":"1.5.5-rc1","managedProviderSha256":md5, + "managedCodeSha256":managed,"managedLaunchersSha256":{}} + (app/"INSTALL.json").write_text(json.dumps(metadata,sort_keys=True)+"\n") + return app,metadata,preserved,H(provider.read_bytes()) +def finalize_metadata(app,source_manifest): + installed={ + "schema":2,"version":"1.5.7", + "managedProviderSha256":source_manifest["providerSha"], + "managedCodeSha256":{name:row["sha"] for name,row in source_manifest["managed"].items() + if name!="router_gateway/providers.json"}, + "managedLaunchersSha256":{} + } + (app/"INSTALL.json").write_text(json.dumps(installed,sort_keys=True)+"\n") +def verify_settings(app,preserved): + for key,expected in preserved.items(): + p=app/key + got=H(p.read_bytes()) if p.exists() else None + need(got==expected,"MUTABLE_DRIFT_"+key) +def case(label,manage_provider,commit): + with TemporaryDirectory(prefix="DIM_R803_USER_RC1_FAKE_HOME_") as td: + home=Path(td)/"home";home.mkdir() + app,prior,preserved,provider_sha=initialize(home,manage_provider) + original={n:(app/n).read_bytes() for n in KNOWN} + original_provider=(app/"router_gateway/providers.json").read_bytes() + original_meta=(app/"INSTALL.json").read_bytes() + invoke(home,"--stage") + pending=home/".local/state/DirectInternetMethod/update-pending" + manifest=json.loads((pending/"manifest.json").read_text()) + need(manifest["version"]=="1.5.7" and manifest["previousVersion"]=="1.5.5-rc1","INCORRECT_RC1_PREDECESSOR") + need(manifest["providerManaged"] is manage_provider,"PROVIDER_MANAGED_POLICY") + need(len(manifest["managed"])==(9 if manage_provider else 8),"MANAGED_CARDINALITY") + invoke(home,"--apply") + verify_settings(app,preserved) + if commit: + finalize_metadata(app,manifest) + invoke(home,"--commit") + need(json.loads((app/"INSTALL.json").read_text())["version"]=="1.5.7","RC1_FORWARD_VERSION") + for name,row in manifest["managed"].items(): + need(H((app/name).read_bytes())==row["sha"],"NEW_MANAGED_SHA_"+name) + if not manage_provider: + need((app/"router_gateway/providers.json").read_bytes()==original_provider,"CUSTOM_PROVIDER_LOST") + else: + invoke(home,"--rollback") + for name,raw in original.items(): + need((app/name).read_bytes()==raw,"OLD_RC1_SOURCE_NOT_RESTORED_"+name) + need((app/"INSTALL.json").read_bytes()==original_meta,"OLD_RC1_METADATA_NOT_RESTORED") + need((app/"router_gateway/providers.json").read_bytes()==original_provider,"OLD_PROVIDER_NOT_RESTORED") + need(not pending.exists(),"PENDING_STAGING_REMAINS") + verify_settings(app,preserved) + print("PASS_R803_USER_FROM_RC1",label,"settings",len(preserved),"managed",len(manifest["managed"])) +for m in (False,True): + for c in (False,True): + case("managedProvider="+str(m)+"/commit="+str(c),m,c) +print("R803_RC1_USER_FORWARD_AND_ROLLBACK",4,"PASS") diff --git a/tests/r805_windows_156_forward_contract.py b/tests/r805_windows_156_forward_contract.py new file mode 100644 index 00000000..202307a4 --- /dev/null +++ b/tests/r805_windows_156_forward_contract.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""R805 source-only Windows forward-release identity contract, no installation. + +Checks that public 1.5.4 stays immutable while the single future Windows and +Linux 1.5.6 private source candidate remains monotonically newer than the +already installed private Windows FileVersion 1.5.5.0. +""" +from pathlib import Path +from xml.etree import ElementTree as ET +import json,re,unittest +R=Path(__file__).resolve().parents[1] +META=json.loads((R/"windows/RELEASE.json").read_text("utf8")) +WIN=json.loads((R/"windows/manifest.json").read_text("utf8")) +PUBLIC=json.loads((R/"RELEASE.json").read_text("utf8")) +ISS=(R/"windows/installer/DirectInternetMethod.iss").read_bytes() +UI=(R/"windows/gui/MainWindow.xaml.cs").read_bytes() +WF=(R/".github/workflows/ci.yml").read_text() +EXPECTED="1.5.6" +class WindowsV156(unittest.TestCase): + def test_exact_product_versions(self): + self.assertEqual(META["version"],EXPECTED) + self.assertEqual(META["status"],"PRIVATE_WINDOWS_V156_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN") + self.assertEqual(WIN["version"],EXPECTED) + self.assertEqual(len(WIN["files"]),681) + self.assertEqual(META["previousAccepted"]["tag"],"v1.5.4") + self.assertEqual(PUBLIC["version"],"1.5.4") + self.assertIsNone(META["publication"]["publishedAt"]) + def test_windows_gui_service_assembly_and_file_versions(self): + for rel in ("windows/gui/DirectInternetMethod.csproj","windows/service/DirectInternetMethod.Service.csproj"): + tree=ET.fromstring((R/rel).read_bytes()) + def val(k):return tree.findtext(".//"+k) + self.assertEqual(val("Version"),EXPECTED,rel) + self.assertEqual(val("FileVersion"),EXPECTED+".0",rel) + self.assertEqual(val("AssemblyVersion"),EXPECTED+".0",rel) + self.assertEqual(val("TargetFramework"),"net10.0-windows") + def test_inplace_same_appid_and_numeric_forward(self): + self.assertIn(b'#define MyAppVersion "1.5.6"',ISS) + self.assertIn(b'VersionInfoVersion=1.5.6.0',ISS) + self.assertIn(b'OutputBaseFilename=DirectInternetMethod_1.5.6_Windows_Setup',ISS) + self.assertIn(b'UsePreviousAppDir=yes',ISS) + self.assertIn(b'UninstallLogMode=overwrite',ISS) + self.assertIn(b'{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}',ISS) + self.assertLess((1,5,5,0),(1,5,6,0)) + def test_hosted_native_path_preserves_actual_recovery(self): + build=(R/"tests/r535_windows_candidate_hosted_build.ps1").read_text("utf8") + native=(R/"tests/r535_windows_native_rollback.ps1").read_text("utf8") + self.assertIn("PASS_ISOLATED_HOSTED_V156_BUILD",build) + self.assertIn("PASS_HOSTED_NATIVE_154_TO_156_AND_BACK_154",native) + self.assertIn("DirectInternetMethod_1.5.6_Windows_Setup.exe",build) + self.assertIn("DirectInternetMethod_1.5.6_Windows_Setup.exe",native) + self.assertIn("IN_PLACE_USER_DATA_PRESERVED",native) + self.assertIn("NETWORK_BASELINE_EXACT",native) + self.assertIn("ORIGINAL_STABLE_BINARY_HASHES_RESTORED",native) + def test_useragent_and_original_repo_branch_guards(self): + self.assertIn(b'DirectInternetMethod/1.5.6',UI) + for name in ("tests/r535_windows_candidate_hosted_build.ps1","tests/r535_windows_native_rollback.ps1"): + raw=(R/name).read_text("utf8") + self.assertIn("GOD13emad/DirectInternetMethod",raw) + self.assertIn("prep/r805-windows-156",raw) + self.assertIn("$env:GITHUB_SHA",raw) + self.assertIn("refs/heads/prep/r805-windows-156",WF) + self.assertIn("windows-private-r535-native:",WF) + self.assertIn("startsWith(github.ref, 'refs/tags/v')",WF) + +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(WindowsV156)) + print("R805_WINDOWS_SOURCE_PRIVATE_FORWARD_TESTS",result.testsRun, + "PASS" if result.wasSuccessful() else "FAIL") + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r845_private_156_to_157_root_forward_contract.py b/tests/r845_private_156_to_157_root_forward_contract.py new file mode 100644 index 00000000..6790d190 --- /dev/null +++ b/tests/r845_private_156_to_157_root_forward_contract.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""R845: fake-root private 1.5.6 -> 1.5.7 positive and negative regression. + +No native Root, DNS, VPN, network route, polkit or actual installation. +""" +import importlib.util, json, pathlib, unittest +R=pathlib.Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location("r845_fixture",R/"tests/protected_upgrade_transaction_contract.py") +assert spec and spec.loader +base=importlib.util.module_from_spec(spec) +spec.loader.exec_module(base) +m=base.m +class Private156To157(base.FakeFixture): + def private_156(self): + for name,source,target,old,new,mode in m.FILES: + p=self.root/target + p.parent.mkdir(parents=True,exist_ok=True) + p.write_bytes((self.pkg/source).read_bytes()) + p.chmod(mode) + self.assertEqual(m.hash_file(p),new) + meta=dict(self.orig,version="1.5.6") + self.meta.write_text(json.dumps(meta,sort_keys=True)+"\n") + self.meta.chmod(0o644) + def test_forward_and_rollback(self): + self.private_156() + before=self.root_sha() + self.assertEqual(m.TARGET_VERSION,"1.5.7") + self.assertEqual(self.tx.stage(self.pkg)["status"],"STAGED") + doc=self.tx.manifest() + self.assertEqual(doc["fromVersion"],"1.5.6") + self.assertEqual(doc["toVersion"],"1.5.7") + self.assertEqual(self.tx.apply()["status"],"APPLIED") + self.assertEqual(json.loads(self.meta.read_text())["version"],"1.5.7") + self.assertEqual(self.tx.rollback()["status"],"ROLLED_BACK") + self.assertEqual(json.loads(self.meta.read_text())["version"],"1.5.6") + self.assertEqual(self.root_sha(),before) + def test_tampered_root_denied(self): + self.private_156() + p=self.root/m.FILES[0][2] + p.write_bytes(b"foreign modified root code") + with self.assertRaisesRegex(RuntimeError,"BASELINE|OLD_FILE_SHA"): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + def test_unknown_version_denied(self): + self.private_156() + meta=json.loads(self.meta.read_text()) + meta["version"]="1.5.8" + self.meta.write_text(json.dumps(meta)+"\n") + with self.assertRaisesRegex(RuntimeError,"OLD_ROOT_VERSION"): + self.tx.stage(self.pkg) + self.assertFalse(self.tx.pending.exists()) + def test_version_pair_whitelist(self): + s=(R/"linux/system/upgrade_guard.py").read_text() + self.assertIn('("1.5.6","1.5.6")',s) + sh=(R/"linux/system/upgrade_system.sh").read_text() + self.assertIn('("1.5.6","1.5.6")',sh) + self.assertNotIn('("1.5.7","1.5.7")',sh) + self.assertNotIn('("1.5.6","1.5.5-rc1")',s) +if __name__=="__main__": + tests=("test_forward_and_rollback","test_tampered_root_denied","test_unknown_version_denied","test_version_pair_whitelist") + run=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(Private156To157(t) for t in tests)) + raise SystemExit(0 if run.wasSuccessful() else 1) diff --git a/tests/r846_windows_157_forward_source_contract.py b/tests/r846_windows_157_forward_source_contract.py new file mode 100644 index 00000000..8e7b8918 --- /dev/null +++ b/tests/r846_windows_157_forward_source_contract.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""R846: proposed private 1.5.7 Windows forward-source proof, not a release.""" +import json +from pathlib import Path +from xml.etree import ElementTree +import unittest + +R=Path(__file__).resolve().parents[1] +PUB=json.loads((R/"RELEASE.json").read_text("utf-8")) +META=json.loads((R/"windows/RELEASE.json").read_text("utf-8")) +ISS=(R/"windows/installer/DirectInternetMethod.iss").read_text("utf-8") +WF=(R/".github/workflows/ci.yml").read_text("utf-8") +UI=(R/"windows/gui/MainWindow.xaml.cs").read_text("utf-8") +UP=(R/"windows/gui/UpdateClient.cs").read_text("utf-8") +SVC=(R/"windows/service/Program.cs").read_text("utf-8") + +class ForwardWindows157(unittest.TestCase): + def test_private_version_not_published(self): + self.assertEqual(PUB["version"],"1.5.4") + self.assertEqual(META["version"],"1.5.7") + self.assertEqual(META["status"],"PRIVATE_WINDOWS_V157_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN") + self.assertEqual(META["previousAccepted"]["tag"],"v1.5.4") + self.assertIsNone(META["publication"]["publishedAt"]) + def test_assembly_forward_from_real_private156(self): + for rel in ("windows/gui/DirectInternetMethod.csproj","windows/service/DirectInternetMethod.Service.csproj"): + tree=ElementTree.parse(R/rel) + for tag,expected in (("Version","1.5.7"),("FileVersion","1.5.7.0"),("AssemblyVersion","1.5.7.0"),("TargetFramework","net10.0-windows")): + self.assertEqual(tree.findtext(".//"+tag),expected,rel) + self.assertGreater((1,5,7,0),(1,5,6,0)) + def test_same_id_inplace_and_single_uninstall_log(self): + for token in ('#define MyAppVersion "1.5.7"','VersionInfoVersion=1.5.7.0', + 'OutputBaseFilename=DirectInternetMethod_1.5.7_Windows_Setup', + 'UsePreviousAppDir=yes','UninstallLogMode=overwrite', + 'AppId={{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}', + 'PrivilegesRequired=admin'): + self.assertIn(token,ISS) + def test_monotonic_updater_unweakened(self): + self.assertIn('latest <= current',UP) + self.assertIn('latest <= CurrentVersion',SVC) + self.assertIn('Version.TryParse(clean, out var latest)',UP) + self.assertIn('Version.TryParse(clean, out var latest)',SVC) + self.assertIn('DirectInternetMethod/1.5.7',UI) + def test_historic_r535_receipts_retained(self): + self.assertIn('PASS_HOSTED_NATIVE_154_TO_156_AND_BACK_154', + (R/"tests/r535_windows_native_rollback.ps1").read_text("utf-8")) + self.assertIn('PRIVATE_WINDOWS_V156_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN', + (R/"tests/r805_windows_156_forward_contract.py").read_text("utf-8")) + self.assertIn('prep/r846-forward-157-native-gates',WF) + self.assertIn('windows-private-r846-native:',WF) + self.assertIn('tests/r846_windows_candidate_hosted_build.ps1',WF) + self.assertIn('tests/r846_windows_native_rollback.ps1',WF) + for rel in ("tests/r846_windows_candidate_hosted_build.ps1","tests/r846_windows_native_rollback.ps1"): + source=(R/rel).read_text("utf-8") + self.assertIn("R846",source) + self.assertIn("refs/heads/prep/r846-forward-157-native-gates",source) + self.assertIn("GOD13emad/DirectInternetMethod",source) + self.assertIn("github-hosted",source) + self.assertNotIn("refs/heads/prep/r803-forward-156-native-gates",source) + +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(ForwardWindows157)) + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r846_windows_candidate_hosted_build.ps1 b/tests/r846_windows_candidate_hosted_build.ps1 new file mode 100644 index 00000000..cb59ac80 --- /dev/null +++ b/tests/r846_windows_candidate_hosted_build.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param() +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$Root=$env:GITHUB_WORKSPACE +if($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/DirectInternetMethod'){throw 'R846_GITHUB_HOST_ONLY'} +if($env:GITHUB_REF -cnotin @('refs/heads/prep/r846-forward-157-native-gates') -or (git -C $Root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R846_BRANCH_HEAD_AUTHORITY'} +$r=Get-Content (Join-Path $Root 'windows\RELEASE.json') -Raw|ConvertFrom-Json +if($r.version -cne '1.5.7' -or $r.status -cne 'PRIVATE_WINDOWS_V157_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN'){throw 'R846_RC1_DECLARATION'} +$qa=Join-Path $env:RUNNER_TEMP 'DIM_R846_NATIVE' +$gstage=Join-Path $qa 'gui' +$sstage=Join-Path $qa 'service' +$gt=Join-Path $Root 'windows\app\DirectInternetMethod.exe' +$st=Join-Path $Root 'windows\app\DirectInternetMethod.Service.exe' +$installer=Join-Path $Root 'delivery\DirectInternetMethod_1.5.7_Windows_Setup.exe' +foreach($p in @($qa,$gt,$st,$installer)){if(Test-Path $p){throw ('R846_UNSAFE_RERUN_'+$p)}} +New-Item -ItemType Directory -Path $qa|Out-Null +$iscc=Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' +if(!(Test-Path $iscc)){$iscc=Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe'} +if(!(Test-Path $iscc)){throw 'R846_GITHUB_IMAGE_INNO_COMPILER_ABSENT'} +Set-Location $Root +& pwsh -NoProfile -NonInteractive -File tests/prepare_powershell_runtime.ps1 +if($LASTEXITCODE -ne 0){throw 'PWSH_BUNDLE_PREP_FAILED'} +if((Get-FileHash (Join-Path $Root 'windows\vendor\pwsh\pwsh.exe') -Algorithm SHA256).Hash -cne 'BFB46AF89433268872DDB43D1CA7A3F433452EE91ED356A9786940F90118E285'){throw 'PWSH_BUNDLE_SHA'} +& dotnet publish windows/gui/DirectInternetMethod.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:ContinuousIntegrationBuild=true -o $gstage +if($LASTEXITCODE -ne 0){throw 'GUI_PUBLISH_FAILED'} +& dotnet publish windows/service/DirectInternetMethod.Service.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:ContinuousIntegrationBuild=true -o $sstage +if($LASTEXITCODE -ne 0){throw 'SERVICE_PUBLISH_FAILED'} +$gb=Join-Path $gstage 'DirectInternetMethod.exe';$sb=Join-Path $sstage 'DirectInternetMethod.Service.exe' +if(!(Test-Path $gb) -or !(Test-Path $sb)){throw 'RC1_BINARIES_MISSING'} +foreach($name in @('Start-Direct.ps1','Stop-Direct.ps1','Recovery.ps1')){Copy-Item -LiteralPath (Join-Path $Root ('windows\app\'+$name)) -Destination (Join-Path $sstage $name)} +$test=Start-Process $sb -ArgumentList '--console-selftest' -Wait -PassThru -NoNewWindow +if($test.ExitCode -ne 0){throw ('SERVICE_SELFTEST_'+$test.ExitCode)} +Copy-Item $gb $gt +Copy-Item $sb $st +& python -B tests/build_windows_manifest.py +if($LASTEXITCODE -ne 0){throw 'MANIFEST_FAILED'} +& python -B tests/dotnet10_target_contract.py +if($LASTEXITCODE -ne 0){throw 'NET10_CONTRACT_FAILED'} +& python -B tests/windows_contract_audit.py | Out-Null +if($LASTEXITCODE -ne 0){throw 'WINDOWS_AUDIT_FAILED'} +$m=Get-Content (Join-Path $Root 'windows\manifest.json') -Raw|ConvertFrom-Json +if($m.version -cne '1.5.7' -or @($m.files).Count -ne 681){throw 'RC1_MANIFEST_IDENTITY'} +& $iscc /Qp (Join-Path $Root 'windows\installer\DirectInternetMethod.iss') +if($LASTEXITCODE -ne 0){throw 'INNO_COMPILATION_FAILED'} +if(!(Test-Path $installer) -or (Get-Item $installer).Length -lt 50000000){throw 'R846_INSTALLER_MISSING'} +if([string](Get-AuthenticodeSignature $installer).Status -ne 'NotSigned'){throw 'R846_SIGNATURE_POLICY_CHANGED'} +$report=[ordered]@{ + revision='R846';stage='BUILD';status='PASS_ISOLATED_HOSTED_V157_BUILD' + head=$env:GITHUB_SHA;version='1.5.7';stable='v1.5.4' + manifestCount=@($m.files).Count;manifestSha=(Get-FileHash (Join-Path $Root 'windows\manifest.json') -Algorithm SHA256).Hash + guiSha=(Get-FileHash $gt -Algorithm SHA256).Hash + serviceSha=(Get-FileHash $st -Algorithm SHA256).Hash + installerSha=(Get-FileHash $installer -Algorithm SHA256).Hash + installerBytes=(Get-Item $installer).Length;unsigned=$true + installation='NOT_TESTED_YET';publicReleaseUnchanged=$true +} +[IO.File]::WriteAllText((Join-Path $qa 'BUILD_R846.json'),(($report|ConvertTo-Json -Depth 5)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false)) +Write-Output ('R846_GUI_SHA256='+$report.guiSha) +Write-Output ('R846_SERVICE_SHA256='+$report.serviceSha) +Write-Output ('R846_MANIFEST_SHA256='+$report.manifestSha) +Write-Output ('PASS_R846_HOSTED_BUILD_SHA256='+$report.installerSha) diff --git a/tests/r846_windows_native_rollback.ps1 b/tests/r846_windows_native_rollback.ps1 new file mode 100644 index 00000000..2e3294ea --- /dev/null +++ b/tests/r846_windows_native_rollback.ps1 @@ -0,0 +1,127 @@ +[CmdletBinding()] +param() +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$Root=$env:GITHUB_WORKSPACE +$Dir=Join-Path $env:RUNNER_TEMP 'DIM_R846_NATIVE' +$Build=Join-Path $Dir 'BUILD_R846.json' +$Result=Join-Path $Dir 'NATIVE_R846.json' +$Old=Join-Path $Dir 'DirectInternetMethod_1.5.4_Windows_Setup.exe' +$New=Join-Path $Root 'delivery\DirectInternetMethod_1.5.7_Windows_Setup.exe' +$App=Join-Path $env:LOCALAPPDATA 'Programs\DirectInternetMethod' +$Priv=Join-Path $env:ProgramFiles 'DirectInternetMethod\Privileged' +$Config=Join-Path $env:ProgramData 'DirectInternetMethod\UserConfig\custom-hosts.txt' +$Provider=Join-Path $App 'app\router_gateway\providers.json' +$Evidence=Join-Path $App 'evidence\R846_KEEP.txt' +$Gui=Join-Path $App 'app\DirectInternetMethod.exe' +$Svc=Join-Path $Priv 'app\DirectInternetMethod.Service.exe' +$Key='HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}_is1' +$PublicSha='FFB9A2AE19C2E89338EE12EE8DA133F5A16DBF4DCF961678C33D77D0C3843133' +$PublicGui='BE9C78C0B5C007B4A46480D423079C219006B730D9589279AA466B0F14BCF00F' +$PublicSvc='2AE7EA11A94BFA3187BC7CDF63A37CB5CD5B55C313C3966330FD280ECC9ECE41' +if($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/DirectInternetMethod'){throw 'R846_HOSTED_VM_ONLY'} +if($env:GITHUB_REF -cnotin @('refs/heads/prep/r846-forward-157-native-gates') -or (git -C $Root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R846_BRANCH_HEAD_DRIFT'} +$admin=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'R846_ADMIN_TOKEN_REQUIRED'} +if(!(Test-Path $Build) -or (Test-Path $Result) -or (Test-Path $Old)){throw 'R846_UNSAFE_REPLAY_OR_MISSING_BUILD'} +$build=Get-Content $Build -Raw |ConvertFrom-Json +if($build.status -cne 'PASS_ISOLATED_HOSTED_V157_BUILD' -or $build.head -cne $env:GITHUB_SHA){throw 'R846_BUILD_AUTHORITY'} +if((Get-FileHash $New -Algorithm SHA256).Hash -cne $build.installerSha){throw 'R846_RC1_INSTALLER_SHA'} +$report=[ordered]@{schema=1;revision='R846';status='FAIL_UNPROVEN';source=$env:GITHUB_SHA; + runner='github-hosted-windows-2025';privateInstallerSha=$build.installerSha; + publishedStableSha=$PublicSha;noReboot=$true;noPhysicalMachines=$true; + checks=@();errors=@()} +function Require([bool]$ok,[string]$name) { + if(!$ok){throw ('R846_FAIL_CLOSED_'+$name)} + $report.checks+=($name+':PASS') +} +function FileSha([string]$p){ (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash } +function Network { + [ordered]@{ + defaultRoutes=(@(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | + Select-Object InterfaceIndex,NextHop,RouteMetric|Sort-Object InterfaceIndex,NextHop)|ConvertTo-Json -Depth 5 -Compress) + dns=(@(Get-DnsClientServerAddress -AddressFamily IPv4| + Select-Object InterfaceIndex,ServerAddresses|Sort-Object InterfaceIndex)|ConvertTo-Json -Depth 5 -Compress)} +} +function Install([string]$exe,[string]$step) { + $args=@('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/NORESTARTAPPLICATIONS','/CLOSEAPPLICATIONS','/SP-') + $p=Start-Process -FilePath $exe -ArgumentList $args -Wait -PassThru -NoNewWindow + Require ($p.ExitCode -eq 0) ($step+'_EXIT') +} +function ExactRegisteredUninstaller([object]$d) { + # Inno Setup allocates uninsNNN per directory, not per version. Inspect + # the real AppId registration and require just one corresponding file pair. + $entries=@(Get-ChildItem -LiteralPath $App -Force -ErrorAction Stop | Where-Object { $_.Name -match '^unins[0-9]{3}[.](exe|dat|msg)$' }) + $exes=@($entries|Where-Object { $_.Extension -ieq '.exe' }) + $datas=@($entries|Where-Object { $_.Extension -ieq '.dat' }) + $msgs=@($entries|Where-Object { $_.Extension -ieq '.msg' }) + Require ($exes.Count -eq 1 -and $datas.Count -eq 1 -and $msgs.Count -le 1) 'ONE_UNINSTALLER' + $stem=[IO.Path]::GetFileNameWithoutExtension($exes[0].Name) + Require ($stem -ieq [IO.Path]::GetFileNameWithoutExtension($datas[0].Name)) 'MATCHED_UNINSTALL_LOG' + Require ($msgs.Count -eq 0 -or $stem -ieq [IO.Path]::GetFileNameWithoutExtension($msgs[0].Name)) 'MATCHED_UNINSTALL_MSG' + foreach($item in $entries) { + Require (!$item.PSIsContainer -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) 'UNINSTALLER_NODE_UNSAFE' + } + $path=[IO.Path]::GetFullPath($exes[0].FullName) + Require ([string]$d.UninstallString -ieq ('"'+$path+'"')) 'EXACT_UNINSTALL_PATH_OWNED' + return $path +} +function Registered([string]$version) { + Require (Test-Path $Key) 'SINGLE_HKLM_APPID' + $d=Get-ItemProperty -LiteralPath $Key + Require ($d.DisplayVersion -ceq $version) 'EXACT_REGISTERED_VERSION' + Require ($d.InstallLocation.TrimEnd('\') -ieq $App.TrimEnd('\')) 'SINGLE_INSTALL_PATH' + Require (!(Test-Path ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}_is1'))) 'NO_HKCU_DUPLICATE' + [void](ExactRegisteredUninstaller $d) + return $d +} +try { + Require (!(Test-Path $Key) -and !(Test-Path $App) -and !(Test-Path $Priv)) 'CLEAN_DISPOSABLE_VM' + $before=Network + $report.networkBefore=$before + Invoke-WebRequest -UseBasicParsing -Uri 'https://github.com/GOD13emad/DirectInternetMethod/releases/download/v1.5.4/DirectInternetMethod_1.5.4_Windows_Setup.exe' -OutFile $Old -MaximumRedirection 5 -TimeoutSec 180 + Require ((Get-Item $Old).Length -eq 154270907 -and (FileSha $Old) -ceq $PublicSha) 'OFFICIAL_STABLE_SHA_PIN' + Install $Old 'STABLE_FRESH' + [void](Registered '1.5.4') + Require ((FileSha $Gui) -ceq $PublicGui -and (FileSha $Svc) -ceq $PublicSvc) 'OFFICIAL_STABLE_GUI_AND_SERVICE' + New-Item -ItemType Directory -Path (Split-Path $Config -Parent) -Force|Out-Null + Require (!(Test-Path $Config)) 'NO_PREEXISTING_PRIVATE_CONFIG' + [IO.File]::WriteAllText($Config,'R846_PRIVATE_CUSTOM_SITE_SENTINEL') + $configSha=FileSha $Config + Require (Test-Path $Provider) 'PROVIDER_EXISTS' + [IO.File]::AppendAllText($Provider,[Environment]::NewLine+' ') + $providerSha=FileSha $Provider + New-Item -ItemType Directory -Path (Split-Path $Evidence -Parent) -Force|Out-Null + [IO.File]::WriteAllText($Evidence,'R846_USER_UNKNOWN_EVIDENCE_KEEP') + $evidenceSha=FileSha $Evidence + Install $New 'RC1_IN_PLACE' + [void](Registered '1.5.7') + Require ((FileSha $Gui) -ceq $build.guiSha -and (FileSha $Svc) -ceq $build.serviceSha) 'RC1_GUI_SERVICE_PIN' + Require ((FileSha (Join-Path $App 'manifest.json')) -ceq $build.manifestSha) 'RC1_MANIFEST_PIN' + & pwsh -NoProfile -NonInteractive -File (Join-Path $Root 'tests/r850_windows_privileged_manifest_attestation.ps1') -UserRoot $App -PrivilegedRoot $Priv -ExpectedVersion '1.5.7' -ExpectedManifestSha256 $build.manifestSha -ExpectedManagedFiles 681 -ExpectedPrivilegedFiles 674 + Require ($LASTEXITCODE -eq 0) 'R850_INSTALLED_PRIVILEGED_674_SHA_MATCH' + Require ((Get-Content (Join-Path $App 'RELEASE.json') -Raw|ConvertFrom-Json).version -ceq '1.5.7') 'RC1_INSTALLED_VERSION' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Provider) -ceq $providerSha -and (FileSha $Evidence) -ceq $evidenceSha) 'IN_PLACE_USER_DATA_PRESERVED' + $d=Registered '1.5.7' + $uninstaller=ExactRegisteredUninstaller $d + $u=Start-Process $uninstaller -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') -Wait -PassThru -NoNewWindow + Require ($u.ExitCode -eq 0) 'RC1_UNINSTALL_ZERO' + Require (!(Test-Path $Key)) 'ONE_UNINSTALLER_REMOVED_REGISTRY' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Evidence) -ceq $evidenceSha) 'UNKNOWN_USER_DATA_SURVIVED_UNINSTALL' + Install $Old 'STABLE_ROLLBACK_REINSTALL' + [void](Registered '1.5.4') + Require ((FileSha $Gui) -ceq $PublicGui -and (FileSha $Svc) -ceq $PublicSvc) 'ORIGINAL_STABLE_BINARY_HASHES_RESTORED' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Evidence) -ceq $evidenceSha) 'PRIVATE_CONFIG_PRESERVED_ROLLBACK' + $after=Network;$report.networkAfter=$after + Require ($before.defaultRoutes -ceq $after.defaultRoutes -and $before.dns -ceq $after.dns) 'NETWORK_BASELINE_EXACT' + $report.status='PASS_HOSTED_NATIVE_154_TO_157_AND_BACK_154' + $report.singleUninstaller=$true;$report.customProviderUpgradePreserved=$true;$report.dataPreserved=$true +} catch { + $report.status='DEGRADED_UNRESOLVED' + $report.errors+=([string]$_.Exception.Message) +} finally { + [IO.File]::WriteAllText($Result,(ConvertTo-Json $report -Depth 9)+[Environment]::NewLine,[Text.UTF8Encoding]::new($false)) + Write-Output ('R846_NATIVE_STATUS='+$report.status) + Write-Output ('R846_RECEIPT_SHA256='+((Get-FileHash $Result -Algorithm SHA256).Hash)) +} +if($report.status -ne 'PASS_HOSTED_NATIVE_154_TO_157_AND_BACK_154'){throw ('R846_NATIVE_NOT_ACCEPTED_'+($report.errors -join ';'))} diff --git a/tests/r848_windows_private156_forward_native.ps1 b/tests/r848_windows_private156_forward_native.ps1 new file mode 100644 index 00000000..2d3a3159 --- /dev/null +++ b/tests/r848_windows_private156_forward_native.ps1 @@ -0,0 +1,134 @@ +[CmdletBinding()] +param() +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +$Root=$env:GITHUB_WORKSPACE +$Dir=Join-Path $env:RUNNER_TEMP 'DIM_R848_NATIVE' +$Build=Join-Path $env:RUNNER_TEMP 'DIM_R846_NATIVE\BUILD_R846.json' +$Result=Join-Path $Dir 'NATIVE_R848.json' +$Old=Join-Path $Dir 'DirectInternetMethod_1.5.6_Windows_Setup.exe' +$New=Join-Path $Root 'delivery\DirectInternetMethod_1.5.7_Windows_Setup.exe' +$App=Join-Path $env:LOCALAPPDATA 'Programs\DirectInternetMethod' +$Priv=Join-Path $env:ProgramFiles 'DirectInternetMethod\Privileged' +$Config=Join-Path $env:ProgramData 'DirectInternetMethod\UserConfig\custom-hosts.txt' +$Provider=Join-Path $App 'app\router_gateway\providers.json' +$Evidence=Join-Path $App 'evidence\R848_KEEP.txt' +$Gui=Join-Path $App 'app\DirectInternetMethod.exe' +$Svc=Join-Path $Priv 'app\DirectInternetMethod.Service.exe' +$Key='HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}_is1' +$PublicSha='5BDE9DFFC3B866A7754DABEF9EDC853CFE519A08944163C39D812B2C8E9F3620' +$PublicGui='0CC4270B507BC9A141B4012378EE28D42C0EE8C5509E03A9ED3FB8C73B9865A3' +$PublicSvc='32AA6E88F57097D669D395999ED23A8AA85C2A330C41C542D1CB44A744C031FB' +if($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:GITHUB_REPOSITORY -cne 'GOD13emad/DirectInternetMethod'){throw 'R848_HOSTED_VM_ONLY'} +if($env:GITHUB_REF -cnotin @('refs/heads/prep/r846-forward-157-native-gates') -or (git -C $Root rev-parse HEAD).Trim() -cne $env:GITHUB_SHA){throw 'R848_BRANCH_HEAD_DRIFT'} +$admin=[Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if(!$admin.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){throw 'R848_ADMIN_TOKEN_REQUIRED'} +if(!(Test-Path $Build) -or (Test-Path $Result) -or (Test-Path $Old)){throw 'R848_UNSAFE_REPLAY_OR_MISSING_BUILD'} +if([string]::IsNullOrWhiteSpace($env:GH_TOKEN)){throw 'R848_GH_TOKEN_ACTIONS_READ_REQUIRED'} +$build=Get-Content $Build -Raw |ConvertFrom-Json +if($build.status -cne 'PASS_ISOLATED_HOSTED_V157_BUILD' -or $build.head -cne $env:GITHUB_SHA){throw 'R848_BUILD_AUTHORITY'} +if((Get-FileHash $New -Algorithm SHA256).Hash -cne $build.installerSha){throw 'R848_RC1_INSTALLER_SHA'} +$report=[ordered]@{schema=1;revision='R848';status='FAIL_UNPROVEN';source=$env:GITHUB_SHA; + runner='github-hosted-windows-2025';privateInstallerSha=$build.installerSha; + previousPrivateSha=$PublicSha;noReboot=$true;noPhysicalMachines=$true; + checks=@();errors=@()} +function Require([bool]$ok,[string]$name) { + if(!$ok){throw ('R848_FAIL_CLOSED_'+$name)} + $report.checks+=($name+':PASS') +} +function FileSha([string]$p){ (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash } +function Network { + [ordered]@{ + defaultRoutes=(@(Get-NetRoute -DestinationPrefix '0.0.0.0/0' | + Select-Object InterfaceIndex,NextHop,RouteMetric|Sort-Object InterfaceIndex,NextHop)|ConvertTo-Json -Depth 5 -Compress) + dns=(@(Get-DnsClientServerAddress -AddressFamily IPv4| + Select-Object InterfaceIndex,ServerAddresses|Sort-Object InterfaceIndex)|ConvertTo-Json -Depth 5 -Compress)} +} +function Install([string]$exe,[string]$step) { + $args=@('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/NORESTARTAPPLICATIONS','/CLOSEAPPLICATIONS','/SP-') + $p=Start-Process -FilePath $exe -ArgumentList $args -Wait -PassThru -NoNewWindow + Require ($p.ExitCode -eq 0) ($step+'_EXIT') +} +function ExactRegisteredUninstaller([object]$d) { + # Inno Setup allocates uninsNNN per directory, not per version. Inspect + # the real AppId registration and require just one corresponding file pair. + $entries=@(Get-ChildItem -LiteralPath $App -Force -ErrorAction Stop | Where-Object { $_.Name -match '^unins[0-9]{3}[.](exe|dat|msg)$' }) + $exes=@($entries|Where-Object { $_.Extension -ieq '.exe' }) + $datas=@($entries|Where-Object { $_.Extension -ieq '.dat' }) + $msgs=@($entries|Where-Object { $_.Extension -ieq '.msg' }) + Require ($exes.Count -eq 1 -and $datas.Count -eq 1 -and $msgs.Count -le 1) 'ONE_UNINSTALLER' + $stem=[IO.Path]::GetFileNameWithoutExtension($exes[0].Name) + Require ($stem -ieq [IO.Path]::GetFileNameWithoutExtension($datas[0].Name)) 'MATCHED_UNINSTALL_LOG' + Require ($msgs.Count -eq 0 -or $stem -ieq [IO.Path]::GetFileNameWithoutExtension($msgs[0].Name)) 'MATCHED_UNINSTALL_MSG' + foreach($item in $entries) { + Require (!$item.PSIsContainer -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) 'UNINSTALLER_NODE_UNSAFE' + } + $path=[IO.Path]::GetFullPath($exes[0].FullName) + Require ([string]$d.UninstallString -ieq ('"'+$path+'"')) 'EXACT_UNINSTALL_PATH_OWNED' + return $path +} +function Registered([string]$version) { + Require (Test-Path $Key) 'SINGLE_HKLM_APPID' + $d=Get-ItemProperty -LiteralPath $Key + Require ($d.DisplayVersion -ceq $version) 'EXACT_REGISTERED_VERSION' + Require ($d.InstallLocation.TrimEnd('\') -ieq $App.TrimEnd('\')) 'SINGLE_INSTALL_PATH' + Require (!(Test-Path ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B5388E8B-9AF3-41F4-87E2-01C9601A36CB}_is1'))) 'NO_HKCU_DUPLICATE' + [void](ExactRegisteredUninstaller $d) + return $d +} +try { + Require (!(Test-Path $Key) -and !(Test-Path $App) -and !(Test-Path $Priv)) 'CLEAN_DISPOSABLE_VM' + $before=Network + $report.networkBefore=$before + $artifactJson=& gh api repos/GOD13emad/DirectInternetMethod/actions/artifacts/12969796101 --jq '{id: .id, name: .name, expired: .expired, run: .workflow_run.id}' + if($LASTEXITCODE -ne 0){throw 'R848_GITHUB_ARTIFACT_QUERY_FAILED'} + $artifact=$artifactJson|ConvertFrom-Json + Require ($artifact.id -eq 12969796101 -and $artifact.name -ceq 'r600-private-tested-v156-41038434425' -and $artifact.expired -eq $false -and $artifact.run -eq 41038434425) 'PRIVATE_156_ARTIFACT_PROVENANCE' + & gh run download 41038434425 -R GOD13emad/DirectInternetMethod -n r600-private-tested-v156-41038434425 -D $Dir + if($LASTEXITCODE -ne 0){throw 'R848_DOWNLOAD_PRIVATE_156_FAILED'} + Require (Test-Path $Old) 'PRIVATE_156_SETUP_DOWNLOADED' + Require ((Get-Item $Old).Length -eq 154257997 -and (FileSha $Old) -ceq $PublicSha) 'EXACT_PRIVATE_156_SETUP_PIN' + Install $Old 'PRIVATE_156_FRESH' + [void](Registered '1.5.6') + Require ((FileSha $Gui) -ceq $PublicGui -and (FileSha $Svc) -ceq $PublicSvc) 'EXACT_PRIVATE_156_GUI_AND_SERVICE' + New-Item -ItemType Directory -Path (Split-Path $Config -Parent) -Force|Out-Null + Require (!(Test-Path $Config)) 'NO_PREEXISTING_PRIVATE_CONFIG' + [IO.File]::WriteAllText($Config,'R848_PRIVATE_CUSTOM_SITE_SENTINEL') + $configSha=FileSha $Config + Require (Test-Path $Provider) 'PROVIDER_EXISTS' + [IO.File]::AppendAllText($Provider,[Environment]::NewLine+' ') + $providerSha=FileSha $Provider + New-Item -ItemType Directory -Path (Split-Path $Evidence -Parent) -Force|Out-Null + [IO.File]::WriteAllText($Evidence,'R848_USER_UNKNOWN_EVIDENCE_KEEP') + $evidenceSha=FileSha $Evidence + Install $New 'RC1_IN_PLACE' + [void](Registered '1.5.7') + Require ((FileSha $Gui) -ceq $build.guiSha -and (FileSha $Svc) -ceq $build.serviceSha) 'RC1_GUI_SERVICE_PIN' + Require ((FileSha (Join-Path $App 'manifest.json')) -ceq $build.manifestSha) 'RC1_MANIFEST_PIN' + & pwsh -NoProfile -NonInteractive -File (Join-Path $Root 'tests/r850_windows_privileged_manifest_attestation.ps1') -UserRoot $App -PrivilegedRoot $Priv -ExpectedVersion '1.5.7' -ExpectedManifestSha256 $build.manifestSha -ExpectedManagedFiles 681 -ExpectedPrivilegedFiles 674 + Require ($LASTEXITCODE -eq 0) 'R850_INSTALLED_PRIVILEGED_674_SHA_MATCH' + Require ((Get-Content (Join-Path $App 'RELEASE.json') -Raw|ConvertFrom-Json).version -ceq '1.5.7') 'RC1_INSTALLED_VERSION' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Provider) -ceq $providerSha -and (FileSha $Evidence) -ceq $evidenceSha) 'IN_PLACE_USER_DATA_PRESERVED' + $d=Registered '1.5.7' + $uninstaller=ExactRegisteredUninstaller $d + $u=Start-Process $uninstaller -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') -Wait -PassThru -NoNewWindow + Require ($u.ExitCode -eq 0) 'RC1_UNINSTALL_ZERO' + Require (!(Test-Path $Key)) 'ONE_UNINSTALLER_REMOVED_REGISTRY' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Evidence) -ceq $evidenceSha) 'UNKNOWN_USER_DATA_SURVIVED_UNINSTALL' + Install $Old 'PRIVATE_156_ROLLBACK_REINSTALL' + [void](Registered '1.5.6') + Require ((FileSha $Gui) -ceq $PublicGui -and (FileSha $Svc) -ceq $PublicSvc) 'EXACT_PRIVATE_156_BINARY_HASHES_RESTORED' + Require ((FileSha $Config) -ceq $configSha -and (FileSha $Evidence) -ceq $evidenceSha) 'PRIVATE_CONFIG_PRESERVED_ROLLBACK' + $after=Network;$report.networkAfter=$after + Require ($before.defaultRoutes -ceq $after.defaultRoutes -and $before.dns -ceq $after.dns) 'NETWORK_BASELINE_EXACT' + $report.status='PASS_HOSTED_NATIVE_156_TO_157_AND_BACK_156' + $report.singleUninstaller=$true;$report.customProviderUpgradePreserved=$true;$report.dataPreserved=$true +} catch { + $report.status='DEGRADED_UNRESOLVED' + $report.errors+=([string]$_.Exception.Message) +} finally { + [IO.File]::WriteAllText($Result,(ConvertTo-Json $report -Depth 9)+[Environment]::NewLine,[Text.UTF8Encoding]::new($false)) + Write-Output ('R848_NATIVE_STATUS='+$report.status) + Write-Output ('R848_RECEIPT_SHA256='+((Get-FileHash $Result -Algorithm SHA256).Hash)) +} +if($report.status -ne 'PASS_HOSTED_NATIVE_156_TO_157_AND_BACK_156'){throw ('R848_NATIVE_NOT_ACCEPTED_'+($report.errors -join ';'))} diff --git a/tests/r848_windows_private156_native_source_contract.py b/tests/r848_windows_private156_native_source_contract.py new file mode 100644 index 00000000..d0566545 --- /dev/null +++ b/tests/r848_windows_private156_native_source_contract.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""One-time exact original-repo hosted native private-to-private release bridge.""" +from pathlib import Path +import unittest +R=Path(__file__).resolve().parents[1] +WF=(R/".github/workflows/ci.yml").read_text() +SCRIPT=(R/"tests/r848_windows_private156_forward_native.ps1").read_text() +class R848(unittest.TestCase): + def test_provenance_and_hosted_authority(self): + for s in ("GOD13emad/DirectInternetMethod","github-hosted","refs/heads/prep/r846-forward-157-native-gates","GITHUB_SHA","GH_TOKEN","12969796101","41038434425","r600-private-tested-v156-41038434425"): + self.assertIn(s,SCRIPT) + def test_fixed_private_v156_asset_digests(self): + for s in ("5BDE9DFFC3B866A7754DABEF9EDC853CFE519A08944163C39D812B2C8E9F3620","0CC4270B507BC9A141B4012378EE28D42C0EE8C5509E03A9ED3FB8C73B9865A3","32AA6E88F57097D669D395999ED23A8AA85C2A330C41C542D1CB44A744C031FB"): + self.assertIn(s,SCRIPT) + def test_native_transaction_contract(self): + for s in ("PASS_HOSTED_NATIVE_156_TO_157_AND_BACK_156","IN_PLACE_USER_DATA_PRESERVED","ONE_UNINSTALLER","NETWORK_BASELINE_EXACT","EXACT_PRIVATE_156_BINARY_HASHES_RESTORED","DIM_R846_NATIVE"): + self.assertIn(s,SCRIPT) + self.assertNotIn("1.5.4",SCRIPT) + def test_strict_one_time_ci_and_token_permissions(self): + for s in ("windows-private-r848-v156-forward:","r848-hosted-156-to-157-once","actions: read","tests/r848_windows_private156_forward_native.ps1","windows-private-r846-native:"): + self.assertIn(s,WF) + self.assertIn("GH_TOKEN: "+chr(36)+"{{ github.token }}",WF) +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(R848)) + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r850_windows_native_installed_manifest_source_contract.py b/tests/r850_windows_native_installed_manifest_source_contract.py new file mode 100644 index 00000000..5ef38642 --- /dev/null +++ b/tests/r850_windows_native_installed_manifest_source_contract.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""R850 native installed manifest hardening in both historic baseline paths.""" +from pathlib import Path +import unittest +R=Path(__file__).resolve().parents[1] +PROBE=(R/"tests/r850_windows_privileged_manifest_attestation.ps1").read_text() +WF=(R/".github/workflows/ci.yml").read_text() +class Contract(unittest.TestCase): + def test_strict_exact_manifest_and_protected_file_sha(self): + for s in ("Get-FileHash","SHA256","ExpectedManifestSha256","FILE_REPARSE","PATH_ESCAPE", + "PRIVILEGED_SHA_MISMATCH","PRIVILEGED_COUNT","DUPLICATE_SCOPE_FILE", + "ExpectedManagedFiles=681","ExpectedPrivilegedFiles=674"): + self.assertIn(s,PROBE) + def test_exact_native_hosted_v154_and_v156_forward_suites(self): + for file in ("tests/r846_windows_native_rollback.ps1","tests/r848_windows_private156_forward_native.ps1"): + x=(R/file).read_text() + self.assertIn("R850_INSTALLED_PRIVILEGED_674_SHA_MATCH",x) + self.assertIn("r850_windows_privileged_manifest_attestation.ps1",x) + self.assertIn("-ExpectedVersion '1.5.7'",x) + self.assertIn("-ExpectedManifestSha256 $build.manifestSha",x) + def test_one_time_private_source_prevent_repeated_artifact_use(self): + self.assertIn("r850-hosted-integrity-once",WF) + self.assertIn("github.repository == 'GOD13emad/DirectInternetMethod'",WF) + self.assertIn("windows-private-r846-native:",WF) + self.assertIn("windows-private-r848-v156-forward:",WF) + def test_attestation_read_only(self): + for s in ("Set-Content","WriteAllText","Copy-Item","Remove-Item","Start-Process","Invoke-WebRequest","pkexec"): + self.assertNotIn(s,PROBE) +if __name__=="__main__": + result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Contract)) + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/r850_windows_privileged_manifest_attestation.ps1 b/tests/r850_windows_privileged_manifest_attestation.ps1 new file mode 100644 index 00000000..bcee0bcd --- /dev/null +++ b/tests/r850_windows_privileged_manifest_attestation.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$UserRoot, + [Parameter(Mandatory=$true)][string]$PrivilegedRoot, + [Parameter(Mandatory=$true)][string]$ExpectedVersion, + [Parameter(Mandatory=$true)][string]$ExpectedManifestSha256, + [int]$ExpectedManagedFiles=681, + [int]$ExpectedPrivilegedFiles=674 +) +$ErrorActionPreference='Stop' +Set-StrictMode -Version Latest +function Guard([bool]$ok,[string]$errorCode) { + if(!$ok){throw ('R850_INSTALLED_MANIFEST_FAIL_CLOSED_'+$errorCode)} +} +Guard ($ExpectedVersion -cin @('1.5.6','1.5.7')) 'VERSION_ARGUMENT' +Guard ($ExpectedManifestSha256 -match '^[A-Fa-f0-9]{64}$') 'EXPECTED_SHA_FORMAT' +Guard ($ExpectedManagedFiles -gt 0 -and $ExpectedPrivilegedFiles -gt 0 -and $ExpectedPrivilegedFiles -le $ExpectedManagedFiles) 'COUNTS' +$rootUser=[IO.Path]::GetFullPath($UserRoot).TrimEnd('\') +$rootPriv=[IO.Path]::GetFullPath($PrivilegedRoot).TrimEnd('\') +Guard ($rootUser -cne $rootPriv) 'ROOTS_IDENTICAL' +Guard (Test-Path -LiteralPath $rootUser -PathType Container) 'USER_ROOT_ABSENT' +Guard (Test-Path -LiteralPath $rootPriv -PathType Container) 'PRIV_ROOT_ABSENT' +$manifest=Join-Path $rootUser 'manifest.json' +Guard (Test-Path -LiteralPath $manifest -PathType Leaf) 'MANIFEST_ABSENT' +$item=Get-Item -LiteralPath $manifest -Force +Guard (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) 'MANIFEST_REPARSE' +Guard ((Get-FileHash -LiteralPath $manifest -Algorithm SHA256).Hash -ceq $ExpectedManifestSha256.ToUpperInvariant()) 'MANIFEST_SHA_MISMATCH' +$doc=Get-Content -LiteralPath $manifest -Raw -Encoding UTF8|ConvertFrom-Json +Guard ([string]$doc.version -ceq $ExpectedVersion) 'VERSION_MISMATCH' +Guard (@($doc.files).Count -eq $ExpectedManagedFiles) 'TOTAL_COUNT' +$seen=[Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +$good=0 +foreach($f in @($doc.files)){ + $scope=[string]$f.scope + Guard ($scope -cin @('privileged','user')) 'INVALID_SCOPE' + $relative=[string]$f.file + Guard (![string]::IsNullOrWhiteSpace($relative)) 'BLANK_RELATIVE_PATH' + Guard (!([IO.Path]::IsPathRooted($relative)) -and !$relative.Contains('..') -and !$relative.Contains(':') -and !$relative.Contains([char]0)) 'UNSAFE_RELATIVE_PATH' + Guard ($seen.Add($scope+':'+$relative)) 'DUPLICATE_SCOPE_FILE' + $root=if($scope -ceq 'privileged'){$rootPriv}else{$rootUser} + $file=[IO.Path]::GetFullPath((Join-Path $root ($relative.Replace('/',[IO.Path]::DirectorySeparatorChar)))) + Guard ($file.StartsWith(($root+[IO.Path]::DirectorySeparatorChar),[StringComparison]::OrdinalIgnoreCase)) 'PATH_ESCAPE' + Guard (Test-Path -LiteralPath $file -PathType Leaf) 'FILE_MISSING' + $fi=Get-Item -LiteralPath $file -Force + Guard (($fi.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) 'FILE_REPARSE' + if($scope -ceq 'privileged'){ + Guard ($fi.Length -eq [long]$f.bytes) 'PRIVILEGED_SIZE_MISMATCH' + Guard ((Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash -ceq ([string]$f.sha256).ToUpperInvariant()) 'PRIVILEGED_SHA_MISMATCH' + $good++ + } +} +Guard ($good -eq $ExpectedPrivilegedFiles) 'PRIVILEGED_COUNT' +Write-Output ('R850_NATIVE_INSTALLED_PRIVILEGED_MANIFEST_PASS_'+$good+'_OF_'+$ExpectedPrivilegedFiles) diff --git a/tests/release_metadata_audit.py b/tests/release_metadata_audit.py index 20c73ae1..24f75cfa 100644 --- a/tests/release_metadata_audit.py +++ b/tests/release_metadata_audit.py @@ -56,7 +56,25 @@ def assert_unsigned_publication_acceptance(meta): ROOT = pathlib.Path(__file__).resolve().parents[1] release = json.loads((ROOT / 'RELEASE.json').read_text(encoding='utf-8-sig')) +def require_public_stable_forward_update_from_rc155(version: str) -> None: + """Public successors must be newer than deployed private Windows 1.5.5-rc1. + + Both already-installed GUI and privileged service carry .NET AssemblyVersion + 1.5.5.0 and compare GitHub's numeric stable tag using System.Version. + In System.Version, v1.5.5 is (1,5,5,-1), NOT newer than (1,5,5,0). + Therefore v1.5.5 Stable could strand both physical RC1 canaries indefinitely + on the private build. The earliest monotonically safe next Stable tag is + v1.5.6 unless a separately validated and installed promotion client exists. + This guard does not declare v1.5.6 acceptable for publication by itself. + """ + if not re.fullmatch(r"[0-9]+[.][0-9]+[.][0-9]+", version): + raise SystemExit("RELEASE_VERSION_NOT_SEMVER") + parts = tuple(int(n) for n in version.split(".")) + if parts > (1, 5, 4) and (*parts, -1) <= (1, 5, 5, 0): + raise SystemExit("PUBLIC_155_WOULD_NOT_AUTOUPDATE_DEPLOYED_PRIVATE_RC1_USE_156_OR_VERIFIED_PROMOTION") + version = str(release['version']) +require_public_stable_forward_update_from_rc155(version) platforms = release['platforms'] expected = {} @@ -128,16 +146,63 @@ def assert_unsigned_publication_acceptance(meta): raise SystemExit(f'README does not mention current version {version}') win_manifest = json.loads((ROOT / 'windows' / 'manifest.json').read_text(encoding='utf-8-sig')) -if str(win_manifest.get('version')) != version: - raise SystemExit(f"windows/manifest.json version {win_manifest.get('version')} != {version}") +win_release = json.loads((ROOT / 'windows' / 'RELEASE.json').read_text(encoding='utf-8-sig')) +win_version = str(win_release.get('version')) +# R535: an UNPUBLISHED, Windows-only private RC1 candidate is developed against +# the publicly accepted v1.5.4 cross-platform base. It cannot silently +# replace root/public release authority or satisfy public publication. +# The exception is branch- and metadata-exact; all original public sha/source +# checks above still run. Every other drift remains a hard failure. +branch = subprocess.run(['git','branch','--show-current'],cwd=ROOT, + capture_output=True,text=True,check=True).stdout.strip() +ref = __import__('os').environ.get('GITHUB_REF','') +private_win_rc1 = ( + version == '1.5.4' and win_version == '1.5.5-rc1' + and win_release.get('status') == 'PRIVATE_WINDOWS_RC1_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN' + and win_release.get('previousAccepted',{}).get('version') == '1.5.4' + and win_release.get('previousAccepted',{}).get('tag') == 'v1.5.4' + and win_release.get('publication',{}).get('publishedAt') is None + and (branch == 'feature/r535-windows-155rc1' + or ref in ('refs/heads/feature/r535-windows-155rc1', + 'refs/heads/feature/r520-linux-155rc1-ci', + 'refs/heads/security/r776-pinned-root-read-qa')) +) +# R805 is a strictly unpublished cross-platform v1.5.6 source candidate. +# It may retain the immutable public v1.5.4 RELEASE.json only on its exact +# private prep branch. It is NOT accepted as a public v1.5.6 publication. +private_win_v157 = ( + version == '1.5.4' and win_version == '1.5.7' + and win_release.get('status') == 'PRIVATE_WINDOWS_V157_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN' + and win_release.get('previousAccepted',{}).get('version') == '1.5.4' + and win_release.get('previousAccepted',{}).get('tag') == 'v1.5.4' + and win_release.get('publication',{}).get('publishedAt') is None + and (branch == 'prep/r846-forward-157-native-gates' + or ref == 'refs/heads/prep/r846-forward-157-native-gates') +) +# R846: a source candidate is not published simply because the version increased. +private_win_v156 = ( + version == '1.5.4' and win_version == '1.5.6' + and win_release.get('status') == 'PRIVATE_WINDOWS_V156_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN' + and win_release.get('previousAccepted',{}).get('version') == '1.5.4' + and win_release.get('previousAccepted',{}).get('tag') == 'v1.5.4' + and win_release.get('publication',{}).get('publishedAt') is None + and (branch == 'prep/r805-windows-156' + or ref == 'refs/heads/prep/r805-windows-156') +) +if win_version != version and not (private_win_rc1 or private_win_v156 or private_win_v157): + raise SystemExit('WINDOWS_PRIVATE_CANDIDATE_IDENTITY_NOT_AUTHORIZED') +if str(win_manifest.get('version')) != win_version: + raise SystemExit('WINDOWS_MANIFEST_NOT_MATCHING_PLATFORM_VERSION') linux_ui = (ROOT / 'linux' / 'app' / 'direct_internet_method.py').read_text(encoding='utf-8-sig') -if ('VERSION="' + version + '"') not in linux_ui and ('VERSION = "' + version + '"') not in linux_ui: - raise SystemExit('Linux UI version is inconsistent with RELEASE.json') - -win_release = json.loads((ROOT / 'windows' / 'RELEASE.json').read_text(encoding='utf-8-sig')) -if str(win_release.get('version')) != version: - raise SystemExit(f"windows/RELEASE.json version {win_release.get('version')} != {version}") +# The Linux 1.5.5-rc1 source was already present on this private draft branch. +# Its published metadata remains anchored to v1.5.4 until native acceptance. +# Require exact RC1 UI bytes for private candidate and exact stable otherwise. +linux_source_expected = ('1.5.5-rc1' if private_win_rc1 else + '1.5.7' if private_win_v157 else + '1.5.6' if private_win_v156 else version) +if ('VERSION="' + linux_source_expected + '"') not in linux_ui and ('VERSION = "' + linux_source_expected + '"') not in linux_ui: + raise SystemExit('Linux UI version is inconsistent with authorized source state') assert_unsigned_publication_acceptance(release) root_status = str(release.get('status','')) diff --git a/tests/service_curl_native/Program.cs b/tests/service_curl_native/Program.cs new file mode 100644 index 00000000..43f13eab --- /dev/null +++ b/tests/service_curl_native/Program.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +// An exact-source, real native downloader regression. It runs ONLY on disposable +// GitHub-hosted Windows runners. No service is registered and no installer is run. +if (Environment.GetEnvironmentVariable("GITHUB_ACTIONS") != "true" || + Environment.GetEnvironmentVariable("RUNNER_ENVIRONMENT") != "github-hosted" || + Environment.GetEnvironmentVariable("RUNNER_OS") != "Windows" || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("RUNNER_TEMP")) || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")) || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_SHA")) || + (Environment.GetEnvironmentVariable("COMPUTERNAME") ?? "") + .Contains("EMAD-PC", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("R062_ONLY_DISPOSABLE_GITHUB_WINDOWS_RUNNER"); + +var root = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE")!; +var service = Path.Combine(root, "windows", "service", "bin", "Release", + "net10.0-windows", "DirectInternetMethod.Service.dll"); +if (!File.Exists(service)) + throw new InvalidOperationException("R062_COMPILED_SERVICE_MISSING"); + +var tempRoot = Path.GetFullPath(Environment.GetEnvironmentVariable("RUNNER_TEMP")!); +var sandbox = Path.Combine(tempRoot, "R062_" + Guid.NewGuid().ToString("N")); +if (Directory.Exists(sandbox)) + throw new InvalidOperationException("R062_UNEXPECTED_SANDBOX_COLLISION"); +Directory.CreateDirectory(sandbox); +var oldPath = Environment.GetEnvironmentVariable("PATH"); +var oldNoProxy = Environment.GetEnvironmentVariable("NO_PROXY"); +var listener = new TcpListener(IPAddress.Loopback, 0); +using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(25)); +try +{ + var decoy = Path.Combine(sandbox, "untrusted-PATH-first"); + Directory.CreateDirectory(decoy); + // This is deliberately NOT a runnable Windows executable. + // Any command search via PATH must cause the test to fail. + File.WriteAllBytes(Path.Combine(decoy, "curl.exe"), + new byte[] { 0x4D, 0x5A, 0x90, 0x00 }); + + listener.Start(1); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var uri = "http://127.0.0.1:" + port + "/r062-exact-service-fixture"; + var download = Path.Combine(sandbox, "immutable-fixture.txt"); + var expectedBytes = Encoding.UTF8.GetBytes( + "R062_NATIVE_SERVICE_CURL_" + Environment.GetEnvironmentVariable("GITHUB_SHA") + "\n"); + var expectedSha = Convert.ToHexString(SHA256.HashData(expectedBytes)); + + Environment.SetEnvironmentVariable("PATH", decoy + Path.PathSeparator + oldPath); + Environment.SetEnvironmentVariable("NO_PROXY", "127.0.0.1,localhost"); + + var assembly = Assembly.LoadFrom(Path.GetFullPath(service)); + var type = assembly.GetType("DirectInternetMethod.Service.Program", throwOnError: true) + ?? throw new InvalidOperationException("R062_SERVICE_TYPE_NOT_FOUND"); + var method = type.GetMethod("DownloadWithSystemCurlAsync", + BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("R062_DOWNLOADER_METHOD_NOT_FOUND"); + + // Native CLR arguments: no PowerShell/PSObject reflection conversions. + var task = method.Invoke(null, new object?[] { uri, download, deadline.Token }) as Task + ?? throw new InvalidOperationException("R062_PRIVATE_METHOD_NOT_TASK"); + using (var client = await listener.AcceptTcpClientAsync(deadline.Token)) + { + await using var stream = client.GetStream(); + var buffer = new byte[4096]; + var received = await stream.ReadAsync(buffer.AsMemory(), deadline.Token); + if (received <= 0) + throw new InvalidOperationException("R062_EMPTY_LOOPBACK_REQUEST"); + var requestLine = Encoding.ASCII.GetString(buffer, 0, received); + if (!requestLine.StartsWith("GET /r062-exact-service-fixture HTTP/1.1", + StringComparison.Ordinal)) + throw new InvalidOperationException("R062_UNEXPECTED_HTTP_REQUEST"); + + const string crlf = "\r\n"; + var headers = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK" + crlf + + "Content-Type: text/plain" + crlf + + "Content-Length: " + expectedBytes.Length + crlf + + "Connection: close" + crlf + crlf); + await stream.WriteAsync(headers.AsMemory(), deadline.Token); + await stream.WriteAsync(expectedBytes.AsMemory(), deadline.Token); + await stream.FlushAsync(deadline.Token); + } + + await task.WaitAsync(TimeSpan.FromSeconds(25)); + if (!File.Exists(download)) + throw new InvalidOperationException("R062_DOWNLOADED_FIXTURE_MISSING"); + var actualSha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(download))); + if (!string.Equals(expectedSha, actualSha, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("R062_EXACT_DOWNLOAD_DIGEST_MISMATCH"); + if (!File.Exists(Path.Combine(Environment.SystemDirectory, "curl.exe"))) + throw new InvalidOperationException("R062_OS_SYSTEM_CURL_MISSING"); + + Console.WriteLine(JsonSerializer.Serialize(new + { + State = "PASS_R062_HOSTED_NATIVE_SERVICE_DOWNLOADER", + SourceSha = Environment.GetEnvironmentVariable("GITHUB_SHA"), + TrustedExecutableUsedDespiteFakePATH = true, + LoopbackHttpOnly = true, + DownloadSha256Matched = true, + DownloadedBytes = expectedBytes.Length, + WindowsServiceStarted = false, + InstallerExecuted = false, + VPNOrSystemNetworkChanged = false + })); +} +finally +{ + deadline.Cancel(); + listener.Stop(); + Environment.SetEnvironmentVariable("PATH", oldPath); + Environment.SetEnvironmentVariable("NO_PROXY", oldNoProxy); + if (Directory.Exists(sandbox)) + Directory.Delete(sandbox, recursive: true); +} diff --git a/tests/service_curl_native/ServiceCurlNative.csproj b/tests/service_curl_native/ServiceCurlNative.csproj new file mode 100644 index 00000000..ef4ebbbc --- /dev/null +++ b/tests/service_curl_native/ServiceCurlNative.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0-windows + enable + enable + + diff --git a/tests/tailscale_cleanup_ownership_contract.py b/tests/tailscale_cleanup_ownership_contract.py index ec4989fe..c3f9d0de 100644 --- a/tests/tailscale_cleanup_ownership_contract.py +++ b/tests/tailscale_cleanup_ownership_contract.py @@ -23,22 +23,28 @@ def f(name): nft_output='''table inet directinternetmethod { chain output { type filter hook output priority mangle; policy accept; + meta mark & 0x40000000 == 0x40000000 return %%MARK%% oifname "enp1s0" tcp dport 80 ct original packets 1-6 queue flags bypass to 200 oifname "enp1s0" tcp dport 443 ct original packets 1-6 queue flags bypass to 200 oifname "enp1s0" udp dport 443 ct original packets 1-6 queue flags bypass to 200 } }''' +inject_mark='meta mark & 0x40000000 == 0x40000000 return' mark="meta mark & 0x00ff0000 == 0x00080000 return" cases=[ ("split_coexistence_owns_marked_table", "system-preserved",mark,True,0,True), + ("split_coexistence_unexpected_tcp443_syn_exemption", "system-preserved",mark,True,81,False), + ("split_coexistence_wrong_tcp443_syn_exemption", "system-preserved",mark,True,81,False), + ("split_coexistence_missing_injected_mark", "system-preserved",mark,True,81,False), + ("split_coexistence_wrong_injected_mark", "system-preserved",mark,True,81,False), ("split_coexistence_rejects_missing_mark", "system-preserved","",True,81,False), ("split_coexistence_rejects_wrong_mark", "system-preserved", "meta mark & 0xff0000 == 0x00010000 return",True,81,False), ("split_coexistence_rejects_wrong_mask", "system-preserved", "meta mark & 0x007f0000 == 0x00080000 return",True,81,False), ("split_coexistence_rejects_late_mark", "system-preserved",mark,True,81,False), - ("standalone_owns_unmarked_table","direct-doh","",True,0,True), + ("standalone_owns_only_nfmark_table","direct-doh","",True,0,True), ("split_coexistence_no_table","system-preserved","",False,0,True), ("split_coexistence_foreign_extra_rule", "system-preserved",mark,True,81,False), ] @@ -80,6 +86,10 @@ def f(name): "ctrldPid":0,"nfqwsPid":0}),encoding="utf-8") fixture=d/"nft.out" value=nft_output.replace("%%MARK%%",bypass) + if name=="split_coexistence_unexpected_tcp443_syn_exemption":value=value.replace('oifname "enp1s0" tcp dport 80', 'oifname "enp1s0" tcp dport 443 tcp flags & (syn | ack) == syn return\n oifname "enp1s0" tcp dport 80',1) + if name=="split_coexistence_wrong_tcp443_syn_exemption":value=value.replace('oifname "enp1s0" tcp dport 80', 'oifname "enp1s0" tcp dport 443 tcp flags & (syn | ack) == ack return\n oifname "enp1s0" tcp dport 80',1) + if name=="split_coexistence_missing_injected_mark":value=value.replace(inject_mark,"",1) + if name=="split_coexistence_wrong_injected_mark":value=value.replace(inject_mark,'meta mark & 0x40000000 == 0x00000000 return',1) if name=="split_coexistence_rejects_late_mark": value=nft_output.replace("%%MARK%%","") first='oifname "enp1s0" tcp dport 80 ct original packets 1-6 queue flags bypass to 200' @@ -106,6 +116,7 @@ def f(name): f'NFQWS=/nonexistent/nfqws\n' + ('pid_owned(){ return 0; }\nkill(){ touch "$MOCK_KILLS"; }\n' if name=="split_coexistence_foreign_extra_rule" else 'pid_owned(){ return 1; }\n') + 'remove_dns_link(){ echo UNEXPECTED_DNS_LINK_REMOVAL >&2; return 89; }\n' + 'restore_selective_dns(){ test ! -e /run/directinternetmethod-selective/session.json; }\n' +functions+ '\ncleanup_state_owned\n' 'rc=$?\n' diff --git a/tests/tailscale_gui_scope_guard_contract.py b/tests/tailscale_gui_scope_guard_contract.py index c096c193..c3154f5a 100644 --- a/tests/tailscale_gui_scope_guard_contract.py +++ b/tests/tailscale_gui_scope_guard_contract.py @@ -35,8 +35,10 @@ def __truediv__(self,child): return FakePath(self.path.rstrip("/")+"/"+child) "os":SimpleNamespace(getuid=lambda:1000), "_external_tunnel_active":lambda:False, "_tailscale_split_safe":lambda:True, + "_top_default_iface":lambda:"enp1s0", "_coexistence_backend_ready":lambda:True, "selective_dns_optin_state":lambda:"OFF", + "encrypted_dns_optin_state":lambda:"OFF", "_systemd_unit_matches":lambda *args:False} exec(code,env) def mode():return env["load_state"]() @@ -80,7 +82,8 @@ def mode():return env["load_state"]() assert mode()["mode"]=="BLOCKED",("exit node / foreign VPN must still block",mode()) print("PASS unknown VPN/exit node still fail-closed") -assert 'mode in ("BLOCKED","CONFIG_REQUIRED","BACKEND_UPDATE_REQUIRED","CONFLICT")' in src +assert 'mode in ("ACTIVE","BLOCKED","CONFIG_REQUIRED","BACKEND_UPDATE_REQUIRED","CONFLICT")' in src +assert 'R735_GUI_NO_UNVERIFIED_GREEN' in src assert 'start_ok=(mode=="OFF")' in src assert 'w.close();self.refresh()' in src print("PASS CONFIG_REQUIRED warning; no Start until Strategy save; immediate refresh") @@ -102,16 +105,16 @@ def mode():return env["load_state"]() check=h_env["_coexistence_backend_ready"] assert check() is False FakePath.entries[good]="safe-backend" -FakePath.entries[meta]='{"version":"1.5.2","uid":1000}' +FakePath.entries[meta]='{"version":"1.5.7","uid":1000}' assert check() is True, "pinned helper and metadata must be recognized" FakePath.entries[good]="different-backend" assert check() is False, "foreign helper must be refused" FakePath.entries[good]="safe-backend" FakePath.entries[meta]='{"version":"1.5.1","uid":1000}' assert check() is False, "old metadata must be refused" -FakePath.entries[meta]='{"version":"1.5.2","uid":1001}' +FakePath.entries[meta]='{"version":"1.5.7","uid":1001}' assert check() is False, "different user metadata must be refused" -FakePath.entries[meta]='{"version":"1.5.2","uid":1000}' +FakePath.entries[meta]='{"version":"1.5.7","uid":1000}' FakePath.symlinks.add(good) assert check() is False, "helper symlink must be refused" FakePath.symlinks.clear() diff --git a/tests/tailscale_nft_ownership_contract.py b/tests/tailscale_nft_ownership_contract.py index dec0e512..0841d4dd 100644 --- a/tests/tailscale_nft_ownership_contract.py +++ b/tests/tailscale_nft_ownership_contract.py @@ -18,6 +18,7 @@ def function(name): table_template='''table inet directinternetmethod { chain output { type filter hook output priority mangle; policy accept; + meta mark & 0x40000000 == 0x40000000 return %%MARK%% oifname "enp1s0" tcp dport 80 ct original packets 1-6 queue flags bypass to 200 oifname "enp1s0" tcp dport 443 ct original packets 1-6 queue flags bypass to 200 @@ -25,9 +26,14 @@ def function(name): } } ''' +INJECT_MARK='meta mark & 0x40000000 == 0x40000000 return' MARK='meta mark & 0x00ff0000 == 0x00080000 return' CASES=[ ("coexist_mark_and_interface", "system-preserved", MARK, "enp1s0", True), + ("coexist_unexpected_tcp443_syn_exemption", "system-preserved", MARK, "enp1s0", False), + ("coexist_wrong_tcp443_syn_exemption", "system-preserved", MARK, "enp1s0", False), + ("coexist_missing_injected_mark", "system-preserved", MARK, "enp1s0", False), + ("coexist_bad_injected_mark", "system-preserved", MARK, "enp1s0", False), ("coexist_missing_bypass_mark", "system-preserved", "", "enp1s0", False), ("coexist_wrong_interface", "system-preserved", MARK, "enp9s0", False), ("coexist_mark_on_wrong_value", "system-preserved", "meta mark & 0xff0000 == 0x00010000 return", "enp1s0", False), @@ -54,6 +60,10 @@ def function(name): env["MOCK_NFT_TABLE"]=str(table) for name,mode,mark,iface,expected in CASES: value=table_template.replace("%%MARK%%",mark) + if name=="coexist_unexpected_tcp443_syn_exemption":value=value.replace('oifname "enp1s0" tcp dport 80', 'oifname "enp1s0" tcp dport 443 tcp flags & (syn | ack) == syn return\n oifname "enp1s0" tcp dport 80',1) + if name=="coexist_wrong_tcp443_syn_exemption":value=value.replace('oifname "enp1s0" tcp dport 80', 'oifname "enp1s0" tcp dport 443 tcp flags & (syn | ack) == ack return\n oifname "enp1s0" tcp dport 80',1) + if name=="coexist_missing_injected_mark":value=value.replace(INJECT_MARK,"",1) + if name=="coexist_bad_injected_mark":value=value.replace(INJECT_MARK,'meta mark & 0x40000000 == 0x00000000 return',1) if name=="coexist_bypass_mark_after_first_queue": value=table_template.replace("%%MARK%%","") first='oifname "enp1s0" tcp dport 80 ct original packets 1-6 queue flags bypass to 200' diff --git a/tests/tailscale_transient_rollback_ownership_contract.py b/tests/tailscale_transient_rollback_ownership_contract.py index ebc3dbdb..ca3d755e 100644 --- a/tests/tailscale_transient_rollback_ownership_contract.py +++ b/tests/tailscale_transient_rollback_ownership_contract.py @@ -19,23 +19,31 @@ def fragment(name,optional=False): } } ''' +inject_mark='meta mark & 0x40000000 == 0x40000000 return' mark='meta mark & 0x00ff0000 == 0x00080000 return' queue=[ 'oifname "enp1s0" tcp dport 80 ct original packets 1-6 queue flags bypass to 200', 'oifname "enp1s0" tcp dport 443 ct original packets 1-6 queue flags bypass to 200', 'oifname "enp1s0" udp dport 443 ct original packets 1-6 queue flags bypass to 200', ] -valid=[mark]+queue +valid=[inject_mark,mark]+queue cases=[ ("coexist_empty_table","table inet directinternetmethod {\n}\n","1",True), ("coexist_empty_chain",fixture.replace("%%RULES%%",""),"1",True), - ("coexist_mark_only",fixture.replace("%%RULES%%",mark),"1",True), + ("coexist_injected_mark_only",fixture.replace("%%RULES%%",inject_mark),"1",True), + ("coexist_injected_and_ts_mark_only",fixture.replace("%%RULES%%",inject_mark+"\n"+mark),"1",True), + ("coexist_foreign_old_syn_exemption",fixture.replace("%%RULES%%","\n".join([inject_mark,mark,'oifname "enp1s0" tcp dport 443 tcp flags & (syn | ack) == syn return']+queue)),"1",False), + ("coexist_wrong_syn_exemption",fixture.replace("%%RULES%%","\n".join([inject_mark,mark,'oifname "enp1s0" tcp dport 443 tcp flags & (syn | ack) == ack return']+queue)),"1",False), + ("coexist_missing_injected_mark",fixture.replace("%%RULES%%",mark),"1",False), + ("coexist_wrong_injected_mark",fixture.replace("%%RULES%%",'meta mark & 0x40000000 == 0x00000000 return'+"\n"+mark),"1",False), ("coexist_partial_rules",fixture.replace("%%RULES%%","\n".join(valid[:2])),"1",True), ("coexist_all_rules",fixture.replace("%%RULES%%","\n".join(valid)),"1",True), ("coexist_foreign_added",fixture.replace("%%RULES%%","\n".join(valid+["ip daddr 203.0.113.7 drop"])),"1",False), - ("coexist_wrong_iface",fixture.replace("%%RULES%%","\n".join([mark,queue[0].replace("enp1s0","enp9s0")])),"1",False), + ("coexist_wrong_iface",fixture.replace("%%RULES%%","\n".join([inject_mark,mark,queue[0].replace("enp1s0","enp9s0")])),"1",False), ("coexist_wrong_order",fixture.replace("%%RULES%%","\n".join([queue[0],mark])),"1",False), ("standalone_foreign_mark",fixture.replace("%%RULES%%","\n".join(valid)),"0",False), + ("standalone_injected_mark_only",fixture.replace("%%RULES%%",inject_mark),"0",True), + ("standalone_injected_and_queue",fixture.replace("%%RULES%%","\n".join([inject_mark]+queue)),"0",True), ("coexist_foreign_chain",fixture.replace("%%RULES%%","\n".join(valid)).replace("\n}\n","\n chain input { type filter hook input priority filter; policy accept; }\n}\n"),"1",False), ] with tempfile.TemporaryDirectory(prefix="dim_transient_rollback_") as td: @@ -78,6 +86,7 @@ def fragment(name,optional=False): CPID=0 pid_owned(){{ return 1; }} remove_dns_link(){{ echo "UNEXPECTED_REMOVE_DNS" >&2;return 90; }} +restore_selective_dns(){{ test ! -e /run/directinternetmethod-selective/session.json; }} '''+real_fragment+''' cleanup_transient rc=$? diff --git a/tests/tailscale_ui_state_contract.py b/tests/tailscale_ui_state_contract.py index 736aa1b7..af0ebf96 100644 --- a/tests/tailscale_ui_state_contract.py +++ b/tests/tailscale_ui_state_contract.py @@ -30,8 +30,10 @@ def read_text(self,encoding="utf-8"): "_systemd_unit_matches":lambda u,p,w:bool(p) and int(p)==400 and u==w, "_external_tunnel_active":lambda:False, "_tailscale_split_safe":lambda:True, + "_top_default_iface":lambda:"enp1s0", "_coexistence_backend_ready":lambda:True, - "selective_dns_optin_state":lambda:"OFF"} + "selective_dns_optin_state":lambda:"OFF", + "encrypted_dns_optin_state":lambda:"OFF"} exec(code,env) state=env["load_state"]() assert state["mode"]=="OFF" and "Tailscale split" in state["detail"],state diff --git a/tests/windows_contract_audit.py b/tests/windows_contract_audit.py index dd56a134..2d08c3fe 100644 --- a/tests/windows_contract_audit.py +++ b/tests/windows_contract_audit.py @@ -15,6 +15,7 @@ def txt(rel): return (W/rel).read_text(encoding="utf-8-sig") signature_gate=(R/"tests"/"windows_distribution_signature_audit.ps1").read_text(encoding="utf-8-sig") gitattributes=(R/".gitattributes").read_text(encoding="utf-8-sig") install_registration_guard=(R/"tests"/"windows_install_registration_audit.ps1").read_text(encoding="utf-8-sig") +native_r535_uninstall=(R/"tests"/"r535_windows_native_rollback.ps1").read_text(encoding="utf-8-sig") installed_release_verifier=(R/"tests"/"windows_installed_release_audit.ps1").read_text(encoding="utf-8-sig") D={"schema":2,"status":"PASS","checks":{},"hashes":{}} def check(name,cond): @@ -22,6 +23,15 @@ def check(name,cond): if not cond:D["status"]="FAIL" def idx(s,x): return s.find(x) +check("r535_registered_uninstaller_suffix_agnostic", all(x in native_r535_uninstall for x in ( + "function ExactRegisteredUninstaller([object]$d)", + "unins[0-9]{3}[.](exe|dat|msg)", + "'ONE_UNINSTALLER'", "'MATCHED_UNINSTALL_LOG'", + "'EXACT_UNINSTALL_PATH_OWNED'", + "[void](ExactRegisteredUninstaller $d)", + "$uninstaller=ExactRegisteredUninstaller $d", +)) and "$uninstaller=Join-Path $App 'unins000.exe'" not in native_r535_uninstall) + check("start_refuses_broad_tunnel_before_mutation", idx(start,"REFUSE_BROAD_TUNNEL_ROUTE")>=0 and idx(start,"REFUSE_BROAD_TUNNEL_ROUTE") < idx(start,"New-NetIPAddress")) check("loopback_index_dynamic", all("DestinationPrefix '::1/128'" in x and "$LoopbackIndex=[int]$LoopbackRoute.InterfaceIndex" in x and "$LoopbackIndex=1" not in x for x in (start,stop,recovery)) and "DestinationPrefix '::1/128'" in status and "InterfaceIndex 1" not in status) check("no_adapter_dns_set", "Set-DnsClientServerAddress" not in start+stop+recovery) @@ -69,10 +79,30 @@ def idx(s,x): return s.find(x) check("ui_gemini_adult_live_checks", all(x in xaml for x in ('Text="Gemini"','Text="Adult coverage"','x:Name="AdultSiteCheckToggle"','x:Name="GeminiText"','x:Name="AdultSiteText"')) and all(x in gui for x in ("https://www.pornhub.com/","https://www.xvideos.com/","https://www.xnxx.com/","https://xhamster.com/","FmtAdult")) and "pornhub" not in xaml.lower()) check("ui_adult_check_default_off_persisted", all(x in gui for x in ("adultSiteLiveCheck","settings.json","AdultSiteCheckToggle_Changed")) and "bool adultCheckEnabled;" in gui) check("adult_catalog_opt_in", all(x in gui for x in ("adultCoverageEnabled","adult-enabled.txt","adult-hosts.txt","nsfw-onlydomains.txt","EnsureAdultCatalogAsync","Adult catalog is unexpectedly small","xvideos.com","xnxx.com","xhamster.com")) and all(x in start for x in ("$AdultEnabledFile","$AdultHosts","$AdultFallback","adultHostCount","ADULT_CATALOG_INVALID")) and 'adult-fallback-hosts.txt' in iss) -check("startup_https_health_quorum", all(x in start for x in ("Curl-Probe-Retry","HTTPS_HEALTH_QUORUM_FAIL","$httpsProbePassCount -lt 2","httpsHealth=[ordered]@{required=2")) and all(x not in start for x in ("throw 'YOUTUBE_HTTP80_FAIL'","throw 'YOUTUBE_HTTPS_FAIL'","throw 'OPENAI_HTTPS_FAIL'","throw 'GITHUB_HTTPS_FAIL'"))) +check("startup_core_site_health_fields", all(x in start for x in ( + "function Get-CoreSiteHealth($ytPage,$yt204,$oa,$gh)", + "required=2", + "passed=3-$failed.Count", + "allCoreSitesOk=($failed.Count -eq 0)", + "unreachableCoreSites=@($failed.ToArray())", + "reachability=if($failed.Count -eq 0){'HEALTHY'}else{'PARTIAL'}", + "$httpsProbePassCount=[int]$httpsHealth.passed", + "$ev.status='PASS_ACTIVE'", +))) +check("startup_https_health_quorum", all(x in start for x in ("Curl-Probe-Retry","HTTPS_HEALTH_QUORUM_FAIL","$httpsProbePassCount -lt 2","$httpsHealth=Get-CoreSiteHealth $ytPage $yt $oa $gh","httpsHealth=$httpsHealth")) and all(x not in start for x in ("throw 'YOUTUBE_HTTP80_FAIL'","throw 'YOUTUBE_HTTPS_FAIL'","throw 'OPENAI_HTTPS_FAIL'","throw 'GITHUB_HTTPS_FAIL'"))) +check("startup_true_youtube_page_plus_204_required", all(x in start for x in ( + "function Get-CoreSiteHealth($ytPage,$yt204,$oa,$gh)", + "if(-not ([bool]$ytPage.ok -and [bool]$yt204.ok)){[void]$failed.Add('youtube')}", + "$ytPage=Curl-Probe-Retry 'https://www.youtube.com/' $route.LocalIp '^200\\|' 8 2", + "$yt=Curl-Probe-Retry 'https://www.youtube.com/generate_204' $route.LocalIp '^204\\|' 8 2", + "$httpsHealth=Get-CoreSiteHealth $ytPage $yt $oa $gh", + "youtubePage=$ytPage;youtube204=$yt", + "$httpsProbePassCount -lt 2", + "reachability=if($failed.Count -eq 0){'HEALTHY'}else{'PARTIAL'}", +)) and "function Get-CoreSiteHealth($yt,$oa,$gh)" not in start) check("adult_strong_override_profile", all(x in strong_override.splitlines() for x in ("xhamster.com","xhamsterlive.com","xhcdn.com","reddit.com","redditstatic.com","redditmedia.com","redd.it")) and all(x in start for x in ("$StrongOverrideHostList","strong-override-hosts.txt","--hostlist='+$StrongOverrideHostList","--dpi-desync=fake,hostfakesplit","--dpi-desync-repeats=11")) and 'strong-override-hosts.txt' in iss) check("ui_openai_403_cannot_be_chatgpt_login_pass", - 'FmtOpenAiApiProbe(await oaTask)' in gui + 'FmtOpenAiApiProbe(openAiProbe)' in gui and 'ProbeAsync("https://api.openai.com/v1/models", IpText.Text, new[] { "200", "401" })' in gui and 'ProbeAsync("https://api.openai.com/v1/models", IpText.Text, new[] { "200", "401", "403" })' not in gui and '"403" when p.Reached => "API HTTP 403 forbidden · login unverified"' in gui @@ -81,6 +111,29 @@ def idx(s,x): return s.find(x) and '"API transport unavailable · login unverified"' in gui) check("ui_live_probe_semantics", "FAIL · HTTP" in gui and 'https://gemini.google.com/' in gui and "adultTasks is not null" in gui and "full pages" in gui) +check("ui_partial_active_core_reachability", all(x in gui for x in ( + "bool coreReachabilityProven = false;", + "var youtubeProbe = await ytTask;", + "var openAiProbe = await oaTask;", + "var githubProbe = await ghTask;", + "var youtube204Probe = await yt204Task;", + "coreReachabilityProven = FullCoreReachability(youtubeProbe, youtube204Probe, openAiProbe, githubProbe);", + 'mode == "ACTIVE" && (!ownedHealthy || !coreReachabilityProven)', + 'StatusBadge.Text = "PARTIAL";', + "StatusBadge.Foreground = warn;", + "at least one core site unreachable", + 'StopButton.IsEnabled = mode is "ACTIVE" or "CONFLICT" or "DEGRADED";', +))) + +check("ui_youtube_homepage_and_204_independent_health", all(x in gui for x in ( + 'var ytTask = ProbeAsync("https://www.youtube.com/", IpText.Text, new[] { "200" });', + 'var yt204Task = ProbeAsync("https://www.youtube.com/generate_204", IpText.Text, new[] { "204" });', + 'var tasks = new List { ytTask, yt204Task, oaTask, ghTask, geminiTask };', + 'FullCoreReachability(ProbeResult youtubePage, ProbeResult youtube204, ProbeResult openai, ProbeResult github)', + 'youtubePage.Ok && youtube204.Ok && openai.Ok && github.Ok', + 'YoutubeText.Text = $"Page {FmtHttp(youtubeProbe)} | 204 {FmtHttp(youtube204Probe)}";', + 'StatusBadge.Text = "PARTIAL";' +)) and "coreReachabilityProven = youtubeProbe.Ok && openAiProbe.Ok && githubProbe.Ok;" not in gui) check("ui_direct_update_service_handoff", 'ServiceClient.Send("update")' in gui and 'ServiceClient.Send("recovery")' in gui and '"handoff"' in gui and "300000" in gui and "DownloadVerifiedAsync" not in gui and "LaunchInstaller" not in gui) check("ui_update_advisory_release_digest", "InstallerDigest" in updater and "digest" in updater and "SHA256SUMS.txt" in updater and "DownloadVerifiedAsync" not in updater and "Process.Start" not in updater) @@ -104,7 +157,30 @@ def idx(s,x): return s.find(x) check("service_hidden_backend", "CreateNoWindow = true" in svc and "WindowStyle = ProcessWindowStyle.Hidden" in svc) check("service_bundled_pwsh_only", '"runtime", "pwsh", "pwsh.exe"' in svc and 'return File.Exists(p) ? p : "pwsh.exe"' not in svc) check("service_action_timeout_guard", "WaitForExit(120000)" in svc and "Kill(entireProcessTree: true)" in svc and "exceeded the 120 second safety timeout" in svc) -check("service_action_completion_no_inherited_pipe_deadlock", "RedirectStandardOutput = true" not in svc and "RedirectStandardError = true" not in svc and "ReadToEndAsync" not in svc and 'Log($"action-end:{actionName}:exit={p.ExitCode}")' in svc) +check("service_action_completion_no_inherited_pipe_deadlock", + "RedirectStandardOutput = true" not in svc + and "ReadToEndAsync" not in svc + and 'Log($"action-end:{actionName}:exit={p.ExitCode}")' in svc + and ( + ("RedirectStandardError = true" not in svc and "ReadActionErrorTailAsync" not in svc) + or ( + "RedirectStandardError = true" in svc + and "var stderrTask = ReadActionErrorTailAsync(p.StandardError);" in svc + and "while ((count = await reader.ReadAsync(buffer.AsMemory())) != 0)" in svc + and "tail.Length > 8192" in svc + and "await stderrTask.WaitAsync(TimeSpan.FromSeconds(3))" in svc + ) + )) +check("service_safe_action_errors_do_not_leak_backend_output", + "RedirectStandardError = true" not in svc + or ( + "DescribeActionFailure(p.ExitCode, stderr)" in svc + and 'standardError.Contains("DEFAULT_ROUTE_NOT_PHYSICAL"' in svc + and 'standardError.Contains("REFUSE_BROAD_TUNNEL_ROUTE"' in svc + and 'standardError.Contains("HASH_MISMATCH_"' in svc + and "WriteActionStatus(actionName, p.ExitCode == 0 ? \"done\" : \"error\", p.ExitCode, message)" in svc + and "RAW_STDERR_LEAK" in svc + )) check("ui_wait_timeout_exceeds_service_timeout", "int timeoutMs = 130000" in gui) check("client_minimum_access", "SERVICE_QUERY_STATUS | SERVICE_USER_DEFINED_CONTROL" in client and "SERVICE_CHANGE_CONFIG" not in client and "SERVICE_START" not in client and "SERVICE_STOP" not in client) check("installer_service_acl_interactive_users_minimum", "(A;;LCCR;;;IU)" in iss and "sdset" in iss and "(A;;LCCR;;;AU)" not in iss) @@ -227,7 +303,19 @@ def idx(s,x): return s.find(x) check("windows_ci_external_audits_fail_fast", ci_workflow.count("if($LASTEXITCODE -ne 0){ exit $LASTEXITCODE }") >= 7) files={(x["scope"],x["file"]) for x in manifest["files"]} -check("manifest_version", manifest.get("version")=="1.5.4") +release_meta=json.loads(txt("RELEASE.json")) +allowed_status={ + "1.5.4":("CANDIDATE_V154_UNPUBLISHED","1.5.3"), + "1.5.5-rc1":("PRIVATE_WINDOWS_RC1_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN","1.5.4"), + "1.5.6":("PRIVATE_WINDOWS_V156_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN","1.5.4"), + "1.5.7":("PRIVATE_WINDOWS_V157_SOURCE_CANDIDATE_UNPUBLISHED_UNPROVEN","1.5.4"), +} +current=release_meta.get("version") +check("manifest_version", current in allowed_status + and manifest.get("version")==current + and release_meta.get("status")==allowed_status[current][0] + and release_meta.get("previousAccepted",{}).get("version")==allowed_status[current][1]) + check("manifest_direct_update", manifest.get("directUpdate",{}).get("userUacRequired") is False and "SHA256SUMS.txt" in manifest.get("directUpdate",{}).get("integrity","")) check("manifest_native_exe", ("user","app/DirectInternetMethod.exe") in files) check("manifest_router_gateway_data", ("user","app/router_gateway/providers.json") in files) @@ -273,8 +361,33 @@ def idx(s,x): return s.find(x) def checked_notice_mapping(filename): src = r"..\bin\zapret" + "\\" + filename dest = r"{commonpf}\DirectInternetMethod\Privileged\bin\zapret" - line = 'Source: "' + src + '"; DestDir: "' + dest + '"; Flags: ignoreversion' + line = 'Source: "' + src + '"; DestDir: "' + dest + '"; Flags: ignoreversion notimestamp' return iss.splitlines().count(line)==1 +def r549_inno_source_timestamp_contract(source): + try: + rows_section=source.split("[Files]\n",1)[1].split("[InstallDelete]",1)[0] + except IndexError: + return False + rows=[row.strip() for row in rows_section.splitlines() + if row.strip().startswith("Source: ")] + if len(rows)!=28 or len(set(rows))!=28: + return False + for row in rows: + if "Flags:" not in row: + return False + flags=row.split("Flags:",1)[1].strip().split() + if flags.count("notimestamp")!=1 or "touch" in flags or "comparetimestamp" in flags: + return False + return True + +# Negative-first source-time fixture: exactly 28 embedded records without +# stored input timestamps. No change to owned uninstall/upgrade file scope. +check("r549_inno_no_source_timestamps", + r549_inno_source_timestamp_contract(iss) + and not r549_inno_source_timestamp_contract(iss.replace(" notimestamp","",1)) + and not r549_inno_source_timestamp_contract( + iss.replace(" notimestamp"," notimestamp touch",1))) + check("installer_four_upstream_license_notices_byte_pinned", all( (W/"bin"/"zapret"/filename).is_file() diff --git a/tools/security/privileged_entry.py b/tools/security/privileged_entry.py new file mode 100644 index 00000000..5f3e11bc --- /dev/null +++ b/tools/security/privileged_entry.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Review-only R781 trusted Linux Root entry (NOT installed or authorized). + +An OS administrator must first provision this exact, audited source at +/usr/local/libexec/directinternetmethod/privileged_entry.py as a root-owned 0500 file. +Never call pkexec on package-provided bash/python. No implicit sudo policy. + +One frozen manifest SHA is an *authority from this root-owned file*, not +from untrusted argv or the downloaded package. A later release requires a +separately reviewed trust-anchor update; this is not a generic updater. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import pwd +import shutil +import stat +import subprocess +import sys +import tempfile + +VERSION = "1.5.7" +PACKAGE = "DirectInternetMethod_1.5.7_Linux_x86_64" +MANIFEST_SHA256 = "FE225338BE8FB9B71B347B1BFE2BB9AF4A4D5CDF1738C239FF00A90E21431C1B" +TRUSTED_ENTRY = Path("/usr/local/libexec/directinternetmethod/privileged_entry.py") +MAX_ITEM = 16 * 1024 * 1024 +MAX_ALL = 48 * 1024 * 1024 +DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK + +class AdmissionError(RuntimeError): + pass + +def require(value:bool, code:str)->None: + if not value: + raise AdmissionError("R781_REJECT_"+code) + +def sha(data:bytes)->str: + return hashlib.sha256(data).hexdigest().upper() + +def _object_no_duplicates(pairs): + result = {} + for key,value in pairs: + require(key not in result,"DUPLICATE_JSON_FIELD") + result[key] = value + return result + +def strict_json(data:bytes): + return json.loads(data.decode("utf-8"), object_pairs_hook=_object_no_duplicates) + +def checked_parts(path:Path)->tuple[str,...]: + require(path.is_absolute(),"SOURCE_MUST_BE_ABSOLUTE") + parts = path.parts[1:] + require(all(x not in ("", ".", "..") for x in parts) and len(parts)>0, + "SOURCE_PATH_COMPONENTS") + return parts + +def open_pinned_directory(path:Path)->int: + parts=checked_parts(path) + parent=os.open("/",DIR_FLAGS) + try: + for comp in parts: + try: + child=os.open(comp,DIR_FLAGS,dir_fd=parent) + except OSError as exc: + raise AdmissionError("R781_REJECT_SOURCE_DIR_NOFOLLOW") from exc + os.close(parent) + parent=child + return parent + except BaseException: + os.close(parent) + raise + +def read_pinned(root_fd:int,relative:str,max_bytes:int)->bytes: + entry=PurePosixPath(relative) + pieces=entry.parts + require(0=0:os.close(fd) + +def freeze_package(source:Path,staging_root:Path,uid:int)->Path: + """Pure source->private copy, isolated user-local in tests, root-only in CLI.""" + source=Path(source) + require(source.name==PACKAGE and type(uid) is int and 0&1 + $SystemCurl=Join-Path ([Environment]::SystemDirectory) 'curl.exe' + if(-not [IO.File]::Exists($SystemCurl)){throw 'WINDOWS_SYSTEM_CURL_MISSING'} + $m=& $SystemCurl -4 --interface $LocalIp --noproxy '*' -sS -o NUL -w '%{http_code}|%{remote_ip}|%{time_connect}|%{time_appconnect}|%{time_total}' --max-time $Timeout $Url 2>&1 [ordered]@{url=$Url;exit=$LASTEXITCODE;meta=($m -join ' ')} } function Curl-Probe-Retry([string]$Url,[string]$LocalIp,[string]$AcceptPattern,[int]$Timeout=7,[int]$Attempts=2){ @@ -217,6 +219,21 @@ function Curl-Probe-Retry([string]$Url,[string]$LocalIp,[string]$AcceptPattern,[ $last=$runs[-1] [ordered]@{url=$Url;exit=$last.exit;meta=$last.meta;ok=$false;attempts=$Attempts;runs=$runs} } +function Get-CoreSiteHealth($ytPage,$yt204,$oa,$gh) { + # A healthy engine/2-of-3 quorum does NOT prove all core sites accessible. + $failed=[Collections.Generic.List[string]]::new() + if(-not ([bool]$ytPage.ok -and [bool]$yt204.ok)){[void]$failed.Add('youtube')} + if(-not [bool]$oa.ok){[void]$failed.Add('openai')} + if(-not [bool]$gh.ok){[void]$failed.Add('github')} + [ordered]@{ + required=2 + passed=3-$failed.Count + members=@('youtube','openai','github') + reachability=if($failed.Count -eq 0){'HEALTHY'}else{'PARTIAL'} + allCoreSitesOk=($failed.Count -eq 0) + unreachableCoreSites=@($failed.ToArray()) + } +} function Rollback($s){ try{Remove-OwnedNrpt ([string]$s.nrptRuleName)}catch{} Clear-DnsClientCache -ErrorAction SilentlyContinue @@ -456,10 +473,12 @@ try{ $dy=Dns-Probe 'www.youtube.com' $yh=Curl-Probe-Retry 'http://www.youtube.com/' $route.LocalIp '^(200|301|302|303|307|308)\|' 7 2 - $yt=Curl-Probe-Retry 'https://www.youtube.com/generate_204' $route.LocalIp '^(200|204)\|' 8 2 + $ytPage=Curl-Probe-Retry 'https://www.youtube.com/' $route.LocalIp '^200\|' 8 2 + $yt=Curl-Probe-Retry 'https://www.youtube.com/generate_204' $route.LocalIp '^204\|' 8 2 $oa=Curl-Probe-Retry 'https://api.openai.com/v1/models' $route.LocalIp '^(200|401)\|' 7 2 $gh=Curl-Probe-Retry 'https://github.com/' $route.LocalIp '^(200|301|302|303|307|308)\|' 7 2 - $httpsProbePassCount=@($yt,$oa,$gh|Where-Object{$_.ok}).Count + $httpsHealth=Get-CoreSiteHealth $ytPage $yt $oa $gh + $httpsProbePassCount=[int]$httpsHealth.passed $routes=@(Get-NetRoute -AddressFamily IPv4 -ErrorAction SilentlyContinue|Where-Object{$_.DestinationPrefix -in @('0.0.0.0/1','128.0.0.0/1')}) $proxy=(netsh winhttp show proxy|Out-String).Trim() $icsLive=Get-Ics @@ -474,7 +493,7 @@ try{ Write-JsonAtomic $StatePath $state $ev.status='PASS_ACTIVE' $ev.live=[ordered]@{ - ctrldPid=$cpid;winwsPid=$wp.Id;directMethods=$DirectMethods;strategy=$strategy;scope=$scope;runtimeHostCount=$merged.Count;adultCoverage=$adultEnabled;adultHostCount=$adultHostCount;youtubeHttp80=$yh;dns=$dy;youtube=$yt;openai=$oa;github=$gh;httpsHealth=[ordered]@{required=2;passed=$httpsProbePassCount;members=@('youtube','openai','github')} + ctrldPid=$cpid;winwsPid=$wp.Id;directMethods=$DirectMethods;strategy=$strategy;scope=$scope;runtimeHostCount=$merged.Count;adultCoverage=$adultEnabled;adultHostCount=$adultHostCount;youtubeHttp80=$yh;dns=$dy;youtube=$yt;youtubePage=$ytPage;youtube204=$yt;openai=$oa;github=$gh;httpsHealth=$httpsHealth ics=$icsLive;proxy=$proxy;broadRouteCount=$routes.Count drivers=@(Get-OwnedDrivers|Select-Object Name,State,PathName) } diff --git a/windows/gui/DirectInternetMethod.csproj b/windows/gui/DirectInternetMethod.csproj index 94a6495d..e5d98076 100644 --- a/windows/gui/DirectInternetMethod.csproj +++ b/windows/gui/DirectInternetMethod.csproj @@ -1 +1 @@ -WinExenet10.0-windowstrueenableenableDirectInternetMethodDirectInternetMethod..\app\DirectInternetMethod.ico1.5.41.5.4.01.5.4.0Direct Internet MethodDirectInternetMethod.ico \ No newline at end of file +WinExenet10.0-windowstrueenableenableDirectInternetMethodDirectInternetMethod..\app\DirectInternetMethod.ico1.5.7false1.5.7.01.5.7.0Direct Internet MethodDirectInternetMethod.ico \ No newline at end of file diff --git a/windows/gui/MainWindow.xaml.cs b/windows/gui/MainWindow.xaml.cs index d6900617..2a84b14d 100644 --- a/windows/gui/MainWindow.xaml.cs +++ b/windows/gui/MainWindow.xaml.cs @@ -132,7 +132,7 @@ async Task EnsureAdultCatalogAsync(bool force) } using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }; - http.DefaultRequestHeaders.UserAgent.ParseAdd("DirectInternetMethod/1.5.4"); + http.DefaultRequestHeaders.UserAgent.ParseAdd("DirectInternetMethod/1.5.7"); Exception? lastError = null; foreach (var sourceUrl in AdultCatalogUrls) { @@ -211,15 +211,7 @@ async Task ProbeAsync(string url, string localIp, string[] allowed) var sw = Stopwatch.StartNew(); try { - var psi = new ProcessStartInfo("curl.exe", - $"-4 --interface {localIp} --noproxy * -A \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36\" -sS -o NUL -w \"%{{http_code}}|%{{remote_ip}}|%{{time_total}}\" --connect-timeout 5 --max-time 8 \"{url}\"") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - WindowStyle = ProcessWindowStyle.Hidden - }; + var psi = ProbeProcessBuilder.Create(url, localIp); using var p = Process.Start(psi)!; var stdout = await p.StandardOutput.ReadToEndAsync(); await p.WaitForExitAsync(); @@ -237,6 +229,12 @@ async Task ProbeAsync(string url, string localIp, string[] allowed) } } + // Engine ACTIVE can be PARTIAL. A lightweight HTTP 204 does not prove + // YouTube's real HTTPS homepage loads; R674-R677 observed this mismatch. + // Never mark the whole core-site set healthy unless both probes succeed. + static bool FullCoreReachability(ProbeResult youtubePage, ProbeResult youtube204, ProbeResult openai, ProbeResult github) => + youtubePage.Ok && youtube204.Ok && openai.Ok && github.Ok; + static string FmtHttp(ProbeResult p) => p.Ok ? $"PASS · HTTP {p.Code} · {p.Ms} ms" : p.Reached ? $"FAIL · HTTP {p.Code} · {p.Ms} ms" : @@ -308,9 +306,12 @@ async Task RefreshStatusAsync() bool ownedHealthy = r.TryGetProperty("ownedResourcesHealthy", out var oh) && oh.GetBoolean(); YoutubeText.Text = OpenAiText.Text = GithubText.Text = GeminiText.Text = mode == "CONFLICT" ? "Suspended by external tunnel" : ownedHealthy ? "Checking…" : "Available when Direct Method is healthy"; AdultSiteText.Text = adultCheckEnabled ? (mode == "CONFLICT" ? "Suspended by external tunnel" : ownedHealthy ? "Checking…" : "Available when Direct Method is healthy") : "Disabled"; + // Engine ownership is not proof that target sites are reachable. + bool coreReachabilityProven = false; if (ownedHealthy && mode == "ACTIVE" && !string.IsNullOrWhiteSpace(IpText.Text) && IpText.Text != "—") { - var ytTask = ProbeAsync("https://www.youtube.com/generate_204", IpText.Text, new[] { "200", "204" }); + var ytTask = ProbeAsync("https://www.youtube.com/", IpText.Text, new[] { "200" }); + var yt204Task = ProbeAsync("https://www.youtube.com/generate_204", IpText.Text, new[] { "204" }); var oaTask = ProbeAsync("https://api.openai.com/v1/models", IpText.Text, new[] { "200", "401" }); var ghTask = ProbeAsync("https://github.com/", IpText.Text, new[] { "200", "301", "302" }); var geminiTask = ProbeAsync("https://gemini.google.com/", IpText.Text, new[] { "200", "301", "302", "303", "307", "308" }); @@ -323,12 +324,17 @@ async Task RefreshStatusAsync() ProbeAsync("https://xhamster.com/", IpText.Text, new[] { "200", "301", "302", "303", "307", "308" }) } : null; - var tasks = new List { ytTask, oaTask, ghTask, geminiTask }; + var tasks = new List { ytTask, yt204Task, oaTask, ghTask, geminiTask }; if (adultTasks is not null) tasks.AddRange(adultTasks); await Task.WhenAll(tasks); - YoutubeText.Text = FmtHttp(await ytTask); - OpenAiText.Text = FmtOpenAiApiProbe(await oaTask); - GithubText.Text = FmtHttp(await ghTask); + var youtubeProbe = await ytTask; + var youtube204Probe = await yt204Task; + var openAiProbe = await oaTask; + var githubProbe = await ghTask; + coreReachabilityProven = FullCoreReachability(youtubeProbe, youtube204Probe, openAiProbe, githubProbe); + YoutubeText.Text = $"Page {FmtHttp(youtubeProbe)} | 204 {FmtHttp(youtube204Probe)}"; + OpenAiText.Text = FmtOpenAiApiProbe(openAiProbe); + GithubText.Text = FmtHttp(githubProbe); GeminiText.Text = FmtHttp(await geminiTask); AdultSiteText.Text = adultTasks is null ? "Disabled" : FmtAdult(await Task.WhenAll(adultTasks)); } @@ -342,6 +348,16 @@ async Task RefreshStatusAsync() "OFF" => "Ready", _ => "Unknown state" }; + if (mode == "ACTIVE" && (!ownedHealthy || !coreReachabilityProven)) + { + // Stay ACTIVE internally so Stop works; never claim full health + // just because two of three startup endpoints passed quorum. + StatusBadge.Text = "PARTIAL"; + StatusBadge.Foreground = warn; + HealthText.Text = ownedHealthy + ? "Direct path active · at least one core site unreachable" + : "Direct path active · owned resource validation failed"; + } if (!string.IsNullOrWhiteSpace(reason)) ToolTip = reason; StartButton.IsEnabled = mode == "OFF"; diff --git a/windows/gui/ProbeProcessBuilder.cs b/windows/gui/ProbeProcessBuilder.cs new file mode 100644 index 00000000..f66b61ba --- /dev/null +++ b/windows/gui/ProbeProcessBuilder.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Sockets; + +namespace DirectInternetMethod; + +// This is a pure process-creation plan: it never launches curl or changes host routes. +// Restrict executable discovery to the OS protected system directory, not PATH/CWD. +internal static class ProbeProcessBuilder +{ + private static readonly HashSet ProbeHosts = new(StringComparer.OrdinalIgnoreCase) + { + "www.youtube.com", "api.openai.com", "github.com", "gemini.google.com", + "www.pornhub.com", "www.xvideos.com", "www.xnxx.com", "xhamster.com" + }; + + internal static ProcessStartInfo Create(string url, string localIp) + { + var ipText = localIp?.Trim(); + if (string.IsNullOrEmpty(ipText) || + !IPAddress.TryParse(ipText, out var ip) || + ip.AddressFamily != AddressFamily.InterNetwork || + !string.Equals(ip.ToString(), ipText, StringComparison.Ordinal)) + throw new ArgumentException("Probe requires one canonical physical IPv4 address.", nameof(localIp)); + + // An unspecified source may select the system default route rather + // than the verified physical adapter. Loopback, link-local, + // multicast, and reserved IPv4 are never valid physical egress. + var octets = ip.GetAddressBytes(); + if (octets[0] == 0 || octets[0] == 127 || octets[0] >= 224 || + (octets[0] == 169 && octets[1] == 254)) + throw new ArgumentException("Probe requires a physical unicast IPv4 address.", nameof(localIp)); + + if (!Uri.TryCreate(url, UriKind.Absolute, out var endpoint) || + endpoint.Scheme != Uri.UriSchemeHttps || + endpoint.Port != 443 || + !string.IsNullOrEmpty(endpoint.UserInfo) || + !string.IsNullOrEmpty(endpoint.Fragment) || + !string.IsNullOrEmpty(endpoint.Query) || + !ProbeHosts.Contains(endpoint.IdnHost)) + throw new ArgumentException("Probe endpoint is not an allowed HTTPS destination.", nameof(url)); + + var systemFolder = Environment.SystemDirectory; + if (string.IsNullOrEmpty(systemFolder) || !Path.IsPathFullyQualified(systemFolder)) + throw new InvalidOperationException("Trusted Windows system directory unavailable."); + var curl = Path.GetFullPath(Path.Combine(systemFolder, "curl.exe")); + if (!File.Exists(curl)) + throw new FileNotFoundException("Protected Windows curl.exe unavailable.", curl); + + var psi = new ProcessStartInfo(curl) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + // ArgumentList prevents endpoint/interface data from being reparsed as extra curl flags. + foreach (var argument in new[] + { + "-4", "--interface", ip.ToString(), "--noproxy", "*", + "-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36", + "-sS", "-o", "NUL", "-w", "%{http_code}|%{remote_ip}|%{time_total}", + "--connect-timeout", "5", "--max-time", "8", endpoint.AbsoluteUri + }) + psi.ArgumentList.Add(argument); + return psi; + } +} diff --git a/windows/installer/DirectInternetMethod.iss b/windows/installer/DirectInternetMethod.iss index f84fde8c..ba8ce1e6 100644 --- a/windows/installer/DirectInternetMethod.iss +++ b/windows/installer/DirectInternetMethod.iss @@ -1,5 +1,5 @@ #define MyAppName "Direct Internet Method" -#define MyAppVersion "1.5.4" +#define MyAppVersion "1.5.7" #define MyAppPublisher "Direct Internet Method" #define MyAppExeName "DirectInternetMethod.exe" @@ -7,6 +7,7 @@ AppId={{B5388E8B-9AF3-41F4-87E2-01C9601A36CB} AppName={#MyAppName} AppVersion={#MyAppVersion} +VersionInfoVersion=1.5.7.0 AppPublisher={#MyAppPublisher} DefaultDirName={localappdata}\Programs\DirectInternetMethod UsePreviousAppDir=yes @@ -18,7 +19,7 @@ UninstallLogMode=overwrite DefaultGroupName=Direct Internet Method DisableProgramGroupPage=yes OutputDir=..\..\delivery -OutputBaseFilename=DirectInternetMethod_1.5.4_Windows_Setup +OutputBaseFilename=DirectInternetMethod_1.5.7_Windows_Setup Compression=lzma2/max SolidCompression=yes WizardStyle=modern @@ -36,35 +37,35 @@ RestartApplications=no Name: "{commonappdata}\DirectInternetMethod\UserConfig"; Permissions: users-modify [Files] -Source: "..\app\DirectInternetMethod.exe"; DestDir: "{app}\app"; Flags: ignoreversion -Source: "..\app\DirectInternetMethod.ico"; DestDir: "{app}\app"; Flags: ignoreversion -Source: "..\app\Status.ps1"; DestDir: "{app}\app"; Flags: ignoreversion -Source: "..\..\router_gateway\providers.json"; DestDir: "{app}\app\router_gateway"; Flags: ignoreversion onlyifdoesntexist -Source: "..\docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "..\manifest.json"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\RELEASE.json"; DestDir: "{app}"; Flags: ignoreversion - -Source: "..\app\DirectInternetMethod.Service.exe"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion -Source: "..\vendor\pwsh\*"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\runtime\pwsh"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "..\app\Start-Direct.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion -Source: "..\app\Stop-Direct.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion -Source: "..\app\Recovery.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion -Source: "..\app\SafeLegacyProviderRollback.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion -Source: "..\bin\ctrld\ctrld.exe"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\ctrld"; Flags: ignoreversion -Source: "..\bin\ctrld\ctrld.toml"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\ctrld"; Flags: ignoreversion -Source: "..\bin\ctrld\LICENSE.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\ctrld"; Flags: ignoreversion -Source: "..\bin\zapret\winws.exe"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\WinDivert.dll"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\WinDivert64.sys"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\cygwin1.dll"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\LICENSE.Cygwin.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\LGPL3.Cygwin.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\GPL3.Cygwin.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\hosts.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\adult-fallback-hosts.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\strong-override-hosts.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\LICENSE.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion -Source: "..\bin\zapret\LICENSE.WinDivert.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion +Source: "..\app\DirectInternetMethod.exe"; DestDir: "{app}\app"; Flags: ignoreversion notimestamp +Source: "..\app\DirectInternetMethod.ico"; DestDir: "{app}\app"; Flags: ignoreversion notimestamp +Source: "..\app\Status.ps1"; DestDir: "{app}\app"; Flags: ignoreversion notimestamp +Source: "..\..\router_gateway\providers.json"; DestDir: "{app}\app\router_gateway"; Flags: ignoreversion onlyifdoesntexist notimestamp +Source: "..\docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs notimestamp +Source: "..\manifest.json"; DestDir: "{app}"; Flags: ignoreversion notimestamp +Source: "..\RELEASE.json"; DestDir: "{app}"; Flags: ignoreversion notimestamp + +Source: "..\app\DirectInternetMethod.Service.exe"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion notimestamp +Source: "..\vendor\pwsh\*"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\runtime\pwsh"; Flags: ignoreversion recursesubdirs createallsubdirs notimestamp +Source: "..\app\Start-Direct.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion notimestamp +Source: "..\app\Stop-Direct.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion notimestamp +Source: "..\app\Recovery.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion notimestamp +Source: "..\app\SafeLegacyProviderRollback.ps1"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\app"; Flags: ignoreversion notimestamp +Source: "..\bin\ctrld\ctrld.exe"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\ctrld"; Flags: ignoreversion notimestamp +Source: "..\bin\ctrld\ctrld.toml"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\ctrld"; Flags: ignoreversion notimestamp +Source: "..\bin\ctrld\LICENSE.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\ctrld"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\winws.exe"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\WinDivert.dll"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\WinDivert64.sys"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\cygwin1.dll"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\LICENSE.Cygwin.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\LGPL3.Cygwin.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\GPL3.Cygwin.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\hosts.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\adult-fallback-hosts.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\strong-override-hosts.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\LICENSE.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp +Source: "..\bin\zapret\LICENSE.WinDivert.txt"; DestDir: "{commonpf}\DirectInternetMethod\Privileged\bin\zapret"; Flags: ignoreversion notimestamp [InstallDelete] Type: files; Name: "{app}\app\ControlPanel.cmd" diff --git a/windows/manifest.json b/windows/manifest.json index 8154cc08..a4288bea 100644 --- a/windows/manifest.json +++ b/windows/manifest.json @@ -1,7 +1,7 @@ { "schema": 2, "product": "Direct Internet Method", - "version": "1.5.4", + "version": "1.5.7", "architecture": "Native WPF UI + fixed-command Windows service + bundled PowerShell 7.6.6 + leak-closed direct-IP ctrld DoH + accepted zapret/winws/WinDivert multiprotocol engine with Targeted/All Sites scope control", "offlineRuntime": true, "normalRunRequiresAdmin": false, @@ -20,7 +20,7 @@ "file": "app/DirectInternetMethod.exe", "source": "app/DirectInternetMethod.exe", "bytes": 139900022, - "sha256": "BE9C78C0B5C007B4A46480D423079C219006B730D9589279AA466B0F14BCF00F" + "sha256": "C2E30BEE147D28F32756878DA6BC66C879A9CB15C2B238F4ED82CD5F63122BDB" }, { "scope": "user", @@ -61,22 +61,22 @@ "scope": "user", "file": "RELEASE.json", "source": "RELEASE.json", - "bytes": 5575, - "sha256": "51F54DC6761078C25A0913379B9B33315E317D96A992196C98DCA6DAF5B62B48" + "bytes": 5949, + "sha256": "88B1DB087B356C3F16790325CD68ECA590ACBA32D6150C5E4141FD52A6A8B85A" }, { "scope": "privileged", "file": "app/DirectInternetMethod.Service.exe", "source": "app/DirectInternetMethod.Service.exe", - "bytes": 73627459, - "sha256": "2AE7EA11A94BFA3187BC7CDF63A37CB5CD5B55C313C3966330FD280ECC9ECE41" + "bytes": 73631555, + "sha256": "21330D21EE8AD631F96BC0F27FB3AE216DEA4F30811535FA2D98A6284301253B" }, { "scope": "privileged", "file": "app/Start-Direct.ps1", "source": "app/Start-Direct.ps1", - "bytes": 26860, - "sha256": "B186872EDE733AE876FF1463E030988E9149A2CC88188F4B6C1FA5B564AEFC05" + "bytes": 27728, + "sha256": "E03AA828A89F8BEA34051C58EEE250685F440CE65B923C4B2CD6EAE8ABE3FE95" }, { "scope": "privileged", diff --git a/windows/service/DirectInternetMethod.Service.csproj b/windows/service/DirectInternetMethod.Service.csproj index 845ad05d..bcbedcfa 100644 --- a/windows/service/DirectInternetMethod.Service.csproj +++ b/windows/service/DirectInternetMethod.Service.csproj @@ -7,9 +7,10 @@ DirectInternetMethod.Service DirectInternetMethod.Service ..\app\DirectInternetMethod.ico - 1.5.4 - 1.5.4.0 - 1.5.4.0 + 1.5.7 + false + 1.5.7.0 + 1.5.7.0 Direct Internet Method Service \ No newline at end of file diff --git a/windows/service/Program.cs b/windows/service/Program.cs index ee5f3ca0..00c5548f 100644 --- a/windows/service/Program.cs +++ b/windows/service/Program.cs @@ -259,7 +259,12 @@ static string GetRegisteredInstallDirectory() static async Task DownloadWithSystemCurlAsync(string url, string destination, CancellationToken ct) { - var curl = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "curl.exe"); + // Resolve the updater helper exclusively from the Windows-owned system directory. + // Never let caller PATH or a resolved folder string choose the executable. + var systemFolder = Environment.SystemDirectory; + if (string.IsNullOrWhiteSpace(systemFolder) || !Path.IsPathFullyQualified(systemFolder)) + throw new InvalidOperationException("Trusted Windows system directory unavailable."); + var curl = Path.GetFullPath(Path.Combine(systemFolder, "curl.exe")); if (!File.Exists(curl)) throw new FileNotFoundException("Windows curl.exe is unavailable.", curl); @@ -519,7 +524,9 @@ static async Task RunActionAsync(string scriptName, string actionName) UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, - WorkingDirectory = AppContext.BaseDirectory + WorkingDirectory = AppContext.BaseDirectory, + // Fail-closed service errors are read, but raw output is never exposed. + RedirectStandardError = true }; Log($"action-begin:{actionName}"); @@ -532,6 +539,8 @@ static async Task RunActionAsync(string scriptName, string actionName) return; } + // Drain stderr concurrently; retain at most 8 KiB and never persist it. + var stderrTask = ReadActionErrorTailAsync(p.StandardError); if (!p.WaitForExit(120000)) { try { p.Kill(entireProcessTree: true); } catch { } @@ -540,10 +549,11 @@ static async Task RunActionAsync(string scriptName, string actionName) WriteActionStatus(actionName, "error", -2, "Privileged action exceeded the 120 second safety timeout and was terminated."); return; } + var stderr = await stderrTask.WaitAsync(TimeSpan.FromSeconds(3)); Log($"action-end:{actionName}:exit={p.ExitCode}"); var message = p.ExitCode == 0 ? "Action completed." - : $"Privileged action failed with exit code {p.ExitCode}. See lifecycle evidence/service log."; + : DescribeActionFailure(p.ExitCode, stderr); WriteActionStatus(actionName, p.ExitCode == 0 ? "done" : "error", p.ExitCode, message); } catch (Exception ex) @@ -557,6 +567,32 @@ static async Task RunActionAsync(string scriptName, string actionName) } } + static async Task ReadActionErrorTailAsync(StreamReader reader) + { + var tail = new StringBuilder(); + var buffer = new char[2048]; + int count; + while ((count = await reader.ReadAsync(buffer.AsMemory())) != 0) + { + tail.Append(buffer, 0, count); + if (tail.Length > 8192) + tail.Remove(0, tail.Length - 8192); + } + return tail.ToString(); + } + + static string DescribeActionFailure(int exitCode, string standardError) + { + // Never disclose arbitrary stderr: it can include paths, hosts or secrets. + if (standardError.Contains("DEFAULT_ROUTE_NOT_PHYSICAL", StringComparison.Ordinal)) + return "A VPN or virtual adapter is the preferred default route. Direct Internet Method kept the routes unchanged. Select a physical default route before starting."; + if (standardError.Contains("REFUSE_BROAD_TUNNEL_ROUTE", StringComparison.Ordinal)) + return "A broad VPN route is active. Direct Internet Method refused to change the current network configuration."; + if (standardError.Contains("HASH_MISMATCH_", StringComparison.Ordinal)) + return "Privileged runtime integrity check failed. Repair the verified installation before starting."; + return $"Privileged action failed with exit code {exitCode}. See lifecycle evidence/service log."; + } + static void WriteActionStatus(string action, string phase, int? exitCode, string message) { try @@ -617,6 +653,15 @@ static string SelfTest() { var required = new[] { "Start-Direct.ps1", "Stop-Direct.ps1", "Recovery.ps1" }; var missing = required.Where(x => !File.Exists(Path.Combine(AppContext.BaseDirectory, x))).ToArray(); - return missing.Length == 0 ? "PASS" : "MISSING:" + string.Join(",", missing); + if (missing.Length != 0) return "MISSING:" + string.Join(",", missing); + if (!DescribeActionFailure(1, "START_FAIL_DEFAULT_ROUTE_NOT_PHYSICAL").Contains("VPN or virtual adapter", StringComparison.Ordinal)) + return "MISSING_ROUTE_GUARD_MESSAGE"; + if (!DescribeActionFailure(1, "REFUSE_BROAD_TUNNEL_ROUTE").Contains("broad VPN route", StringComparison.Ordinal)) + return "MISSING_BROAD_ROUTE_GUARD_MESSAGE"; + if (!DescribeActionFailure(1, "HASH_MISMATCH_ctrld.exe").Contains("integrity check failed", StringComparison.Ordinal)) + return "MISSING_RUNTIME_INTEGRITY_MESSAGE"; + if (DescribeActionFailure(1, "SECRET_SAMPLE private path").Contains("SECRET_SAMPLE", StringComparison.Ordinal)) + return "RAW_STDERR_LEAK"; + return "PASS"; } }