diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0eea57e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "05:00" + timezone: America/New_York + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: gradle + directory: /android + schedule: + interval: weekly + day: monday + time: "05:30" + timezone: America/New_York + open-pull-requests-limit: 10 + groups: + android-dependencies: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb2af35..36aeb94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,9 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 500M @@ -24,10 +27,10 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Restore compiler cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .ccache key: ${{ runner.os }}-${{ github.job }}-ccache-${{ github.sha }} @@ -76,7 +79,7 @@ jobs: name: Fedora build and tests runs-on: ubuntu-latest container: - image: fedora:latest + image: fedora:44@sha256:6c75d5bf57cb0fa5aa4b92c6a83c86c791644496d9ac230de7711f5b8ec3b898 steps: - name: Install dependencies @@ -98,10 +101,10 @@ jobs: zlib-devel - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Restore compiler cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .ccache key: ${{ runner.os }}-${{ github.job }}-ccache-${{ github.sha }} @@ -123,10 +126,10 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Restore compiler cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .ccache key: ${{ runner.os }}-${{ github.job }}-ccache-${{ github.sha }} @@ -163,13 +166,59 @@ jobs: - name: Compiler cache stats run: ccache --show-stats + protocol-fuzz: + name: Protocol fuzz smoke + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install dependencies + run: | + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list \ + /etc/apt/sources.list.d/azure-cli.list \ + /etc/apt/sources.list.d/microsoft*.list || true + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang \ + libclang-rt-dev \ + cmake \ + ninja-build \ + libx11-dev \ + libssl-dev \ + pkg-config \ + zlib1g-dev + + - name: Build fuzz target + env: + CC: clang + CXX: clang++ + run: | + cmake -S . -B build-fuzz -G Ninja \ + -DBUILD_TESTING=OFF \ + -DMWB_ENABLE_FUZZING=ON + cmake --build build-fuzz --target mwb_clipboard_fuzzer --parallel + + - name: Fuzz untrusted clipboard and protocol parsers + env: + ASAN_OPTIONS: abort_on_error=1:detect_leaks=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: | + ./build-fuzz/mwb_clipboard_fuzzer \ + -dict=tests/fuzz/clipboard.dict \ + -max_total_time=30 \ + -timeout=5 \ + -rss_limit_mb=2048 \ + -max_len=1048576 + static-checks: name: Static checks runs-on: ubuntu-latest steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Check trailing whitespace run: | @@ -200,16 +249,19 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Validate Gradle wrapper integrity + uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "21" - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 - name: Build release APK, run unit tests, and enforce lint working-directory: android diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0e7eeae --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,97 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "23 4 * * 2" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: c-cpp + build-mode: manual + - language: java-kotlin + build-mode: manual + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended + + - name: Install native build dependencies + if: matrix.language == 'c-cpp' + run: | + sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list \ + /etc/apt/sources.list.d/azure-cli.list \ + /etc/apt/sources.list.d/microsoft*.list || true + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + libx11-dev \ + libssl-dev \ + pkg-config \ + zlib1g-dev + + - name: Build native targets + if: matrix.language == 'c-cpp' + run: | + cmake -S . -B build-codeql -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo + cmake --build build-codeql --parallel + + - name: Validate Gradle wrapper integrity + if: matrix.language == 'java-kotlin' + uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + + - name: Set up JDK 21 + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + if: matrix.language == 'java-kotlin' + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + + - name: Build Android targets + if: matrix.language == 'java-kotlin' + working-directory: android + env: + # CodeQL instrumentation increases compiler memory use. Keep one JVM + # and bounded parallelism so the hosted runner cannot overcommit RAM. + GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dkotlin.compiler.execution.strategy=in-process + run: ./gradlew :app:assembleRelease --no-daemon --max-workers=2 --stacktrace + + - name: Analyze + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..a43b06a --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,49 @@ +name: Scorecard supply-chain security + +on: + branch_protection_rule: + push: + branches: [main] + schedule: + - cron: "41 5 * * 3" + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'branch_protection_rule' || github.event_name == 'workflow_dispatch' + permissions: + actions: read + contents: read + id-token: write + security-events: write + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Run Scorecard analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: scorecard-sarif + path: results.sarif + retention-days: 14 + + - name: Upload results to code scanning + if: always() + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 + with: + sarif_file: results.sarif diff --git a/CMakeLists.txt b/CMakeLists.txt index afd2e81..50b2a90 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ endif() option(MWB_ENABLE_SANITIZERS "Build with compiler sanitizers for debug hardening" OFF) set(MWB_SANITIZERS "address,undefined" CACHE STRING "Comma-separated sanitizer list used when MWB_ENABLE_SANITIZERS=ON") +option(MWB_ENABLE_FUZZING "Build coverage-guided security fuzz targets with Clang" OFF) if (MWB_ENABLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") include(CheckCXXSourceCompiles) @@ -23,6 +24,10 @@ if (MWB_ENABLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") endif() endif() +if (MWB_ENABLE_FUZZING AND NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR "MWB_ENABLE_FUZZING requires Clang and libFuzzer") +endif() + function(mwb_apply_sanitizers target) if (MWB_ENABLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") target_compile_options(${target} PRIVATE @@ -212,6 +217,25 @@ if (BUILD_TESTING) ) mwb_apply_sanitizers(mwb_clipboard_socket_security_tests) + add_executable(mwb_crypto_helper_tests + tests/test_crypto_helper.cpp + src/CryptoHelper.cpp + ) + target_include_directories(mwb_crypto_helper_tests PRIVATE src) + target_compile_options(mwb_crypto_helper_tests PRIVATE -Wall -Wextra -Wpedantic) + target_link_libraries(mwb_crypto_helper_tests PRIVATE OpenSSL::Crypto) + mwb_apply_sanitizers(mwb_crypto_helper_tests) + + add_executable(mwb_clipboard_fuzz_smoke + tests/fuzz_clipboard_payload.cpp + tests/fuzz_smoke_driver.cpp + src/ClipboardManager.cpp + ) + target_include_directories(mwb_clipboard_fuzz_smoke PRIVATE src) + target_compile_options(mwb_clipboard_fuzz_smoke PRIVATE -Wall -Wextra -Wpedantic) + target_link_libraries(mwb_clipboard_fuzz_smoke PRIVATE ZLIB::ZLIB) + mwb_apply_sanitizers(mwb_clipboard_fuzz_smoke) + add_test(NAME mwb_client_unit_tests COMMAND mwb_client_unit_tests) add_test(NAME mwb_input_mapping_tests COMMAND mwb_input_mapping_tests) add_test(NAME mwb_inject_mouse_abs_tests COMMAND mwb_inject_mouse_abs_tests) @@ -224,11 +248,24 @@ if (BUILD_TESTING) "${CMAKE_CURRENT_SOURCE_DIR}/tests/topology_config_docs_test.py" "${CMAKE_CURRENT_SOURCE_DIR}/docs/topology.md" ) + add_test(NAME inputflow_systemd_hardening + COMMAND "${PYTHON3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_systemd_hardening.py" + "${CMAKE_CURRENT_SOURCE_DIR}/packaging/usr/lib/systemd/user/mwb-client.service" + ) + add_test(NAME inputflow_ci_security + COMMAND "${PYTHON3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ci_security.py" + "${CMAKE_CURRENT_SOURCE_DIR}/.github/workflows" + ) endif() add_test(NAME mwb_mouse_trace_tests COMMAND mwb_mouse_trace_tests) add_test(NAME mwb_media_key_bridge_tests COMMAND mwb_media_key_bridge_tests) add_test(NAME mwb_protocol_security_tests COMMAND mwb_protocol_security_tests) add_test(NAME mwb_clipboard_socket_security_tests COMMAND mwb_clipboard_socket_security_tests) + add_test(NAME mwb_crypto_helper_tests COMMAND mwb_crypto_helper_tests) + add_test(NAME mwb_clipboard_fuzz_smoke COMMAND mwb_clipboard_fuzz_smoke) + set_tests_properties(mwb_clipboard_fuzz_smoke PROPERTIES TIMEOUT 30) add_test(NAME mwb_client_help COMMAND mwb_client --help) add_test(NAME mwb_client_doctor COMMAND mwb_client doctor --config "${CMAKE_CURRENT_BINARY_DIR}/missing-doctor-config.ini") add_test(NAME mwb_client_topology_explain @@ -257,6 +294,23 @@ if (BUILD_TESTING) set_tests_properties(inputflow_diagnostics_privacy PROPERTIES TIMEOUT 30) endif() +if (MWB_ENABLE_FUZZING) + add_executable(mwb_clipboard_fuzzer + tests/fuzz_clipboard_payload.cpp + src/ClipboardManager.cpp + ) + target_include_directories(mwb_clipboard_fuzzer PRIVATE src) + target_compile_options(mwb_clipboard_fuzzer PRIVATE + -O1 + -g + -fno-omit-frame-pointer + -fsanitize=fuzzer,address,undefined + ) + set_property(TARGET mwb_clipboard_fuzzer APPEND_STRING PROPERTY + LINK_FLAGS " -fsanitize=fuzzer,address,undefined") + target_link_libraries(mwb_clipboard_fuzzer PRIVATE ZLIB::ZLIB) +endif() + if (PkgConfig_FOUND) pkg_check_modules(MWB_TRAY_DEPS QUIET IMPORTED_TARGET gtk+-3.0 ayatana-appindicator3-0.1) if (MWB_TRAY_DEPS_FOUND) diff --git a/SECURITY.md b/SECURITY.md index 15decf9..0512842 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,13 +11,17 @@ Security fixes should target the current `main` branch. If you discover a vulnerability that could expose input, clipboard data, or pairing secrets: 1. Do not open a public issue with exploit details. -2. Send a private report to the maintainers with: +2. Open a [private vulnerability report](https://github.com/daredoole/inputflow-linux/security/advisories/new) with: - affected commit or release - reproduction steps - impact assessment - logs or packet traces with keys and hostnames removed -If no private contact channel is published yet, open a minimal public issue that only asks for a secure disclosure path and avoid technical detail. +Maintainers aim to acknowledge reports within three business days, provide an +initial severity assessment within seven days, and keep reporters updated at +least every fourteen days until resolution. Remediation timing depends on +severity and compatibility impact; actively exploited critical issues take +priority over the normal release cadence. ## Security guidance for users @@ -43,3 +47,6 @@ If no private contact channel is published yet, open a minimal public issue that - The upstream PowerToys protocol uses AES-256-CBC framing but does not provide a modern end-to-end authenticated channel. - Full AEAD or MAC-based integrity would require a protocol change on both the Linux client and the PowerToys side. - Public beta users should treat InputFlow as a trusted-LAN tool, not an internet-exposed remote-control service. + +The production threat model and mandatory release evidence are documented in +[`docs/production-security.md`](docs/production-security.md). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b3c574d..cd80fc1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,7 +18,8 @@ android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + android:usesCleartextTraffic="false"> diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index c61a118..80d8229 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/docs/production-security.md b/docs/production-security.md new file mode 100644 index 0000000..6eb12c3 --- /dev/null +++ b/docs/production-security.md @@ -0,0 +1,79 @@ +# Production security and release standard + +InputFlow is production-eligible only within its declared deployment boundary: +a trusted LAN or a private VPN with host firewall rules limiting reachability to +approved peers. The PowerToys compatibility ports must never be exposed directly +to the public internet. + +This standard follows the risk-management structure of NIST SP 800-218 (SSDF), +uses OpenSSF Scorecard for repository supply-chain signals, and treats automated +checks as necessary evidence rather than proof that no vulnerability exists. + +## Threat model + +| Asset or boundary | Primary threats | Required controls | +| --- | --- | --- | +| Keyboard, pointer, and clipboard stream | Passive capture, tampering, replay, peer impersonation | Trusted LAN/VPN, host firewall, strong shared key, authenticated machine-ID pinning after first approved session | +| Pairing secrets | Shell history, permissive files, diagnostics, process memory, stolen pairing export | Secret Service or owner-only file, atomic symlink-safe writes, diagnostics redaction, short-lived derived copies, manual review of exports | +| Network parsers | Malformed frames, oversized payloads, decompression bombs, memory-safety defects | Fixed packet sizes, 16 MiB payload and inflation caps, connection limits/timeouts, negative tests, ASan/UBSan, coverage-guided fuzzing | +| Linux input privilege | Compromised network peer reaching `/dev/uinput` or desktop input portals | Explicit OS permission, no privilege escalation, hardened user service, revocable feature grants | +| Android input privilege | Abuse of Accessibility, Shizuku, root, notification, or IME access | Per-feature user consent, strong relay secret, Android Keystore, AEAD session protocol, non-exported or permission-protected components | +| Build and release | Compromised Actions, dependency drift, unsigned artifacts, secret leakage | SHA-pinned Actions, Gradle distribution checksum, Dependabot, CodeQL, Scorecard, SBOM, provenance, checksums, signed publication artifacts | + +## Non-negotiable protocol boundary + +The legacy Mouse Without Borders protocol uses compatibility-mandated +AES-256-CBC framing without modern authenticated integrity. InputFlow adds +challenge validation, session source/destination checks, and stable remote +machine-ID pinning, but it cannot add AEAD or a MAC unilaterally without breaking +PowerToys interoperability. + +Therefore: + +- direct internet exposure is unsupported and is a release-blocking deployment error; +- first contact remains shared-secret authenticated trust-on-first-use; +- after approval, a peer address change is accepted only when the encrypted handshake reports the pinned machine ID; +- users needing hostile-network operation must place the connection inside a mutually authenticated VPN. + +## Mandatory automated gates + +Every production commit must pass: + +1. Ubuntu Release build, complete CTest suite, archive smoke test, and hardening checks. +2. Fedora package build and tests using the pinned container digest. +3. ASan and UBSan with failures treated as fatal. +4. Thirty-second libFuzzer smoke coverage of clipboard decompression, text/HTML parsing, socket headers, and protocol type dispatch. +5. Android release assembly, unit tests, lint, Gradle wrapper validation, and the pinned wrapper checksum. +6. CodeQL `security-extended` analysis for C/C++ and Java/Kotlin. +7. OpenSSF Scorecard analysis and GitHub secret scanning with push protection. +8. Release repository hygiene, privacy tests, SBOM, provenance, and SHA-256 checksums. + +No failed or skipped security gate may be waived silently. A waiver must name +the failed control, owner, compensating control, expiration date, and follow-up +issue in the release notes. + +## Manual production gates + +- Use a protected `main` branch with required status checks and resolved review conversations. +- Review changes to protocol, cryptography, secret handling, CI workflows, packaging, and privileged Android components separately from feature approval. +- Sign Linux release metadata and the Android APK with protected release keys; verify signatures before publication. Unsigned Android artifacts are test artifacts only. +- Run `INPUTFLOW_ANDROID_APK=/path/to/signed.apk scripts/production-release-gate.sh`; the production wrapper fails unless `apksigner` verifies the supplied APK. +- Run an outage/recovery soak and a real two-host interoperability test for every networking release. +- Resolve all critical/high CodeQL, dependency, and security-advisory findings before release. +- Obtain independent security review before claiming safety outside the trusted-LAN/private-VPN boundary. + +## Incident response and maintenance + +Private reports use GitHub Security Advisories as described in `SECURITY.md`. +Critical fixes receive a regression test whenever reproducible. Dependency and +GitHub Actions updates are proposed weekly. Threat-model and release-gate changes +are reviewed whenever a new network listener, privileged backend, data type, or +external build dependency is introduced. + +## Production claim + +Passing this standard supports the claim “production-ready for trusted LANs and +private VPNs.” It does not support “safe for arbitrary hostile networks” or +“formally verified.” Those claims require a mutually authenticated modern +transport, external penetration testing, and release-key operational controls +beyond this repository. diff --git a/packaging/usr/lib/systemd/user/mwb-client.service b/packaging/usr/lib/systemd/user/mwb-client.service index f358b9c..9aae829 100644 --- a/packaging/usr/lib/systemd/user/mwb-client.service +++ b/packaging/usr/lib/systemd/user/mwb-client.service @@ -2,13 +2,37 @@ Description=InputFlow Linux client Documentation=man:systemd.service(5) ConditionPathExists=%h/.config/mwb-client/config.ini +StartLimitIntervalSec=60s +StartLimitBurst=5 [Service] Type=simple ExecStart=/usr/bin/mwb_client run --config %h/.config/mwb-client/config.ini Restart=on-failure RestartSec=3s +TimeoutStopSec=15s NoNewPrivileges=true +UMask=0077 +PrivateTmp=true +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=true +RestrictSUIDSGID=true +RestrictRealtime=true +RestrictNamespaces=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +SystemCallArchitectures=native +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +LimitCORE=0 +LimitNOFILE=4096 +TasksMax=128 +MemoryMax=1G +MemorySwapMax=1G [Install] WantedBy=default.target diff --git a/scripts/generate-release-metadata.py b/scripts/generate-release-metadata.py index 530781b..43ea216 100644 --- a/scripts/generate-release-metadata.py +++ b/scripts/generate-release-metadata.py @@ -7,6 +7,7 @@ import datetime as dt import hashlib import json +import os import pathlib import platform import re @@ -95,10 +96,35 @@ def copy_artifact(source: pathlib.Path, destination: pathlib.Path) -> pathlib.Pa return target +def apk_is_signed(apk: pathlib.Path, repo: pathlib.Path) -> bool: + apksigner = shutil.which("apksigner") + if not apksigner: + sdk_root = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if sdk_root: + candidates = sorted( + pathlib.Path(sdk_root).glob("build-tools/*/apksigner"), + reverse=True, + ) + if candidates: + apksigner = str(candidates[0]) + if not apksigner: + return False + result = subprocess.run( + [apksigner, "verify", "--verbose", "--print-certs", str(apk)], + cwd=repo, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + return result.returncode == 0 + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--android-apk") + parser.add_argument("--require-signed-android", action="store_true") args = parser.parse_args() repo = pathlib.Path(__file__).resolve().parent.parent @@ -117,14 +143,21 @@ def main() -> int: ) if not apk_source.is_file(): raise SystemExit(f"Android release APK is missing: {apk_source}") + android_signed = apk_is_signed(apk_source, repo) + if args.require_signed_android and not android_signed: + raise SystemExit( + "Production release requires an APK verified by apksigner; " + "set INPUTFLOW_ANDROID_APK to the signed artifact" + ) copied = [ copy_artifact(archives[0], release_dir), copy_artifact(apk_source, release_dir), copy_artifact(repo / "CHANGELOG.md", release_dir), ] - copied[1].rename(release_dir / f"inputflow-android-{version}-unsigned.apk") - copied[1] = release_dir / f"inputflow-android-{version}-unsigned.apk" + android_suffix = "" if android_signed else "-unsigned" + copied[1].rename(release_dir / f"inputflow-android-{version}{android_suffix}.apk") + copied[1] = release_dir / f"inputflow-android-{version}{android_suffix}.apk" timestamp = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() native_binary = build_dir / "mwb_client" @@ -169,7 +202,7 @@ def main() -> int: "externalParameters": { "version": version, "linuxBuildType": "Release", - "androidArtifactSigned": False, + "androidArtifactSigned": android_signed, }, "internalParameters": {"workingTreeDirty": dirty}, "resolvedDependencies": ( @@ -197,7 +230,10 @@ def main() -> int: checksum_text = "".join(f"{sha256(path)} {path.name}\n" for path in checksum_paths) (release_dir / "SHA256SUMS").write_text(checksum_text, encoding="utf-8") print(f"release metadata generated in {release_dir}") - print("Android APK is unsigned; sign and verify it before publication.") + if android_signed: + print("Android APK signature verified with apksigner.") + else: + print("Android APK is unsigned; it is a test artifact and must not be published.") return 0 diff --git a/scripts/production-release-gate.sh b/scripts/production-release-gate.sh new file mode 100755 index 0000000..38dabfd --- /dev/null +++ b/scripts/production-release-gate.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" + +if [[ -z "${INPUTFLOW_ANDROID_APK:-}" ]]; then + echo "production release gate: INPUTFLOW_ANDROID_APK must name the signed APK" >&2 + exit 2 +fi + +export INPUTFLOW_REQUIRE_SIGNED_ANDROID=1 +exec "$REPO_ROOT/scripts/release-gate.sh" diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh index 9f1d3d3..dedf7c9 100755 --- a/scripts/release-gate.sh +++ b/scripts/release-gate.sh @@ -60,9 +60,15 @@ echo "[9/9] SBOM, provenance, and release checksums" if [[ "${INPUTFLOW_SKIP_ANDROID:-0}" == "1" ]]; then echo "Release metadata skipped because the Android artifact was skipped" else - python3 scripts/generate-release-metadata.py \ - --build-dir "$BUILD_DIR" \ - --android-apk android/app/build/outputs/apk/release/app-release-unsigned.apk + ANDROID_APK="${INPUTFLOW_ANDROID_APK:-android/app/build/outputs/apk/release/app-release-unsigned.apk}" + METADATA_ARGS=( + --build-dir "$BUILD_DIR" + --android-apk "$ANDROID_APK" + ) + if [[ "${INPUTFLOW_REQUIRE_SIGNED_ANDROID:-0}" == "1" ]]; then + METADATA_ARGS+=(--require-signed-android) + fi + python3 scripts/generate-release-metadata.py "${METADATA_ARGS[@]}" fi echo "InputFlow release gate passed" diff --git a/src/AppState.cpp b/src/AppState.cpp index 9212a4b..9ca58fd 100644 --- a/src/AppState.cpp +++ b/src/AppState.cpp @@ -113,6 +113,25 @@ std::string NormalizePeerName(std::string value) { return value; } +void AddUniquePreviousHost(PeerState& peer, const std::string& host) { + if (host.empty() || host == peer.host || + std::find(peer.previousHosts.begin(), peer.previousHosts.end(), host) != peer.previousHosts.end()) { + return; + } + + constexpr std::size_t kMaximumPreviousHosts = 8; + peer.previousHosts.push_back(host); + if (peer.previousHosts.size() > kMaximumPreviousHosts) { + peer.previousHosts.erase(peer.previousHosts.begin()); + } +} + +void MergePreviousHosts(PeerState& target, const PeerState& source) { + for (const auto& host : source.previousHosts) { + AddUniquePreviousHost(target, host); + } +} + } // namespace std::filesystem::path DefaultStatePath() { @@ -163,7 +182,7 @@ bool LoadAppState(const std::filesystem::path& path, AppState& state, std::strin fields.push_back(field); } - if (fields.size() != 6 && fields.size() != 7) { + if (fields.size() < 6 || fields.size() > 9) { errorMessage = "Invalid peer record on line " + std::to_string(lineNumber) + "."; return false; } @@ -174,9 +193,10 @@ bool LoadAppState(const std::filesystem::path& path, AppState& state, std::strin const auto port = ParsePort(fields[2]); const auto approved = ParseBool(fields[3]); - const auto connectedNow = (fields.size() == 7) ? ParseBool(fields[4]) : std::optional(false); - const auto lastSeen = ParseInt64(fields[fields.size() == 7 ? 5 : 4]); - const auto lastConnected = ParseInt64(fields[fields.size() == 7 ? 6 : 5]); + const bool hasConnectedField = fields.size() >= 7; + const auto connectedNow = hasConnectedField ? ParseBool(fields[4]) : std::optional(false); + const auto lastSeen = ParseInt64(fields[hasConnectedField ? 5 : 4]); + const auto lastConnected = ParseInt64(fields[hasConnectedField ? 6 : 5]); if (!port || !approved || !connectedNow || !lastSeen || !lastConnected) { errorMessage = "Invalid peer values on line " + std::to_string(lineNumber) + "."; return false; @@ -187,6 +207,22 @@ bool LoadAppState(const std::filesystem::path& path, AppState& state, std::strin peer.connectedNow = *connectedNow; peer.lastSeenEpochSeconds = *lastSeen; peer.lastConnectedEpochSeconds = *lastConnected; + if (fields.size() >= 8) { + const auto remoteMachineId = ParseMachineId(fields[7]); + if (!remoteMachineId) { + errorMessage = "Invalid peer machine id on line " + std::to_string(lineNumber) + "."; + return false; + } + peer.remoteMachineId = *remoteMachineId; + } + if (fields.size() >= 9) { + std::stringstream aliases(fields[8]); + std::string alias; + while (std::getline(aliases, alias, ',')) { + alias = Trim(std::move(alias)); + AddUniquePreviousHost(peer, alias); + } + } parsed.peers.push_back(std::move(peer)); } } @@ -206,7 +242,15 @@ bool SaveAppState(const std::filesystem::path& path, const AppState& state, std: << (peer.approved ? "true" : "false") << '\t' << (peer.connectedNow ? "true" : "false") << '\t' << peer.lastSeenEpochSeconds << '\t' - << peer.lastConnectedEpochSeconds << "\n"; + << peer.lastConnectedEpochSeconds << '\t' + << "0x" << std::hex << peer.remoteMachineId << std::dec << '\t'; + for (std::size_t index = 0; index < peer.previousHosts.size(); ++index) { + if (index != 0) { + output << ','; + } + output << peer.previousHosts[index]; + } + output << "\n"; } return WritePrivateFileAtomically(path, output.str(), errorMessage); @@ -228,9 +272,19 @@ uint32_t EnsureLocalMachineId(AppState& state) { void UpsertPeerState(AppState& state, const PeerState& peer) { auto existing = std::find_if(state.peers.begin(), state.peers.end(), [&](const PeerState& current) { - return current.host == peer.host && current.port == peer.port; + return peer.remoteMachineId != 0 && + current.remoteMachineId == peer.remoteMachineId && + current.port == peer.port; }); + if (existing == state.peers.end()) { + existing = std::find_if(state.peers.begin(), state.peers.end(), [&](const PeerState& current) { + const bool identitiesCompatible = peer.remoteMachineId == 0 || current.remoteMachineId == 0 || + current.remoteMachineId == peer.remoteMachineId; + return identitiesCompatible && current.host == peer.host && current.port == peer.port; + }); + } + if (existing == state.peers.end()) { PeerState toInsert = peer; if (toInsert.lastSeenEpochSeconds == 0) { @@ -240,9 +294,19 @@ void UpsertPeerState(AppState& state, const PeerState& peer) { return; } + if (peer.remoteMachineId != 0 && existing->remoteMachineId == peer.remoteMachineId && + !peer.host.empty() && existing->host != peer.host) { + const std::string previousHost = existing->host; + existing->host = peer.host; + AddUniquePreviousHost(*existing, previousHost); + } if (!peer.name.empty()) { existing->name = peer.name; } + if (peer.remoteMachineId != 0) { + existing->remoteMachineId = peer.remoteMachineId; + } + MergePreviousHosts(*existing, peer); existing->approved = existing->approved || peer.approved; existing->connectedNow = peer.connectedNow; existing->lastSeenEpochSeconds = std::max(existing->lastSeenEpochSeconds, peer.lastSeenEpochSeconds); @@ -292,6 +356,7 @@ void MarkSessionEstablished( const std::string& host, int port, const std::string& remoteName, + uint32_t remoteMachineId, uint32_t localMachineId, std::int64_t epochSeconds) { if (localMachineId != 0) { @@ -299,7 +364,9 @@ void MarkSessionEstablished( } ClearConnectedPeers(state); - RemoveStalePeerAddressesForName(state, remoteName, host, port); + if (remoteMachineId == 0) { + RemoveStalePeerAddressesForName(state, remoteName, host, port); + } PeerState peer; peer.host = host; @@ -309,6 +376,7 @@ void MarkSessionEstablished( peer.connectedNow = true; peer.lastSeenEpochSeconds = epochSeconds; peer.lastConnectedEpochSeconds = epochSeconds; + peer.remoteMachineId = remoteMachineId; UpsertPeerState(state, peer); } diff --git a/src/AppState.h b/src/AppState.h index bc59933..a52496d 100644 --- a/src/AppState.h +++ b/src/AppState.h @@ -4,11 +4,32 @@ #include #include #include +#include #include namespace mwb { struct PeerState { + PeerState() = default; + PeerState(std::string hostValue, + std::string nameValue, + int portValue, + bool approvedValue, + bool connectedNowValue, + std::int64_t lastSeenValue, + std::int64_t lastConnectedValue, + uint32_t remoteMachineIdValue = 0, + std::vector previousHostsValue = {}) + : host(std::move(hostValue)), + name(std::move(nameValue)), + port(portValue), + approved(approvedValue), + connectedNow(connectedNowValue), + lastSeenEpochSeconds(lastSeenValue), + lastConnectedEpochSeconds(lastConnectedValue), + remoteMachineId(remoteMachineIdValue), + previousHosts(std::move(previousHostsValue)) {} + std::string host; std::string name; int port{15101}; @@ -16,6 +37,8 @@ struct PeerState { bool connectedNow{false}; std::int64_t lastSeenEpochSeconds{0}; std::int64_t lastConnectedEpochSeconds{0}; + uint32_t remoteMachineId{0}; + std::vector previousHosts; }; struct AppState { @@ -40,6 +63,7 @@ void MarkSessionEstablished( const std::string& host, int port, const std::string& remoteName, + uint32_t remoteMachineId, uint32_t localMachineId, std::int64_t epochSeconds); void MarkSessionDisconnected(AppState& state); diff --git a/src/ClientRuntime.cpp b/src/ClientRuntime.cpp index 3930faa..bd2556f 100644 --- a/src/ClientRuntime.cpp +++ b/src/ClientRuntime.cpp @@ -385,6 +385,7 @@ int ClientRuntime::Run() { m_options.reconnectInitialBackoffMs, m_options.reconnectMaxBackoffMs, m_options.reconnectIdleRetryMs); + m_network->SetExpectedRemoteMachineId(m_options.expectedRemoteMachineId); if (m_options.resolveHost) { m_network->SetHostResolver(m_options.resolveHost); } diff --git a/src/ClientRuntime.h b/src/ClientRuntime.h index 189534e..0b4839d 100644 --- a/src/ClientRuntime.h +++ b/src/ClientRuntime.h @@ -38,6 +38,7 @@ struct RuntimeOptions { bool mprisMediaKeysEnabled{true}; std::string mprisPlayer; std::optional localMachineId; + uint32_t expectedRemoteMachineId{0}; std::string localMachineName; bool debugInputLogging{false}; bool debugKeyLogging{false}; @@ -51,9 +52,9 @@ struct RuntimeOptions { AndroidRelayOptions androidRelay; std::function onSessionEstablished; std::function onSessionDisconnected; - // Re-resolves the peer address when reconnect attempts stall (returns a - // fresh host/IP via discovery, or std::nullopt if none is better). - std::function()> resolveHost; + // Discovers candidate endpoints for the intended approved peer. The + // network layer authenticates each candidate's machine id before use. + std::function resolveHost; }; class ClientRuntime { diff --git a/src/CryptoHelper.cpp b/src/CryptoHelper.cpp index c81220b..662af99 100644 --- a/src/CryptoHelper.cpp +++ b/src/CryptoHelper.cpp @@ -1,19 +1,83 @@ #include "CryptoHelper.h" +#include #include -#include -#include +#include +#include #include #include -#include #include namespace mwb { +namespace { -CryptoHelper::CryptoHelper(const std::string& securityKey) : m_securityKey(securityKey), m_encryptCtx(nullptr), m_decryptCtx(nullptr) { - if (m_securityKey.size() > static_cast(std::numeric_limits::max())) { +class CleanseGuard { +public: + CleanseGuard(void* data, std::size_t size) : m_data(data), m_size(size) {} + ~CleanseGuard() { OPENSSL_cleanse(m_data, m_size); } + + CleanseGuard(const CleanseGuard&) = delete; + CleanseGuard& operator=(const CleanseGuard&) = delete; + +private: + void* m_data; + std::size_t m_size; +}; + +void Cleanse(std::vector& value) { + if (!value.empty()) { + OPENSSL_cleanse(value.data(), value.size()); + } +} + +uint32_t Compute24BitHash(const std::string& securityKey) { + std::array bytes{}; + std::array hashValue{}; + CleanseGuard bytesGuard(bytes.data(), bytes.size()); + CleanseGuard hashGuard(hashValue.data(), hashValue.size()); + + for (std::size_t index = 0; index < bytes.size() && index < securityKey.size(); ++index) { + bytes[index] = static_cast(securityKey[index]); + } + + using DigestContext = std::unique_ptr; + DigestContext context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!context) { + throw std::runtime_error("SHA512 context allocation failed"); + } + + unsigned int length = 0; + if (EVP_DigestInit_ex(context.get(), EVP_sha512(), nullptr) != 1 || + EVP_DigestUpdate(context.get(), bytes.data(), bytes.size()) != 1 || + EVP_DigestFinal_ex(context.get(), hashValue.data(), &length) != 1 || + length != hashValue.size()) { + throw std::runtime_error("SHA512 hash failed"); + } + + for (int iteration = 0; iteration < 50000; ++iteration) { + if (EVP_DigestInit_ex(context.get(), EVP_sha512(), nullptr) != 1 || + EVP_DigestUpdate(context.get(), hashValue.data(), hashValue.size()) != 1 || + EVP_DigestFinal_ex(context.get(), hashValue.data(), &length) != 1 || + length != hashValue.size()) { + throw std::runtime_error("SHA512 hash failed"); + } + } + + // Match C# Encryption.Get24BitHash exactly. + return (static_cast(hashValue[0]) << 23) + + (static_cast(hashValue[1]) << 16) + + (static_cast(hashValue[63]) << 8) + + static_cast(hashValue[2]); +} + +} // namespace + +CryptoHelper::CryptoHelper(const std::string& securityKey) : m_encryptCtx(nullptr), m_decryptCtx(nullptr) { + if (securityKey.size() > static_cast(std::numeric_limits::max())) { throw std::runtime_error("Security key is too large"); } + m_magicHash = Compute24BitHash(securityKey); + std::string ivStr = "1844674407370955"; m_iv.resize(16); std::memcpy(m_iv.data(), ivStr.data(), 16); @@ -26,10 +90,11 @@ CryptoHelper::CryptoHelper(const std::string& securityKey) : m_securityKey(secur } m_key.resize(32); - if (!PKCS5_PBKDF2_HMAC(m_securityKey.c_str(), static_cast(m_securityKey.length()), + if (!PKCS5_PBKDF2_HMAC(securityKey.c_str(), static_cast(securityKey.length()), salt.data(), static_cast(salt.size()), 50000, EVP_sha512(), 32, m_key.data())) { + Cleanse(m_key); throw std::runtime_error("PBKDF2 HMAC Failed"); } @@ -42,6 +107,8 @@ CryptoHelper::CryptoHelper(const std::string& securityKey) : m_securityKey(secur EVP_CIPHER_CTX_free(m_decryptCtx); m_decryptCtx = nullptr; } + Cleanse(m_key); + Cleanse(m_iv); throw std::runtime_error(message); }; @@ -63,47 +130,12 @@ CryptoHelper::CryptoHelper(const std::string& securityKey) : m_securityKey(secur CryptoHelper::~CryptoHelper() { if (m_encryptCtx) EVP_CIPHER_CTX_free(m_encryptCtx); if (m_decryptCtx) EVP_CIPHER_CTX_free(m_decryptCtx); + Cleanse(m_key); + Cleanse(m_iv); } -uint32_t CryptoHelper::Get24BitHash() { - std::vector bytes(32, 0); - for (size_t i = 0; i < 32 && i < m_securityKey.length(); i++) { - bytes[i] = static_cast(m_securityKey[i]); - } - - std::vector hashValue(64); - unsigned int len = 0; - EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); - if (mdctx == nullptr) { - throw std::runtime_error("SHA512 context allocation failed"); - } - - if (EVP_DigestInit_ex(mdctx, EVP_sha512(), nullptr) != 1 || - EVP_DigestUpdate(mdctx, bytes.data(), bytes.size()) != 1 || - EVP_DigestFinal_ex(mdctx, hashValue.data(), &len) != 1 || - len != hashValue.size()) { - EVP_MD_CTX_free(mdctx); - throw std::runtime_error("SHA512 hash failed"); - } - - for (int i = 0; i < 50000; i++) { - if (EVP_DigestInit_ex(mdctx, EVP_sha512(), nullptr) != 1 || - EVP_DigestUpdate(mdctx, hashValue.data(), hashValue.size()) != 1 || - EVP_DigestFinal_ex(mdctx, hashValue.data(), &len) != 1 || - len != hashValue.size()) { - EVP_MD_CTX_free(mdctx); - throw std::runtime_error("SHA512 hash failed"); - } - } - EVP_MD_CTX_free(mdctx); - - // Match C# Encryption.Get24BitHash exactly: - // return (uint)((hashValue[0] << 23) + (hashValue[1] << 16) + (hashValue[^1] << 8) + hashValue[2]); - uint32_t magic = (static_cast(hashValue[0]) << 23) + - (static_cast(hashValue[1]) << 16) + - (static_cast(hashValue[63]) << 8) + - static_cast(hashValue[2]); - return magic; +uint32_t CryptoHelper::Get24BitHash() const { + return m_magicHash; } void CryptoHelper::Reset() { diff --git a/src/CryptoHelper.h b/src/CryptoHelper.h index 74d5811..5987f4a 100644 --- a/src/CryptoHelper.h +++ b/src/CryptoHelper.h @@ -18,13 +18,13 @@ class CryptoHelper { bool EncryptStream(const std::vector& plaintext, std::vector& ciphertext); bool DecryptStream(const std::vector& ciphertext, std::vector& plaintext); - uint32_t Get24BitHash(); + uint32_t Get24BitHash() const; void Reset(); private: - std::string m_securityKey; std::vector m_key; std::vector m_iv; + uint32_t m_magicHash{0}; EVP_CIPHER_CTX* m_encryptCtx; EVP_CIPHER_CTX* m_decryptCtx; diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index f548149..8556e9e 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -979,7 +979,7 @@ void NetworkManager::SetReconnectBackoff(int initialBackoffMs, int maxBackoffMs, m_reconnectIdleRetryMs = policy.idleRetryMs; } -void NetworkManager::SetHostResolver(std::function()> resolver) { +void NetworkManager::SetHostResolver(std::function resolver) { m_hostResolver = std::move(resolver); } @@ -993,22 +993,39 @@ bool NetworkManager::TryRefreshHostFromResolver() { return false; } - const auto resolved = m_hostResolver(); - if (!resolved || resolved->empty()) { + PeerHostResolution resolution = m_hostResolver(); + if (resolution.expectedRemoteMachineId != 0) { + m_expectedRemoteMachineId = resolution.expectedRemoteMachineId; + } + + const std::string currentHost = HostSnapshot(); + m_resolvedHostCandidates.clear(); + for (auto& candidate : resolution.candidateHosts) { + if (candidate.empty() || candidate == currentHost || + std::find(m_resolvedHostCandidates.begin(), m_resolvedHostCandidates.end(), candidate) != + m_resolvedHostCandidates.end()) { + continue; + } + m_resolvedHostCandidates.push_back(std::move(candidate)); + } + return TryNextResolvedHost(); +} + +bool NetworkManager::TryNextResolvedHost() { + if (m_resolvedHostCandidates.empty()) { return false; } + const std::string resolved = std::move(m_resolvedHostCandidates.front()); + m_resolvedHostCandidates.erase(m_resolvedHostCandidates.begin()); { std::lock_guard lock(m_hostMutex); - if (m_host == *resolved) { - return false; - } - std::cout << "[RECONNECT] Peer address changed via rediscovery; resetting backoff." + std::cout << "[RECONNECT] Trying a rediscovered peer endpoint; resetting backoff." << std::endl; - m_host = *resolved; + m_host = resolved; } - if (const auto resolvedAddress = ResolveConfiguredHostAddress(*resolved); resolvedAddress.has_value()) { + if (const auto resolvedAddress = ResolveConfiguredHostAddress(resolved); resolvedAddress.has_value()) { m_expectedPeerAddress = *resolvedAddress; } else { m_expectedPeerAddress = 0; @@ -1878,6 +1895,10 @@ void NetworkManager::RunLoop() { reconnectState = InitialReconnectState(reconnectPolicy); } if (!ConnectOutbound(outboundChallenge)) { + if (TryNextResolvedHost()) { + reconnectState = InitialReconnectState(reconnectPolicy); + continue; + } const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); const int delayMs = AddReconnectJitter(scheduledBackoffMs); std::cout << "[RECONNECT] Peer unavailable. Retrying in " << delayMs << " ms." << std::endl; @@ -1897,6 +1918,11 @@ void NetworkManager::RunLoop() { } closeSocket(m_socket); + if (TryNextResolvedHost()) { + reconnectState = InitialReconnectState(reconnectPolicy); + continue; + } + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); const int delayMs = AddReconnectJitter(scheduledBackoffMs); std::cout << "[RECONNECT] Protocol error (noise). Retrying in " << delayMs << " ms." << std::endl; @@ -1913,6 +1939,11 @@ void NetworkManager::RunLoop() { fprintf(stderr, "[OUTBOUND] Failed to decrypt server noise\n"); closeSocket(m_socket); + if (TryNextResolvedHost()) { + reconnectState = InitialReconnectState(reconnectPolicy); + continue; + } + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); const int delayMs = AddReconnectJitter(scheduledBackoffMs); std::cout << "[RECONNECT] Crypto error (noise). Retrying in " << delayMs << " ms." << std::endl; @@ -2023,6 +2054,11 @@ void NetworkManager::RunLoop() { fprintf(stderr, "[OUTBOUND] Handshake failed, will reconnect\n"); closeSocket(m_socket); + if (TryNextResolvedHost()) { + reconnectState = InitialReconnectState(reconnectPolicy); + continue; + } + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); const int delayMs = AddReconnectJitter(scheduledBackoffMs); std::cout << "[RECONNECT] Handshake failed. Retrying in " << delayMs << " ms." << std::endl; @@ -2033,8 +2069,39 @@ void NetworkManager::RunLoop() { continue; } + if (m_expectedRemoteMachineId != 0 && m_desId != m_expectedRemoteMachineId) { + std::cerr << "[SECURITY] Rediscovered endpoint authenticated as unexpected machine id 0x" + << std::hex << m_desId << "; expected 0x" << m_expectedRemoteMachineId + << std::dec << ". Rejecting candidate." << std::endl; + closeSocket(m_socket); + { + std::lock_guard lock(m_sendMutex); + m_sessionId = 0; + m_desId = 0; + m_handshakeDone = false; + m_remoteName.clear(); + m_crypto.Reset(); + } + if (TryNextResolvedHost()) { + reconnectState = InitialReconnectState(reconnectPolicy); + continue; + } + + const int scheduledBackoffMs = ScheduledReconnectDelayMs(reconnectPolicy, reconnectState); + const int delayMs = AddReconnectJitter(scheduledBackoffMs); + std::cout << "[RECONNECT] No candidate matched the approved peer identity. Retrying in " + << delayMs << " ms." << std::endl; + reconnectState = AdvanceReconnectAfterFailure(reconnectPolicy, reconnectState); + if (!ReconnectSleep(delayMs)) { + break; + } + continue; + } + printf("[SUCCESS] MWB Session Established. Entering Main Loop.\n"); fflush(stdout); + m_expectedRemoteMachineId = m_desId; + m_resolvedHostCandidates.clear(); reconnectState = ResetReconnectAfterSuccess(reconnectPolicy); if (!remoteName.empty()) { m_remoteName = remoteName; diff --git a/src/NetworkManager.h b/src/NetworkManager.h index 535aa92..fd69a50 100644 --- a/src/NetworkManager.h +++ b/src/NetworkManager.h @@ -17,6 +17,11 @@ namespace mwb { +struct PeerHostResolution { + uint32_t expectedRemoteMachineId{0}; + std::vector candidateHosts; +}; + class NetworkManager { public: NetworkManager(const std::string& host, int port, const std::string& key); @@ -35,10 +40,11 @@ class NetworkManager { void SetAutoConnectEnabled(bool enabled) { m_autoConnectEnabled = enabled; } void SetReconnectBackoff(int initialBackoffMs, int maxBackoffMs, int idleRetryMs); - // Optional callback used to re-resolve the peer address (e.g. via LAN - // discovery) when reconnect attempts stall. Returns a fresh host/IP, or - // std::nullopt when no better address is known. - void SetHostResolver(std::function()> resolver); + void SetExpectedRemoteMachineId(uint32_t machineId) { m_expectedRemoteMachineId = machineId; } + // Optional callback used to discover candidate endpoints for the approved + // peer when reconnect attempts stall. Candidates are authenticated against + // expectedRemoteMachineId before a session can be established. + void SetHostResolver(std::function resolver); bool Connect(); void RunLoop(); bool SendMouse(const MouseData& mouse); @@ -107,8 +113,10 @@ class NetworkManager { int m_reconnectInitialBackoffMs{1000}; int m_reconnectMaxBackoffMs{30000}; int m_reconnectIdleRetryMs{30000}; + uint32_t m_expectedRemoteMachineId{0}; + std::vector m_resolvedHostCandidates; - std::function()> m_hostResolver; + std::function m_hostResolver; std::atomic m_networkChanged{false}; int m_netlinkFd{-1}; @@ -116,6 +124,7 @@ class NetworkManager { std::string HostSnapshot(); bool TryRefreshHostFromResolver(); + bool TryNextResolvedHost(); void StartNetworkChangeWatcher(); void NetworkChangeWatcherLoop(); bool ReconnectSleep(int delayMs); diff --git a/src/PeerRecovery.cpp b/src/PeerRecovery.cpp index 4095a1d..00329d5 100644 --- a/src/PeerRecovery.cpp +++ b/src/PeerRecovery.cpp @@ -161,7 +161,9 @@ std::vector CollectRecoveryPeerNames(const AppState& state, } if (IsIpv4Literal(configuredHost)) { - if (peer.host != configuredHost) { + if (peer.host != configuredHost && + std::find(peer.previousHosts.begin(), peer.previousHosts.end(), configuredHost) == + peer.previousHosts.end()) { continue; } } else if (!HostLabelsMatch(peer.name, configuredHost) && !HostLabelsMatch(peer.host, configuredHost)) { @@ -301,8 +303,178 @@ std::vector CollectRecoveryDiscoveredHosts(const AppState& state, return hosts; } +std::vector CollectRecoveryUnidentifiedHosts( + const AppState& state, + std::string_view configuredHost, + int port, + const std::vector& candidates) { + const std::vector recoveryNames = CollectRecoveryPeerNames(state, configuredHost, port); + if (recoveryNames.empty()) { + return {}; + } + + std::vector normalizedNames; + normalizedNames.reserve(recoveryNames.size()); + for (const auto& name : recoveryNames) { + const std::string normalized = NormalizeHostLabel(name); + if (!normalized.empty()) { + normalizedNames.push_back(normalized); + } + } + + std::vector hosts; + for (const auto& candidate : candidates) { + if (candidate.status != DiscoveryStatus::Open || + !candidate.hostName.empty() || + !IsIpv4Literal(candidate.ipAddress) || + candidate.ipAddress == configuredHost) { + continue; + } + + const auto knownPeer = std::find_if(state.peers.begin(), state.peers.end(), [&](const PeerState& peer) { + return peer.approved && peer.port == port && peer.host == candidate.ipAddress && !peer.name.empty(); + }); + if (knownPeer != state.peers.end()) { + const std::string normalized = NormalizeHostLabel(knownPeer->name); + if (std::find(normalizedNames.begin(), normalizedNames.end(), normalized) == normalizedNames.end()) { + continue; + } + } + + if (std::find(hosts.begin(), hosts.end(), candidate.ipAddress) == hosts.end()) { + hosts.push_back(candidate.ipAddress); + } + } + + return hosts; +} + +uint32_t FindExpectedRemoteMachineId(const AppState& state, + std::string_view configuredHost, + int port) { + uint32_t expectedRemoteMachineId = 0; + for (const auto& peer : state.peers) { + if (!peer.approved || peer.port != port || peer.remoteMachineId == 0) { + continue; + } + + bool matches = peer.host == configuredHost; + if (!matches) { + matches = std::find(peer.previousHosts.begin(), peer.previousHosts.end(), configuredHost) != + peer.previousHosts.end(); + } + if (!matches && !IsIpv4Literal(configuredHost)) { + matches = HostLabelsMatch(peer.name, configuredHost) || HostLabelsMatch(peer.host, configuredHost); + } + if (!matches) { + continue; + } + + if (expectedRemoteMachineId != 0 && expectedRemoteMachineId != peer.remoteMachineId) { + return 0; + } + expectedRemoteMachineId = peer.remoteMachineId; + } + return expectedRemoteMachineId; +} + +namespace { + +void AddUniqueCandidate(std::vector& hosts, + std::string_view configuredHost, + const std::string& candidate) { + if (candidate.empty() || candidate == configuredHost || !IsIpv4Literal(candidate) || + std::find(hosts.begin(), hosts.end(), candidate) != hosts.end()) { + return; + } + hosts.push_back(candidate); +} + +} // namespace + +PeerRecoveryPlan BuildPeerRecoveryPlanFromCandidates( + const AppConfig& config, + const AppState& state, + const std::vector& candidates) { + PeerRecoveryPlan plan; + plan.expectedRemoteMachineId = FindExpectedRemoteMachineId(state, config.host, config.port); + + const auto knownHosts = CollectRecoveryCandidateHosts(state, config.host, config.port); + for (const auto& host : knownHosts) { + AddUniqueCandidate(plan.candidateHosts, config.host, host); + } + + if (plan.expectedRemoteMachineId != 0) { + std::vector identityMatches; + for (const auto& peer : state.peers) { + if (peer.approved && peer.port == config.port && + peer.remoteMachineId == plan.expectedRemoteMachineId) { + identityMatches.push_back(&peer); + } + } + std::stable_sort(identityMatches.begin(), identityMatches.end(), [](const PeerState* lhs, const PeerState* rhs) { + return lhs->lastConnectedEpochSeconds > rhs->lastConnectedEpochSeconds; + }); + for (const auto* peer : identityMatches) { + AddUniqueCandidate(plan.candidateHosts, config.host, peer->host); + for (const auto& previousHost : peer->previousHosts) { + AddUniqueCandidate(plan.candidateHosts, config.host, previousHost); + } + } + } + + for (const auto& host : CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { + AddUniqueCandidate(plan.candidateHosts, config.host, host); + } + + if (!IsIpv4Literal(config.host)) { + for (const auto& candidate : candidates) { + if (candidate.status == DiscoveryStatus::Open && + HostLabelsMatch(candidate.hostName, config.host)) { + AddUniqueCandidate(plan.candidateHosts, config.host, candidate.ipAddress); + } + } + } + + if (plan.expectedRemoteMachineId != 0) { + // Every open candidate is safe to try because NetworkManager validates + // the authenticated handshake machine id before entering the session. + for (const auto& candidate : candidates) { + if (candidate.status == DiscoveryStatus::Open) { + AddUniqueCandidate(plan.candidateHosts, config.host, candidate.ipAddress); + } + } + } else { + const auto unidentifiedHosts = + CollectRecoveryUnidentifiedHosts(state, config.host, config.port, candidates); + if (unidentifiedHosts.size() == 1) { + AddUniqueCandidate(plan.candidateHosts, config.host, unidentifiedHosts.front()); + } + } + + return plan; +} + +PeerRecoveryPlan BuildPeerRecoveryPlan(const AppConfig& config, const AppState& state) { + DiscoveryOptions discoveryOptions; + discoveryOptions.port = static_cast(config.port); + discoveryOptions.connectTimeoutMs = 200; + discoveryOptions.maxHostsPerSubnet = 256; + return BuildPeerRecoveryPlanFromCandidates(config, state, DiscoverLanCandidates(discoveryOptions)); +} + std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& config, const AppState& state) { + const PeerRecoveryPlan identityPlan = BuildPeerRecoveryPlan(config, state); + for (const auto& host : identityPlan.candidateHosts) { + if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { + if (identityPlan.expectedRemoteMachineId != 0) { + std::cout << "[RECOVERY] Trying a candidate for the approved peer identity." << std::endl; + } + return reachable; + } + } + const bool configuredHostIsIpv4 = IsIpv4Literal(config.host); const auto knownPeerHosts = CollectRecoveryCandidateHosts(state, config.host, config.port); for (const auto& host : knownPeerHosts) { @@ -378,6 +550,18 @@ std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& } } + // Some Windows hosts accept the control connection but do not expose a + // resolvable LAN name. If discovery leaves exactly one open, unnamed + // candidate after known other peers are excluded, let the authenticated + // session handshake confirm it instead of remaining stuck on a stale IP. + const auto unidentifiedHosts = + CollectRecoveryUnidentifiedHosts(state, config.host, config.port, candidates); + if (unidentifiedHosts.size() == 1) { + std::cout << "[RECOVERY] Trying the sole unidentified peer address; session authentication will verify it." + << std::endl; + return unidentifiedHosts.front(); + } + return std::nullopt; } diff --git a/src/PeerRecovery.h b/src/PeerRecovery.h index 7d0d02d..929e34d 100644 --- a/src/PeerRecovery.h +++ b/src/PeerRecovery.h @@ -11,6 +11,11 @@ namespace mwb { +struct PeerRecoveryPlan { + uint32_t expectedRemoteMachineId{0}; + std::vector candidateHosts; +}; + bool IsIpv4Literal(std::string_view host); std::string NormalizeHostLabel(std::string_view value); bool HostLabelsMatch(std::string_view lhs, std::string_view rhs); @@ -29,6 +34,18 @@ std::vector CollectRecoveryDiscoveredHosts(const AppState& state, std::string_view configuredHost, int port, const std::vector& candidates); +std::vector CollectRecoveryUnidentifiedHosts(const AppState& state, + std::string_view configuredHost, + int port, + const std::vector& candidates); +uint32_t FindExpectedRemoteMachineId(const AppState& state, + std::string_view configuredHost, + int port); +PeerRecoveryPlan BuildPeerRecoveryPlanFromCandidates( + const AppConfig& config, + const AppState& state, + const std::vector& candidates); +PeerRecoveryPlan BuildPeerRecoveryPlan(const AppConfig& config, const AppState& state); std::optional RecoverConfiguredHostFromKnownPeers(const AppConfig& config, const AppState& state); diff --git a/src/TrayController.cpp b/src/TrayController.cpp index 62e4f32..e018187 100644 --- a/src/TrayController.cpp +++ b/src/TrayController.cpp @@ -969,10 +969,12 @@ int RunTrayAndGui(const std::string& binary, options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled; options.onSessionEstablished = [&](const std::string& host, int port, - const std::string& remoteName, uint32_t, uint32_t localMachineId) { + const std::string& remoteName, uint32_t remoteMachineId, + uint32_t localMachineId) { { std::lock_guard lock(stateMutex); - MarkSessionEstablished(state, host, port, remoteName, localMachineId, CurrentEpochSeconds()); + MarkSessionEstablished( + state, host, port, remoteName, remoteMachineId, localMachineId, CurrentEpochSeconds()); SaveStateOrLog(statePath, state); } PostStatus(&context, mainWin, "active", host + ":" + std::to_string(port) + " (" + remoteName + ")"); @@ -1007,9 +1009,12 @@ int RunTrayAndGui(const std::string& binary, // DHCP lease is rediscovered without restarting the daemon. if (PowerToysCompatibilityEnabled(runtimeConfig.connectionMode)) { const AppConfig resolverConfig = config; - options.resolveHost = [resolverConfig, &state, &stateMutex]() -> std::optional { + options.expectedRemoteMachineId = + FindExpectedRemoteMachineId(state, resolverConfig.host, resolverConfig.port); + options.resolveHost = [resolverConfig, &state, &stateMutex]() -> PeerHostResolution { std::lock_guard lock(stateMutex); - return RecoverConfiguredHostFromKnownPeers(resolverConfig, state); + PeerRecoveryPlan plan = BuildPeerRecoveryPlan(resolverConfig, state); + return PeerHostResolution{plan.expectedRemoteMachineId, std::move(plan.candidateHosts)}; }; } diff --git a/src/main.cpp b/src/main.cpp index 8dd778e..7fdbb58 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1020,48 +1020,7 @@ std::optional ProbeReachableIpv4Host(const std::string& host, int p std::optional TryRecoverHostFromKnownPeers(const mwb::AppConfig& config, const mwb::AppState& state) { - const bool configuredHostIsIpv4 = mwb::IsIpv4Literal(config.host); - const auto knownPeerHosts = mwb::CollectRecoveryCandidateHosts(state, config.host, config.port); - for (const auto& host : knownPeerHosts) { - if (auto reachable = ProbeReachableIpv4Host(host, config.port, 250)) { - std::cout << "[RECOVERY] Found a verified approved peer address"; - if (configuredHostIsIpv4) { - std::cout << "; using name-priority recovery before trusting the configured IP"; - } else { - std::cout << "; reusing verified peer address"; - } - std::cout << std::endl; - return reachable; - } - } - - if (configuredHostIsIpv4 && ProbeReachableIpv4Host(config.host, config.port, 200).has_value()) { - return std::nullopt; - } - - mwb::DiscoveryOptions discoveryOptions; - discoveryOptions.port = static_cast(config.port); - discoveryOptions.connectTimeoutMs = 200; - discoveryOptions.maxHostsPerSubnet = 256; - const auto candidates = mwb::DiscoverLanCandidates(discoveryOptions); - if (!mwb::IsIpv4Literal(config.host)) { - for (const auto& candidate : candidates) { - if (candidate.status != mwb::DiscoveryStatus::Open || - candidate.hostName.empty() || - !mwb::IsIpv4Literal(candidate.ipAddress) || - !mwb::HostLabelsMatch(candidate.hostName, config.host)) { - continue; - } - std::cout << "[RECOVERY] Resolved the approved peer through LAN discovery." << std::endl; - return candidate.ipAddress; - } - } - for (const auto& host : mwb::CollectRecoveryDiscoveredHosts(state, config.host, config.port, candidates)) { - std::cout << "[RECOVERY] Using the verified address of an approved peer." << std::endl; - return host; - } - - return std::nullopt; + return mwb::RecoverConfiguredHostFromKnownPeers(config, state); } std::int64_t CurrentEpochSeconds() { @@ -1269,9 +1228,10 @@ int RunClient(const mwb::AppConfig& config, options.androidRelay.androidDeviceWidth = runtimeConfig.androidDeviceWidth; options.androidRelay.androidDeviceHeight = runtimeConfig.androidDeviceHeight; options.androidRelay.notificationSyncEnabled = runtimeConfig.notificationSyncEnabled; - options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, uint32_t, uint32_t localMachineId) { + options.onSessionEstablished = [&](const std::string& host, int port, const std::string& remoteName, + uint32_t remoteMachineId, uint32_t localMachineId) { std::lock_guard lock(stateMutex); - mwb::MarkSessionEstablished(state, host, port, remoteName, localMachineId, CurrentEpochSeconds()); + mwb::MarkSessionEstablished(state, host, port, remoteName, remoteMachineId, localMachineId, CurrentEpochSeconds()); (void)SaveStateOrReport(statePath, state); }; options.onSessionDisconnected = [&]() { @@ -1279,6 +1239,16 @@ int RunClient(const mwb::AppConfig& config, mwb::MarkSessionDisconnected(state); (void)SaveStateOrReport(statePath, state); }; + if (mwb::PowerToysCompatibilityEnabled(runtimeConfig.connectionMode)) { + const mwb::AppConfig resolverConfig = config; + options.expectedRemoteMachineId = + mwb::FindExpectedRemoteMachineId(state, resolverConfig.host, resolverConfig.port); + options.resolveHost = [resolverConfig, &state, &stateMutex]() -> mwb::PeerHostResolution { + std::lock_guard lock(stateMutex); + mwb::PeerRecoveryPlan plan = mwb::BuildPeerRecoveryPlan(resolverConfig, state); + return mwb::PeerHostResolution{plan.expectedRemoteMachineId, std::move(plan.candidateHosts)}; + }; + } mwb::ClientRuntime runtime(std::move(options)); diff --git a/tests/fuzz/clipboard.dict b/tests/fuzz/clipboard.dict new file mode 100644 index 0000000..178a713 --- /dev/null +++ b/tests/fuzz/clipboard.dict @@ -0,0 +1,11 @@ +txt="TXT" +html="HTM" +rtf="RTF" +image="IMG" +separator="{4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F}" +start_html="StartHTML:" +end_html="EndHTML:" +start_fragment="StartFragment:" +end_fragment="EndFragment:" +fragment_open="" +fragment_close="" diff --git a/tests/fuzz_clipboard_payload.cpp b/tests/fuzz_clipboard_payload.cpp new file mode 100644 index 0000000..7d5803b --- /dev/null +++ b/tests/fuzz_clipboard_payload.cpp @@ -0,0 +1,33 @@ +#include "ClipboardManager.h" +#include "Protocol.h" + +#include +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, std::size_t size) { + if (data == nullptr || size > mwb::kClipboardSocketMaxSize) { + return 0; + } + + const std::vector input(data, data + size); + (void)mwb::ClipboardManager::DecodePayload(input); + (void)mwb::ClipboardManager::DecodeTextPayload(input); + (void)mwb::ClipboardManager::DecodeImagePayload(input); + + std::vector header(mwb::kClipboardSocketHeaderSize, 0); + const std::size_t headerBytes = std::min(header.size(), input.size()); + std::copy_n(input.begin(), headerBytes, header.begin()); + std::size_t payloadSize = 0; + std::string kind; + (void)mwb::ClipboardManager::DecodeSocketHeader(header, payloadSize, kind); + + if (!input.empty()) { + const uint8_t type = input.front(); + (void)mwb::isBigPackage(type); + (void)mwb::bigPacketCarriesMachineName(type); + } + return 0; +} diff --git a/tests/fuzz_smoke_driver.cpp b/tests/fuzz_smoke_driver.cpp new file mode 100644 index 0000000..af10b5d --- /dev/null +++ b/tests/fuzz_smoke_driver.cpp @@ -0,0 +1,27 @@ +#include +#include +#include +#include + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, std::size_t size); + +int main() { + std::vector input; + uint32_t state = 0x9e3779b9U; + for (std::size_t iteration = 0; iteration < 4096; ++iteration) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + const std::size_t size = state % 4097; + input.resize(size); + for (std::size_t index = 0; index < input.size(); ++index) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + input[index] = static_cast(state); + } + LLVMFuzzerTestOneInput(input.data(), input.size()); + } + std::cout << "Deterministic parser fuzz smoke passed\n"; + return 0; +} diff --git a/tests/test_ci_security.py b/tests/test_ci_security.py new file mode 100755 index 0000000..cffbfa0 --- /dev/null +++ b/tests/test_ci_security.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Prevent mutable CI dependencies and unsafe trigger regressions.""" + +from __future__ import annotations + +import pathlib +import re +import sys + + +ACTION_REF = re.compile(r"^[-A-Za-z0-9_.]+/[-A-Za-z0-9_.]+(?:/[-A-Za-z0-9_.]+)?@([0-9a-f]{40})$") +CONTAINER_REF = re.compile(r"^[^\s]+@sha256:[0-9a-f]{64}$") + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: test_ci_security.py WORKFLOW_DIR") + workflow_dir = pathlib.Path(sys.argv[1]) + failures: list[str] = [] + for path in sorted(workflow_dir.glob("*.y*ml")): + text = path.read_text(encoding="utf-8") + if "pull_request_target:" in text: + failures.append(f"{path}: pull_request_target is forbidden") + if not re.search(r"^permissions:", text, re.MULTILINE): + failures.append(f"{path}: explicit top-level permissions are required") + for line_number, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("uses:"): + action = stripped.split(":", 1)[1].strip().split(" #", 1)[0] + if not ACTION_REF.fullmatch(action): + failures.append( + f"{path}:{line_number}: action must use an immutable 40-character SHA: {action}" + ) + if stripped.startswith("image:"): + image = stripped.split(":", 1)[1].strip() + if not CONTAINER_REF.fullmatch(image): + failures.append( + f"{path}:{line_number}: container must use an immutable sha256 digest: {image}" + ) + if failures: + print("CI security contract failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + return 1 + print("CI security contract passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_crypto_helper.cpp b/tests/test_crypto_helper.cpp new file mode 100644 index 0000000..50fa8fb --- /dev/null +++ b/tests/test_crypto_helper.cpp @@ -0,0 +1,81 @@ +#include "CryptoHelper.h" + +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +void Expect(bool condition, const char* message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++g_failures; + } +} + +void TestHashCompatibilityAndStability() { + mwb::CryptoHelper first("production-test-key"); + mwb::CryptoHelper second("production-test-key"); + mwb::CryptoHelper different("production-test-key-2"); + + Expect(first.Get24BitHash() == second.Get24BitHash(), + "The compatibility hash must be stable for the same key"); + Expect(first.Get24BitHash() != different.Get24BitHash(), + "Different test keys should not share the compatibility hash"); +} + +void TestStreamRoundTripAndReset() { + mwb::CryptoHelper encryptor("production-test-key"); + mwb::CryptoHelper decryptor("production-test-key"); + const std::vector plaintext(64, 0x5a); + std::vector ciphertext; + std::vector decoded; + + Expect(encryptor.EncryptStream(plaintext, ciphertext), + "A block-aligned payload should encrypt"); + Expect(ciphertext != plaintext, "Ciphertext should not equal plaintext"); + Expect(decryptor.DecryptStream(ciphertext, decoded), + "A matching stream should decrypt"); + Expect(decoded == plaintext, "The decrypted stream should match the input"); + + encryptor.Reset(); + decryptor.Reset(); + std::vector resetCiphertext; + std::vector resetDecoded; + Expect(encryptor.EncryptStream(plaintext, resetCiphertext), + "Encryption should work after resetting stream state"); + Expect(resetCiphertext == ciphertext, + "Reset must preserve the PowerToys-compatible stream initialization"); + Expect(decryptor.DecryptStream(resetCiphertext, resetDecoded) && resetDecoded == plaintext, + "Decryption should work after resetting stream state"); +} + +void TestRejectsInvalidBlockLengths() { + mwb::CryptoHelper crypto("production-test-key"); + std::vector output; + + Expect(!crypto.EncryptStream({}, output), "Empty plaintext should be rejected"); + Expect(!crypto.EncryptStream(std::vector(15, 0), output), + "Non-block-aligned plaintext should be rejected"); + Expect(!crypto.DecryptStream({}, output), "Empty ciphertext should be rejected"); + Expect(!crypto.DecryptStream(std::vector(17, 0), output), + "Non-block-aligned ciphertext should be rejected"); +} + +} // namespace + +int main() { + TestHashCompatibilityAndStability(); + TestStreamRoundTripAndReset(); + TestRejectsInvalidBlockLengths(); + + if (g_failures != 0) { + std::cerr << g_failures << " crypto helper test(s) failed\n"; + return 1; + } + std::cout << "Crypto helper tests passed\n"; + return 0; +} diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 12b6859..b692582 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -443,6 +444,8 @@ void TestAppStateRoundTrip() { true, 111, 222, + 0x89abcdefU, + {"192.0.2.106", "windows-box.local"}, }); const std::filesystem::path path = MakeTempPath("mwb-state-test.ini"); @@ -461,6 +464,30 @@ void TestAppStateRoundTrip() { Expect(peer.connectedNow, "State peer connectedNow round-trip"); Expect(peer.lastSeenEpochSeconds == 111, "State peer lastSeen round-trip"); Expect(peer.lastConnectedEpochSeconds == 222, "State peer lastConnected round-trip"); + Expect(peer.remoteMachineId == 0x89abcdefU, "State remote machine id round-trip"); + Expect(peer.previousHosts.size() == 2, "State previous host aliases round-trip"); + } + std::error_code ignore; + std::filesystem::remove(path, ignore); +} + +void TestLegacyAppStateMigration() { + const std::filesystem::path path = MakeTempPath("mwb-state-legacy-test.ini"); + { + std::ofstream output(path); + output << "local_machine_id=0x1234abcd\n" + << "peer=192.0.2.107\twindows-box\t15101\ttrue\tfalse\t111\t222\n"; + } + + mwb::AppState loaded; + std::string error; + Expect(mwb::LoadAppState(path, loaded, error), "Legacy seven-field state should migrate"); + Expect(loaded.peers.size() == 1, "Legacy state should retain its peer"); + if (!loaded.peers.empty()) { + Expect(loaded.peers.front().remoteMachineId == 0, + "Legacy peer should remain identity-unknown until authentication"); + Expect(loaded.peers.front().previousHosts.empty(), + "Legacy peer should start without address aliases"); } std::error_code ignore; std::filesystem::remove(path, ignore); @@ -535,6 +562,28 @@ void TestUpsertPeerState() { Expect(peer.lastSeenEpochSeconds == 200, "UpsertPeerState should keep newest lastSeen"); Expect(peer.lastConnectedEpochSeconds == 300, "UpsertPeerState should keep newest lastConnected"); } + + mwb::AppState identityState; + mwb::PeerState original{"192.0.2.161", "desktop", 15101, true, false, 100, 200}; + original.remoteMachineId = 0x10203040U; + mwb::UpsertPeerState(identityState, original); + mwb::PeerState moved{"192.0.2.160", "desktop", 15101, true, true, 300, 300}; + moved.remoteMachineId = 0x10203040U; + mwb::UpsertPeerState(identityState, moved); + Expect(identityState.peers.size() == 1, + "UpsertPeerState should merge endpoints by authenticated machine id"); + if (!identityState.peers.empty()) { + const auto& peer = identityState.peers.front(); + Expect(peer.host == "192.0.2.160", "Identity merge should adopt the current address"); + Expect(peer.previousHosts.size() == 1 && peer.previousHosts.front() == "192.0.2.161", + "Identity merge should retain the stale configured address as an alias"); + } + + mwb::PeerState replacement{"192.0.2.160", "replacement", 15101, true, true, 400, 400}; + replacement.remoteMachineId = 0xaabbccddU; + mwb::UpsertPeerState(identityState, replacement); + Expect(identityState.peers.size() == 2, + "A reused address with a different authenticated identity must not overwrite the original peer"); } void TestSessionStateTransitions() { @@ -543,7 +592,8 @@ void TestSessionStateTransitions() { state.peers.push_back(mwb::PeerState{"192.0.2.10", "old", 15101, true, true, 100, 200}); state.peers.push_back(mwb::PeerState{"192.0.2.11", "other", 15101, true, true, 100, 200}); - mwb::MarkSessionEstablished(state, "192.0.2.12", 15101, "fresh", 0x22222222u, 300); + mwb::MarkSessionEstablished( + state, "192.0.2.12", 15101, "fresh", 0xabcdef01u, 0x22222222u, 300); Expect(state.localMachineId == 0x22222222u, "Session establishment should update the local machine id"); int connectedPeers = 0; @@ -559,6 +609,8 @@ void TestSessionStateTransitions() { Expect(connectedPeer->host == "192.0.2.12", "Session establishment should mark the connected host"); Expect(connectedPeer->name == "fresh", "Session establishment should store the remote name"); Expect(connectedPeer->approved, "Session establishment should approve the authenticated peer"); + Expect(connectedPeer->remoteMachineId == 0xabcdef01u, + "Session establishment should persist the authenticated remote machine id"); Expect(connectedPeer->lastConnectedEpochSeconds == 300, "Session establishment should update last connected time"); } @@ -718,6 +770,100 @@ void TestCollectRecoveryDiscoveredHostsUsesApprovedNamesOnly() { } } +void TestCollectRecoveryUnidentifiedHostsExcludesKnownOtherPeers() { + mwb::AppState state; + state.peers.push_back(mwb::PeerState{"192.0.2.107", "WORK-PC", 15101, true, false, 100, 300}); + state.peers.push_back(mwb::PeerState{"192.0.2.254", "OTHER-PC", 15101, true, false, 110, 400}); + + std::vector candidates; + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.160", "", false, "eth0", mwb::DiscoveryStatus::Open, + }); + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.254", "", false, "eth0", mwb::DiscoveryStatus::Open, + }); + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.199", "NAMED-PC", false, "eth0", mwb::DiscoveryStatus::Open, + }); + + const auto hosts = + mwb::CollectRecoveryUnidentifiedHosts(state, "192.0.2.107", 15101, candidates); + Expect(hosts.size() == 1, + "Unnamed recovery should exclude cached addresses belonging to another approved peer"); + if (!hosts.empty()) { + Expect(hosts.front() == "192.0.2.160", + "Unnamed recovery should retain the sole unclaimed candidate for authenticated probing"); + } +} + +void TestIdentityAwareRecoveryPlanForMovedPeer() { + mwb::AppConfig config; + config.host = "192.0.2.161"; + config.port = 15101; + + mwb::AppState state; + mwb::PeerState intended{"192.0.2.160", "M0491", 15101, true, false, 200, 300}; + intended.remoteMachineId = 0x10203040U; + intended.previousHosts = {"192.0.2.161"}; + state.peers.push_back(intended); + mwb::PeerState other{"192.0.2.254", "OTHER-PC", 15101, true, false, 250, 400}; + other.remoteMachineId = 0x55667788U; + state.peers.push_back(other); + + std::vector candidates; + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.160", "", false, "eth0", mwb::DiscoveryStatus::Open, + }); + candidates.push_back(mwb::DiscoveryCandidate{ + "192.0.2.254", "", false, "eth0", mwb::DiscoveryStatus::Open, + }); + + const auto plan = mwb::BuildPeerRecoveryPlanFromCandidates(config, state, candidates); + Expect(plan.expectedRemoteMachineId == 0x10203040U, + "Recovery should derive the intended identity from a previous configured address"); + Expect(!plan.candidateHosts.empty() && plan.candidateHosts.front() == "192.0.2.160", + "Recovery should prefer the last authenticated endpoint for the intended identity"); + Expect(std::find(plan.candidateHosts.begin(), plan.candidateHosts.end(), "192.0.2.254") != + plan.candidateHosts.end(), + "Identity-aware recovery may probe other open endpoints because the handshake rejects a mismatch"); +} + +void TestAmbiguousConfiguredAddressHasNoExpectedIdentity() { + mwb::AppState state; + mwb::PeerState first{"192.0.2.160", "FIRST", 15101, true, false, 100, 100}; + first.remoteMachineId = 0x11111111U; + first.previousHosts = {"192.0.2.161"}; + state.peers.push_back(first); + mwb::PeerState second{"192.0.2.170", "SECOND", 15101, true, false, 100, 100}; + second.remoteMachineId = 0x22222222U; + second.previousHosts = {"192.0.2.161"}; + state.peers.push_back(second); + + Expect(mwb::FindExpectedRemoteMachineId(state, "192.0.2.161", 15101) == 0, + "Conflicting identity aliases must not select a peer automatically"); +} + +void TestIdentityAwareHostnameRecoveryIncludesUnnamedCandidates() { + mwb::AppConfig config; + config.host = "M0491"; + config.port = 15101; + + mwb::AppState state; + mwb::PeerState intended{"192.0.2.160", "M0491", 15101, true, false, 200, 300}; + intended.remoteMachineId = 0x10203040U; + state.peers.push_back(intended); + + const std::vector candidates = { + {"192.0.2.170", "", false, "eth0", mwb::DiscoveryStatus::Open}, + }; + const auto plan = mwb::BuildPeerRecoveryPlanFromCandidates(config, state, candidates); + Expect(plan.expectedRemoteMachineId == 0x10203040U, + "Hostname recovery should resolve the saved authenticated machine id"); + Expect(std::find(plan.candidateHosts.begin(), plan.candidateHosts.end(), "192.0.2.170") != + plan.candidateHosts.end(), + "Hostname recovery should probe unnamed endpoints when identity verification is available"); +} + void TestDiscoveryZeroHosts() { mwb::DiscoveryOptions options; options.maxHostsPerSubnet = 0; @@ -783,6 +929,7 @@ int main() { TestParseAppConfigKeyFileOverridesInlineKey(); TestParseAppConfigKeySecretIdOverridesKeyAndKeyFile(); TestAppStateRoundTrip(); + TestLegacyAppStateMigration(); TestPrivateFileSecurity(); TestEnsureLocalMachineIdStable(); TestUpsertPeerState(); @@ -795,6 +942,10 @@ int main() { TestCollectRecoveryCandidateHostsForConfiguredIpv4(); TestCollectRecoveryCandidateHostsForConfiguredHostname(); TestCollectRecoveryDiscoveredHostsUsesApprovedNamesOnly(); + TestCollectRecoveryUnidentifiedHostsExcludesKnownOtherPeers(); + TestIdentityAwareRecoveryPlanForMovedPeer(); + TestAmbiguousConfiguredAddressHasNoExpectedIdentity(); + TestIdentityAwareHostnameRecoveryIncludesUnnamedCandidates(); TestDiscoveryZeroHosts(); TestKScreenDoctorSingleOutputGeometry(); TestKScreenDoctorMultiOutputBoundingBox(); diff --git a/tests/test_systemd_hardening.py b/tests/test_systemd_hardening.py new file mode 100755 index 0000000..619dd26 --- /dev/null +++ b/tests/test_systemd_hardening.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Enforce the security contract of the packaged user service.""" + +from __future__ import annotations + +import pathlib +import sys + + +def parse_service(path: pathlib.Path) -> dict[str, list[str]]: + values: dict[str, list[str]] = {} + section = "" + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith(("#", ";")): + continue + if line.startswith("[") and line.endswith("]"): + section = line[1:-1] + continue + if "=" not in line: + raise ValueError(f"invalid unit line: {raw_line}") + key, value = line.split("=", 1) + values.setdefault(f"{section}.{key}", []).append(value) + return values + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: test_systemd_hardening.py SERVICE") + values = parse_service(pathlib.Path(sys.argv[1])) + required = { + "Service.NoNewPrivileges": "true", + "Service.UMask": "0077", + "Service.PrivateTmp": "true", + "Service.LockPersonality": "true", + "Service.RestrictSUIDSGID": "true", + "Service.RestrictRealtime": "true", + "Service.RestrictNamespaces": "true", + "Service.SystemCallArchitectures": "native", + "Service.LimitCORE": "0", + } + failures = [ + f"{key} must be {expected}" + for key, expected in required.items() + if values.get(key) != [expected] + ] + families = set(" ".join(values.get("Service.RestrictAddressFamilies", [])).split()) + expected_families = {"AF_UNIX", "AF_INET", "AF_INET6", "AF_NETLINK"} + if families != expected_families: + failures.append( + "RestrictAddressFamilies must allow only " + " ".join(sorted(expected_families)) + ) + if failures: + print("systemd hardening contract failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + return 1 + print("systemd hardening contract passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())