From 164439f99d58fa08764a2cdb4a16f072bd7fd243 Mon Sep 17 00:00:00 2001 From: bigph00t Date: Fri, 15 May 2026 11:29:56 -0700 Subject: [PATCH] fix: harden mobile daemon security --- .github/workflows/ci.yml | 33 + .github/workflows/release.yml | 94 + README.md | 42 +- SECURITY_AUDIT_ISSUE_33.md | 407 ++++ SECURITY_COMPLETION_AUDIT_ISSUE_33.md | 86 + ...RITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md | 120 + SECURITY_RELEASE_RUNBOOK_ISSUE_33.md | 327 +++ SECURITY_REMEDIATION_PLAN_ISSUE_33.md | 534 +++++ cli/Cargo.lock | 24 + cli/Cargo.toml | 3 + cli/README.md | 17 +- cli/src/auth.rs | 249 ++ cli/src/daemon.rs | 2014 ++++++++++++++--- cli/src/filesystem/config.rs | 77 +- cli/src/filesystem/operations.rs | 12 + cli/src/filesystem/platform.rs | 1 + cli/src/filesystem/security.rs | 57 +- cli/src/filesystem/tests.rs | 27 + cli/src/link.rs | 90 +- cli/src/main.rs | 156 +- cli/src/platform.rs | 15 +- cli/src/protocol.rs | 109 +- cli/src/pty_wrapper.rs | 163 +- cli/src/setup.rs | 305 ++- docs/ARCHITECTURE_QUICK_REFERENCE.md | 50 +- docs/SYSTEM_COMPONENT_MAP_DESKTOP.md | 13 +- docs/WINDOWS_SETUP.md | 14 +- install.sh | 65 +- scripts/test-installer-checksum.sh | 53 + 29 files changed, 4575 insertions(+), 582 deletions(-) create mode 100644 SECURITY_AUDIT_ISSUE_33.md create mode 100644 SECURITY_COMPLETION_AUDIT_ISSUE_33.md create mode 100644 SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md create mode 100644 SECURITY_RELEASE_RUNBOOK_ISSUE_33.md create mode 100644 SECURITY_REMEDIATION_PLAN_ISSUE_33.md create mode 100644 cli/src/auth.rs create mode 100644 scripts/test-installer-checksum.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a056278..8899d2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,12 @@ jobs: working-directory: cli run: cargo test + - name: Verify installer script + run: bash -n install.sh + + - name: Test installer checksum verification + run: bash scripts/test-installer-checksum.sh + cross-compile-check: name: Cross-compile check runs-on: ubuntu-latest @@ -82,3 +88,30 @@ jobs: if: matrix.target == 'aarch64-unknown-linux-gnu' working-directory: cli run: cross build --target ${{ matrix.target }} + + desktop-platform-check: + name: Desktop platform check (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: cli + + - name: Check desktop build + working-directory: cli + run: cargo check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa784d8..34874aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,61 @@ env: CARGO_TERM_COLOR: always jobs: + verify: + name: Security release gates + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: cli + + - name: Check CLI formatting + working-directory: cli + run: cargo fmt --check + + - name: Lint CLI + working-directory: cli + run: cargo clippy --all-targets -- -D warnings -A dead-code + + - name: Test CLI + working-directory: cli + run: cargo test + + - name: Verify installer script + run: bash -n install.sh + + - name: Test installer checksum verification + run: bash scripts/test-installer-checksum.sh + + - name: Verify tag matches crate version + run: | + python3 - <<'PY' + import os + import pathlib + import sys + import tomllib + + version = tomllib.loads(pathlib.Path("cli/Cargo.toml").read_text())["package"]["version"] + expected = f"v{version}" + actual = os.environ["GITHUB_REF_NAME"] + if actual != expected: + print(f"::error::Release tag {actual} must match cli/Cargo.toml version {expected}") + sys.exit(1) + PY + build: name: Build ${{ matrix.target }} + needs: verify runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -55,6 +108,11 @@ jobs: if: matrix.cross run: command -v cross || cargo install cross --git https://github.com/cross-rs/cross --tag v0.2.5 + - name: Test CLI on native runner + if: ${{ !matrix.cross }} + working-directory: cli + run: cargo test --release + - name: Build (native) if: ${{ !matrix.cross }} working-directory: cli @@ -113,11 +171,47 @@ jobs: mkdir release find artifacts -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec cp {} release/ \; ls -la release/ + expected=( + "mobilecli-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz" + "mobilecli-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz" + "mobilecli-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz" + "mobilecli-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz" + "mobilecli-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip" + ) + for file in "${expected[@]}"; do + test -f "release/$file" || { + echo "::error::Missing release archive: $file" + exit 1 + } + done + count="$(find release -maxdepth 1 -type f \( -name "*.tar.gz" -o -name "*.zip" \) | wc -l)" + test "$count" -eq "${#expected[@]}" || { + echo "::error::Expected ${#expected[@]} release archives, found $count" + exit 1 + } - name: Generate checksums working-directory: release run: | sha256sum * > SHA256SUMS.txt + expected=( + "mobilecli-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz" + "mobilecli-${{ github.ref_name }}-aarch64-unknown-linux-gnu.tar.gz" + "mobilecli-${{ github.ref_name }}-x86_64-apple-darwin.tar.gz" + "mobilecli-${{ github.ref_name }}-aarch64-apple-darwin.tar.gz" + "mobilecli-${{ github.ref_name }}-x86_64-pc-windows-msvc.zip" + ) + for file in "${expected[@]}"; do + grep -Eq " ${file}$" SHA256SUMS.txt || { + echo "::error::Missing checksum entry: $file" + exit 1 + } + done + line_count="$(wc -l < SHA256SUMS.txt | tr -d ' ')" + test "$line_count" -eq "${#expected[@]}" || { + echo "::error::Expected ${#expected[@]} checksum entries, found $line_count" + exit 1 + } cat SHA256SUMS.txt - name: Create Release diff --git a/README.md b/README.md index a1a0e5a..830b1b8 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ You kick off Claude Code on a large refactor. You go make coffee. You come back This happens constantly with AI coding assistants. They're powerful but need a human in the loop. That human doesn't need to be chained to a desk. -**MobileCLI streams your terminal to your phone over your local network.** When your AI assistant asks a question, requests tool access, or finishes a task, you get a push notification. Tap it, read the context, approve or deny, and go back to what you were doing. +**MobileCLI streams your terminal to your phone over a network path you control.** When your AI assistant asks a question, requests tool access, or finishes a task, you get a push notification. Tap it, read the context, approve or deny, and go back to what you were doing. -No cloud. No accounts. No relay servers. Just a direct WebSocket between your machine and your phone. +The terminal stream is served by a local daemon over WebSocket. Mobile clients pair with auth-v2 QR credentials and then prove possession with a challenge-response handshake before the daemon sends sessions, terminal output, filesystem data, or push-token registration. There is no MobileCLI terminal relay or account system, but push notifications are delivered through Expo's push service, and the daemon should still only be reachable from a trusted LAN, Tailscale network, or protected custom endpoint.
@@ -59,7 +59,7 @@ No cloud. No accounts. No relay servers. Just a direct WebSocket between your ma curl -fsSL https://mobilecli.app/install.sh | bash ``` -This downloads a single static binary and puts it on your PATH. The daemon is written in Rust — no runtime dependencies, no Docker, no Node.js. +This macOS/Linux installer downloads the matching GitHub Release archive, verifies it against that release's `SHA256SUMS.txt` manifest before extraction, and puts the binary on your PATH. Windows users should install from the GitHub Releases `.zip` or with Cargo. The checksum protects against a corrupted or tampered archive relative to the published release manifest; for stronger supply-chain assurance, inspect the source or build from source with Cargo.
Other install methods @@ -83,7 +83,7 @@ cd mobilecli/cli && cargo install --path . mobilecli setup ``` -This starts the daemon, generates a cryptographic auth token, and displays a QR code. Open the MobileCLI iOS app, tap **Scan QR Code**, and you're connected. The QR encodes a `ws://` URL with your token — no manual entry needed. +This starts the daemon, saves your connection mode, creates a fresh mobile credential, and displays a QR code. Open the MobileCLI iOS app, tap **Scan QR Code**, and you're connected. The QR encodes the `ws://` or `wss://` URL, device id/name metadata, credential id, server id, and one-time pairing token. The desktop stores only a derived verifier, not the raw token. ### 3. Start a session @@ -126,8 +126,8 @@ Your Machine Your Phone └─────────────────────────────────┘ │ Port 9847 (default) - Auth token required - Never leaves your network + Trusted network required + Protect from untrusted clients ``` The daemon allocates a PTY (pseudo-terminal) for each session, streams the byte output over WebSocket, and relays keyboard input from your phone back to the PTY. The mobile app renders the stream using a bundled xterm.js instance — full ANSI color, cursor positioning, and alternate screen buffer support. @@ -174,33 +174,34 @@ The Files tab gives you direct access to your dev machine's filesystem: - **Search** files by name across your entire project tree - **Edit** files with a built-in editor featuring Save/Undo/Redo, Markdown formatting shortcuts (Bold, Italic, Code, H1, List, Link), and syntax awareness - **Create** new files and folders from your phone +- **Destructive actions stay opt-in** — delete and rename are disabled by default in the daemon config and must be explicitly enabled during setup or config review - **Upload** photos, files, or camera captures from your phone to your dev machine — the daemon saves them and returns the desktop path so you can paste it into your terminal - **Git integration** — file listings show git status indicators ### Push notifications -Notifications are delivered through APNs (Apple Push Notification service). The daemon sends a push when: +Notifications are delivered through Expo's push notification service for the current iOS app. The daemon sends a push when: - An AI CLI enters a wait state (tool approval, plan review, question) - A session finishes or exits - A long-running command completes -The push token is registered over the WebSocket connection — no external services, no Firebase, no accounts. The daemon talks directly to APNs using your device token. +The push token is registered over the WebSocket connection and then used by the daemon to call Expo's push API. Notification payloads include the notification title/body and session id, not the full terminal stream.
## Privacy and security -MobileCLI is **fully self-hosted**. There is no cloud component. +MobileCLI keeps the terminal streaming path self-hosted, but the current iOS push-notification path uses Expo's cloud push service. -- **No relay servers.** Your terminal output travels directly from your machine to your phone over your local network. +- **No MobileCLI terminal relay.** Your terminal output is served by the daemon over your configured network path. - **No accounts.** No sign-up, no email, no OAuth. - **No telemetry.** The daemon collects nothing. -- **Auth token.** Every WebSocket connection requires a cryptographic token generated during `mobilecli setup`. The token is stored in iOS Keychain and `~/.mobilecli/config.json`. -- **Token stripping.** Auth tokens are scrubbed from session output before streaming, preventing accidental exposure. +- **Auth-v2 pairing.** Each mobile app stores a pairing token in SecureStore and authenticates with a challenge-response proof before receiving sessions or terminal data. Use `mobilecli pair --rotate` or `mobilecli credentials revoke ` to replace or revoke mobile access. +- **Network isolation still matters.** Keep port `9847` on a trusted LAN, Tailnet, firewall allowlist, or protected custom endpoint. Do not expose it directly to the public internet. - **Bounded resources.** The daemon limits concurrent connections, channel buffer sizes, and session counts to prevent resource exhaustion. -Your terminal output **never touches the internet** unless you explicitly configure Tailscale or a custom URL for remote access. +Your terminal stream does not go through MobileCLI-operated servers. If push notifications are enabled, Expo receives the notification title/body and session id. If you configure Tailscale or a custom remote URL, traffic follows that network provider or endpoint.
@@ -208,9 +209,9 @@ Your terminal output **never touches the internet** unless you explicitly config | Mode | How it works | Setup | |------|-------------|-------| -| **LAN** | Direct WebSocket over your WiFi/ethernet. Fastest and simplest. | Auto-detected during `mobilecli setup` | -| **Tailscale** | WireGuard mesh VPN. Access from anywhere, still peer-to-peer. | `mobilecli setup` → select your Tailscale IP | -| **Custom URL** | Your own proxy, port-forward, or TLS terminator. | Provide a `ws://` or `wss://` URL | +| **LAN** | WebSocket over your trusted WiFi/ethernet. Fastest and simplest. | Auto-detected during `mobilecli setup` | +| **Tailscale** | WireGuard-based mesh VPN. Access from your Tailnet without opening the daemon to the public internet. | `mobilecli setup` → select your Tailscale IP | +| **Custom URL** | Your own protected `ws://` or `wss://` endpoint, such as a private reverse proxy or TLS terminator. | Provide the URL during setup | For most users, LAN mode is all you need. Open a terminal, scan the QR, done. @@ -230,6 +231,9 @@ Session commands: Setup and management: mobilecli setup Interactive setup wizard (generates QR code) mobilecli pair Show QR code for pairing additional devices + mobilecli pair --rotate Revoke existing mobile credentials and pair again + mobilecli credentials list List paired mobile credentials without secrets + mobilecli credentials revoke Revoke one paired mobile credential mobilecli status Show daemon status, active sessions, connections mobilecli stop Stop the daemon @@ -319,7 +323,7 @@ All config lives in `~/.mobilecli/`: | File | Purpose | |------|---------| -| `config.json` | Device identity, connection URL, auth token hash | +| `config.json` | Device identity and connection mode/URL | | `sessions.json` | Persisted session metadata (names, history) | | `daemon.pid` | Running daemon's process ID | | `daemon.port` | Active WebSocket port (default: `9847`) | @@ -402,14 +406,14 @@ MobileCLI/ 2. **Daemon running?** Run `mobilecli status` to check. If not running, `mobilecli daemon` starts it. 3. **Firewall?** Ensure port `9847` (or whatever `~/.mobilecli/daemon.port` says) allows inbound TCP. 4. **Re-pair:** Run `mobilecli pair` to show a fresh QR code and scan it again. -5. **Check logs:** `~/.mobilecli/daemon.log` will show connection attempts and auth failures. +5. **Check logs:** `~/.mobilecli/daemon.log` will show connection attempts and connection errors.
No push notifications 1. Verify notifications are enabled for MobileCLI in iOS Settings. -2. The push token registers automatically when the WebSocket connects — check the Config tab shows "connected" status. +2. The push token registers automatically after the WebSocket auth-v2 handshake completes — check the Config tab shows "connected" status. 3. Notifications require the daemon to be running. If you restart your machine, make sure the daemon is back up (`mobilecli autostart install` handles this automatically).
diff --git a/SECURITY_AUDIT_ISSUE_33.md b/SECURITY_AUDIT_ISSUE_33.md new file mode 100644 index 0000000..97c88f5 --- /dev/null +++ b/SECURITY_AUDIT_ISSUE_33.md @@ -0,0 +1,407 @@ +# Security Audit — Issue #33: WebSocket Authentication + +> Operational note (2026-05-15): this file is retained as third-party audit evidence and claim verification. The remediation section later in this document proposed an older raw-token/hash design; that design is superseded by `SECURITY_REMEDIATION_PLAN_ISSUE_33.md` and `SECURITY_RELEASE_RUNBOOK_ISSUE_33.md`, which use auth-v2 challenge-response, credential scopes, filesystem hardening, and per-workspace release gates. + +**Audited:** 2026-05-15 +**Branch:** `main` (HEAD) +**Issue:** https://github.com/MobileCLI/mobilecli/issues/33 +**Auditor:** Claude Code (claude-sonnet-4-6), multi-agent codebase analysis + +--- + +## Executive Summary + +GitHub issue #33 alleges 6 security findings against the MobileCLI daemon. After a full source code audit, the **code-level findings are largely accurate** for the current `main` branch. However, the framing partially misses context: + +- The README's auth token claims are **aspirational documentation that was never implemented** — not a deliberate deception. +- The actual security model is **network-level isolation** (Tailscale VPN or LAN trust), which is intentional and documented in code comments, but not communicated to users anywhere they would read it. +- The daemon source explicitly states this intent in `daemon.rs:394`: + > *"Security model: Access is controlled at the network level via: Local network: Only devices on same WiFi can connect / Tailscale: Only authenticated Tailscale network members can connect"* + +This means: for Tailscale users, the security posture is reasonable (Tailscale membership = authentication). For local-network users on untrusted WiFi, it is not. The README is wrong either way. + +--- + +## Finding-by-Finding Verdict + +### Finding 1 — No Authentication (Critical) — **ACCURATE, missing context** + +**Issue claim:** `handle_connection` accepts any WebSocket with no token check. `Config` has no `auth_token`. `ConnectionInfo.encryption_key` is always `None`. QR code contains no secret. + +**Code confirms:** + +- `daemon.rs:394`: `TcpListener::bind(format!("0.0.0.0:{}", port))` — binds all interfaces unconditionally +- `daemon.rs:497–535`: `handle_connection` calls `rx.next().await` as first action — no auth check before or after +- `setup.rs:22–30`: `Config` struct fields are `device_id`, `device_name`, `connection_mode`, `tailscale_ip`, `local_ip` — **no `auth_token` field** +- `protocol.rs:602–619`: `ConnectionInfo` has `encryption_key: Option` that is **always `None`**, never set, never checked +- `protocol.rs:630–661`: QR encodes `mobilecli://host:port?device_id=UUID&device_name=HOSTNAME[&wss=1]` — **no secret of any kind** + +**What the README says (false):** +- Line 86: *"generates a cryptographic auth token… The QR encodes a `ws://` URL with your token"* +- Line 129: *"Auth token required"* (architecture diagram) +- Line 199: *"Every WebSocket connection requires a cryptographic token generated during `mobilecli setup`. The token is stored in iOS Keychain and `~/.mobilecli/config.json`."* +- Line 200: *"Token stripping. Auth tokens are scrubbed from session output before streaming."* +- Line 322: *"config.json | Device identity, connection URL, **auth token hash**"* +- `docs/ARCHITECTURE_QUICK_REFERENCE.md:84`: *"Pairing Token (Optional): A per-device `auth_token` is generated during setup"* + +**Actual `~/.mobilecli/config.json` on disk:** +```json +{ + "connection_mode": "tailscale", + "device_id": "4fbff292-8275-473c-b958-144198219418", + "device_name": "Sandman", + "local_ip": null, + "tailscale_ip": "100.120.208.57" +} +``` +No token. Never generated. + +**Verdict:** All README auth token claims are false. The security model is network isolation, not application-layer tokens. The daemon binds `0.0.0.0` and accepts all connections with zero application-layer checks. + +--- + +### Finding 2 — Unauthenticated RCE (Critical) — **ACCURATE, minor mechanism nuance** + +**Issue claim:** `is_shell_safe` (line 1139) only blocks `` ` ``, `$(`, `\n`, `\r`, `\0`. Attacker can spawn `bash` with `args: ["-c", "malicious"]`. + +**Code confirms — `daemon.rs:1139–1145`:** +```rust +fn is_shell_safe(s: &str) -> bool { + !s.contains('\n') + && !s.contains('\r') + && !s.contains('\0') + && !s.contains('`') + && !s.contains("$(") +} +``` +Semicolons, pipes, redirects, `&`, `||`, `&&` are **not blocked**. + +**Full allowed command list (`daemon.rs:1112–1128`):** +``` +claude, codex, gemini, opencode, bash, zsh, sh, fish, nu, pwsh, powershell, python, python3, node, ruby +``` + +**Nuance on `-c` flag:** The issue says pass `args: ["-c", "malicious"]`. In practice, `shell_args_for_command` (`daemon.rs:1173–1188`) internally appends `-l -i -c ` for shell commands. However, **the command string passed to `-c` is not sanitized for semicolons/pipes**. An attacker passes `bash` with a command string containing `;` to chain arbitrary commands. The exact mechanism is slightly different from what the issue describes but the RCE risk is real and confirmed. + +**Additional finding — `SendInput` (daemon.rs:2129–2160):** +- Any connected client can inject arbitrary keystrokes into **any session by session ID** +- **No session ownership check** exists +- No restriction on which session a client can target +- This includes injecting "yes" into AI tool approval prompts (Claude Code, Codex, Gemini) + +**Verdict:** RCE risk is real. `is_shell_safe` is insufficient. `SendInput` has no ownership enforcement. + +--- + +### Finding 3 — Unauthenticated File System Access (High) — **ACCURATE** + +**Code confirms — `cli/src/filesystem/config.rs:33–50`:** + +`allowed_roots` defaults to `home_dir()` — **full home directory access**. + +Current `denied_patterns`: +``` +**/.ssh/* *.pem *.key **/id_rsa* +**/.gnupg/* **/.aws/credentials +**/.env **/.env.* **/secrets.* *.secret +**/token* **/.npmrc **/.pypirc +``` + +Supported operations: **list, read (50 MB), read_chunk, write (50 MB), create_directory, delete (recursive), rename, copy, get_file_info** + +**Confirmed denylist gaps (not blocked):** +- `~/.kube/config` — Kubernetes cluster credentials +- `~/.docker/config.json` — Docker registry credentials +- `~/.config/gcloud/` — GCP credentials +- `~/.bash_history`, `~/.zsh_history` — shell command history +- `~/.psql_history`, `~/.python_history`, `~/.node_repl_history` — REPL histories +- `~/.config/google-chrome/Default/Login Data` — Chrome saved passwords +- `~/.mozilla/firefox/**/*.sqlite` — Firefox credentials +- `~/.git-credentials`, `~/.netrc` — git/HTTP credentials +- `~/.vault-token` — HashiCorp Vault token +- `~/*.tfstate` — Terraform state (contains secrets) +- `~/.aws/**` (only `credentials` is blocked, not the whole directory) + +**Verdict:** Issue is accurate. Delete is enabled, write is enabled. Denylist approach has significant gaps. + +--- + +### Finding 4 — Listens on All Interfaces (High) — **ACCURATE, INTENTIONAL** + +`daemon.rs:394`: `TcpListener::bind(format!("0.0.0.0:{}", port))` — no conditions, always all interfaces. + +The code comment explicitly justifies this with the network-isolation security model. The concern about public WiFi/shared office networks is valid for users in local mode who did not choose Tailscale. + +**Verdict:** Accurate and intentional. Risk depends on user's network context. + +--- + +### Finding 5 — Push Notification Hijacking (Medium) — **ACCURATE** + +**Code confirms:** +- `daemon.rs:2646–2670`: `RegisterPushToken` — any connected client can register, no verification, no limit +- `daemon.rs:4500–4549`: Sends to `https://exp.host/--/api/v2/push/send` (Expo Push API) +- Push payload includes: session ID, title, body, "waiting_for_input" type + +**README line 50:** *"No cloud. No accounts. No relay servers. Just a direct WebSocket between your machine and your phone."* + +This is **false** — Expo push uses an external cloud relay service. + +**Verdict:** Any client can register push tokens. "No cloud" claim is false for users with push notifications. The prompt content risk is slightly overstated — push body says "waiting for input" with session ID, not the actual prompt text. + +--- + +### Finding 6 — No Binary Integrity Verification (Medium) — **ACCURATE** + +- `install.sh:94`: Downloads binary from GitHub releases +- No SHA-256 verification anywhere in the script +- `install.sh:117–119`: Escalates to `sudo` with only a printed warning, no explicit confirmation prompt + +**Verdict:** Accurate. No checksum verification exists. + +--- + +## Summary Table + +| Finding | Verdict | Notes | +|---------|---------|-------| +| No auth token in code | TRUE | Never implemented | +| README auth token claims | TRUE — README is outdated/false | Aspirational docs, never built | +| RCE via SpawnSession + `is_shell_safe` gaps | TRUE | Semicolons/pipes not blocked | +| `-c` flag injection specifically | PARTIALLY TRUE | `-c` is added internally, but semicolons in command string still allow injection | +| `SendInput` injects into any session | TRUE | No ownership check | +| Filesystem: home dir default | TRUE | `allowed_roots = [home_dir()]` | +| Filesystem: denylist gaps | TRUE | Many sensitive paths uncovered | +| `0.0.0.0` binding | TRUE, INTENTIONAL | Necessary for mobile; risk depends on network | +| Push token registration unvalidated | TRUE | Any client registers | +| "No cloud" contradicted by Expo push | TRUE | Expo uses `exp.host` external service | +| `install.sh` no checksums | TRUE | Confirmed | +| `install.sh` silent sudo | PARTIALLY TRUE | Prints warning, no "proceed?" prompt | + +--- + +--- + +# Remediation Plan + +## Fix 1 — Implement Application-Layer Auth Token + +**Goal:** Build what the README claims. Generate a real token during setup, embed it in the QR code, verify it on every WebSocket connection. + +### CLI changes + +**`cli/src/setup.rs`** +- Add `auth_token_hash: String` field to `Config` struct +- During setup wizard: generate 32 cryptographically random bytes (`rand` or `getrandom` crate), hex-encode as raw token +- SHA-256 hash the raw token, store hex-encoded hash in `config.json` as `auth_token_hash` + +**`cli/src/protocol.rs`** +- Update `ConnectionInfo.to_compact_qr()` to append `&token={raw_token}` to QR URL +- Add `token: Option` field to `ConnectionInfo` +- Add `ClientMessage::Auth { token: String }` variant to client message enum + +**`cli/src/daemon.rs`** +- Load `config.json` at startup, read `auth_token_hash` +- In `handle_connection` (~line 497): expect first client message to be `ClientMessage::Auth { token }`. Compute `sha256hex(token)`, compare to stored hash. Close connection if mismatch. +- Add ~1 second constant-time delay on auth failure to resist timing attacks + +**`cli/Cargo.toml`** +- Add `sha2 = "0.10"` (or `ring`) +- Confirm `rand` or `getrandom` present + +### Mobile app changes (`mobile/`) + +- Parse `token` query param from deep-link/QR URL +- Store raw token in iOS Keychain (`kSecAttrService = "mobilecli"`) and Android Keystore +- On WebSocket connect, send `{ "type": "auth", "token": "" }` as **first message** before any other message + +--- + +## Fix 2 — Session Ownership for SendInput + +**Goal:** Only the client that spawned a session can inject input into it. + +**`cli/src/daemon.rs`** +- Generate `connection_id: Uuid` per WebSocket connection at start of `handle_connection` +- Add `owner_connection_id: Uuid` to the session struct +- In `SpawnSession` handler: set `session.owner_connection_id = connection_id` +- In `SendInput` handler (~line 2129): verify `connection_id == session.owner_connection_id`, reject if mismatch +- Apply same ownership check to `ResizeSession` and `KillSession` + +--- + +## Fix 3 — Strengthen `is_shell_safe` + +**`cli/src/daemon.rs` (line 1139)** + +Replace current implementation with: +```rust +fn is_shell_safe(s: &str) -> bool { + !s.contains('\n') + && !s.contains('\r') + && !s.contains('\0') + && !s.contains('`') + && !s.contains("$(") + && !s.contains(';') // command chaining + && !s.contains("||") // OR chaining + && !s.contains("&&") // AND chaining + && !s.contains('>') // stdout redirect + && !s.contains('<') // stdin redirect + && !s.contains('&') // background / AND + && !s.contains('|') // pipe +} +``` + +Also audit every call site to confirm `is_shell_safe` is applied to both the command string **and** each arg in `SpawnSession`. Apply it to args if not already done. + +--- + +## Fix 4 — Tighten Filesystem Denylist + +**`cli/src/filesystem/config.rs` (lines 36–50)** + +Append to `denied_patterns`: +```rust +// Kubernetes +"**/.kube/**", +// Docker +"**/.docker/config.json", +"**/.docker/contexts/**", +// Google Cloud +"**/.config/gcloud/**", +// Shell histories +"**/.bash_history", +"**/.zsh_history", +"**/.sh_history", +"**/.fish/fish_history", +"**/.local/share/fish/fish_history", +// REPL histories +"**/.psql_history", +"**/.python_history", +"**/.node_repl_history", +"**/.irb_history", +// Browser credentials (Linux) +"**/.config/google-chrome/Default/Login Data", +"**/.config/chromium/Default/Login Data", +"**/.mozilla/firefox/**/*.sqlite", +// macOS Keychain +"**/Library/Keychains/**", +// Git credentials +"**/.git-credentials", +"**/.config/git/credentials", +// netrc +"**/.netrc", +// Vault / 1Password +"**/.vault-token", +"**/.config/op/**", +// yarn tokens +"**/.yarnrc", +"**/.config/yarn/**", +// Terraform state (contains secrets) +"**/*.tfstate", +"**/*.tfstate.backup", +// Broader AWS (not just credentials file) +"**/.aws/**", +// age encryption keys +"**/*.age", +``` + +--- + +## Fix 5 — Add Checksum Verification to install.sh + +**`install.sh` (after the download block, ~line 94)** + +```bash +# Download checksum file published alongside the release binary +checksum_url="${download_url}.sha256" +download_file "${checksum_url}" "${tmp_dir}/${archive_name}.sha256" + +# Verify integrity +if command -v sha256sum >/dev/null 2>&1; then + (cd "${tmp_dir}" && sha256sum --check "${archive_name}.sha256") \ + || die "SHA-256 checksum verification failed — binary may be corrupt or tampered with" +elif command -v shasum >/dev/null 2>&1; then + (cd "${tmp_dir}" && shasum -a 256 -c "${archive_name}.sha256") \ + || die "SHA-256 checksum verification failed — binary may be corrupt or tampered with" +else + warn "Neither sha256sum nor shasum found — skipping checksum verification (not recommended)" +fi +``` + +**Also update the GitHub Actions release workflow** (`.github/workflows/release.yml` or equivalent) to generate and upload `{archive}.sha256` files alongside each release binary. + +--- + +## Fix 6 — Update README and Docs + +**`README.md`** — remove/replace these specific false claims: + +| Location | Remove | Replace with | +|----------|--------|--------------| +| Line 86 | "generates a cryptographic auth token… QR encodes a `ws://` URL with your token" | Accurate description after Fix 1 is live | +| Line 129 | "Auth token required" in architecture diagram | "Auth token required (256-bit, verified on connect)" | +| Line 199 | Entire auth token paragraph | Accurate description of the token system after Fix 1 | +| Line 200 | "Token stripping. Auth tokens are scrubbed from session output…" | Remove entirely | +| Line 322 | "auth token hash" in config.json table | Remove or update | + +**Add a "Security Model" section** explaining: +- **Token auth:** 256-bit token generated during `mobilecli setup`. Raw token embedded in QR. Hash stored in `config.json`. Required for every WebSocket connection. +- **Local mode:** Token auth + LAN access. Suitable for trusted home/office networks. +- **Tailscale mode:** Token auth + Tailscale membership. Recommended for remote/public access. +- **Push notifications:** The only outbound network call is Expo push (`exp.host`) for "waiting for input" alerts. No terminal output leaves your machine. + +**`docs/ARCHITECTURE_QUICK_REFERENCE.md:84`** — replace false "Pairing Token (Optional)" claim with accurate post-Fix 1 description. + +--- + +## Fix 7 — Push Token Rate Limiting + +**`cli/src/daemon.rs` (~line 2646)** + +- After Fix 1 lands, push token registration is already gated behind auth +- Add a cap: maximum 3 push tokens per authenticated `device_id` +- Scope push tokens to `device_id` so re-registration replaces rather than accumulates + +--- + +## Implementation Order + +| Priority | Fix | Scope | Notes | +|----------|-----|-------|-------| +| 1 | Fix 3 — `is_shell_safe` | Small, 1 function | Highest bang-for-buck, isolated change | +| 2 | Fix 4 — filesystem denylist | Small, 1 file | Low risk, append-only | +| 3 | Fix 6 — README/docs | No code changes | Fix the false claims immediately | +| 4 | Fix 5 — install.sh checksums | install.sh + release workflow | Self-contained | +| 5 | Fix 2 — session ownership | daemon.rs only | Moderate scope, no protocol change | +| 6 | Fix 1 — auth token system | daemon + protocol + mobile app | Largest; breaks existing clients; do last | +| 7 | Fix 7 — push rate limit | After Fix 1 | Trivial once Fix 1 is done | + +--- + +## Critical Files + +| File | Relevant Fixes | +|------|---------------| +| `cli/src/daemon.rs` | Fix 1, 2, 3, 7 — auth, ownership, is_shell_safe, push limit | +| `cli/src/setup.rs` | Fix 1 — token generation, Config struct | +| `cli/src/protocol.rs` | Fix 1 — QR encoding, ClientMessage::Auth | +| `cli/src/filesystem/config.rs` | Fix 4 — denylist extension | +| `cli/Cargo.toml` | Fix 1 — sha2/rand deps | +| `install.sh` | Fix 5 — checksum verification | +| `.github/workflows/release.yml` | Fix 5 — publish .sha256 files | +| `README.md` | Fix 6 — remove false claims, add Security Model | +| `docs/ARCHITECTURE_QUICK_REFERENCE.md` | Fix 6 — remove false claim line 84 | +| `mobile/` (WebSocket connect code) | Fix 1 — send Auth message, store token | + +--- + +## Verification Checklist + +- [ ] **Fix 1:** `websocat ws://localhost:9847` closes immediately with error. Client with correct token gets `Welcome`. +- [ ] **Fix 2:** Client A spawns session. Client B sends `SendInput` to that session ID → rejected. +- [ ] **Fix 3:** `SpawnSession { command: "bash", args: ["; echo pwned"] }` → rejected by `is_shell_safe`. +- [ ] **Fix 4:** Read `~/.kube/config` and `~/.bash_history` via file bridge → denied. +- [ ] **Fix 5:** Corrupt downloaded binary, run `install.sh` → fails with checksum error message. +- [ ] **Fix 6:** Full README read — no false auth token claims remain. +- [ ] **End-to-end:** Setup → QR scan → mobile connects with token → sessions work normally. diff --git a/SECURITY_COMPLETION_AUDIT_ISSUE_33.md b/SECURITY_COMPLETION_AUDIT_ISSUE_33.md new file mode 100644 index 0000000..3eb4851 --- /dev/null +++ b/SECURITY_COMPLETION_AUDIT_ISSUE_33.md @@ -0,0 +1,86 @@ +# Security Completion Audit - Issue #33 + +Date: 2026-05-15 +Status: not complete for release; local implementation and local verification are complete for the current checkout + +## Objective Restated + +The user asked for a thorough issue #33 remediation plan and implementation path that covers: + +- the truth of the GitHub issue #33 claims and Claude audit notes +- a completed remediation plan document +- all desktop daemon/CLI/security components +- all mobile app/security components +- docs, website, installer, and release workflows +- Linux, macOS, Windows, iOS, and Android compatibility +- use of subagents for speed/depth +- no premature coding-only conclusion before release/device gates are known + +## Prompt-To-Artifact Checklist + +| Requirement | Artifact or evidence | Current state | +| --- | --- | --- | +| Audit issue #33 claims | `SECURITY_AUDIT_ISSUE_33.md` plus local code review | Historical audit retained; operational note says raw-token remediation is superseded by auth-v2 plan | +| Complete remediation plan | `SECURITY_REMEDIATION_PLAN_ISSUE_33.md` | Updated with implemented local state, remaining release gates, transport limitations, and verification status | +| Executable release/device plan | `SECURITY_RELEASE_RUNBOOK_ISSUE_33.md` | Added host/mobile/network matrix, EAS/TestFlight/internal build order, evidence tables, and final sign-off table | +| Release evidence capture | `SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md` | Added a manifest/evidence template for commits, builds, artifacts, platform smoke, security smoke, and release sign-off | +| Desktop auth-v2 | CLI protocol/auth/daemon changes | Implemented locally with challenge-response, credential scopes, revocation, timeout/size cap, and unit coverage | +| Local PTY registration hardening | CLI daemon/wrapper/auth changes | Implemented local PTY proof so loopback clients cannot register unauthenticated PTY sessions; daemon-side validation has focused unit coverage | +| Desktop filesystem hardening | CLI filesystem/daemon/setup changes | Implemented project roots, denied secrets, destructive opt-in, safe empty roots, Windows-style pattern normalization, copy/search clamps | +| Desktop spawn hardening | CLI daemon changes | Mobile spawn uses supported profiles, rejects args/paths/interpreter flags, and defaults to approved roots instead of home | +| Desktop config secret handling | CLI setup/platform changes | Unix private writes, Windows ACL failures surfaced, no project-local config fallback when home is missing | +| Mobile auth-v2 | Mobile QR/auth/sync changes | Implemented QR auth fields, proof generation, pre-auth message gating, auth-state tests | +| Mobile credential storage | Mobile devices/storage changes | Pairing tokens are SecureStore-only per device; AsyncStorage stores metadata only; old inline tokens migrate/scrub | +| Mobile push behavior | Mobile sync/push changes | Push registration waits for auth; opt-out unregister state persists across app termination | +| Mobile native build need | Plan/runbook/checklist | New build required; current configured floor is iOS build `112`, Android versionCode `92` | +| Android release signing | Android Gradle/preflight | Release no longer signs with debug config; preflight fails if that pattern returns | +| Installer integrity | Root/website installer and release workflow | Installer verifies GitHub Release checksums; root and website scripts are byte-for-byte identical locally | +| Root release workflow | `.github/workflows/release.yml` | Adds CLI gates, tag/version sync, native release-runner tests, archive completeness, checksum completeness | +| Per-workspace CI | root/mobile/website workflows | Added/updated local workflow files; note mobile and website are separate nested workspaces and must be committed/released separately | +| Public docs accuracy | README/docs/website/LLM artifacts | Updated auth-v2/manual pairing/Tailscale/Expo/filesystem/install claims; stale command scan has no unsupported flag hits except intentional warnings | +| Subagent depth | Subagent results in session | Desktop, mobile, and docs/release subagents found residual blockers; local patch set addresses all actionable code findings except transport confidentiality | +| Linux compatibility | Local target check and CI matrix | `cargo check --target x86_64-unknown-linux-gnu` passed locally; release/manual Linux ARM64 still external | +| Windows compatibility | Local target check and docs/workflow | `cargo check --target x86_64-pc-windows-gnu` passed locally; real Windows smoke and ACL inspection still external | +| macOS compatibility | Workflow/runbook | Planned in CI/release and runbook; no local macOS runner evidence in this sandbox | +| iOS compatibility | Mobile build metadata/runbook | Build numbers updated and TypeScript/preflight pass; real TestFlight/device smoke still external | +| Android compatibility | Mobile build metadata/runbook | versionCode updated and preflight passes; production cleartext and signing evidence still external | + +## Fresh Local Verification Evidence + +Commands passed after the latest edits: + +- `cargo fmt --check` +- `cargo clippy --all-targets -- -D warnings -A dead-code` +- `cargo test` (`74 passed`) +- `cargo check --target x86_64-unknown-linux-gnu` +- `cargo check --target x86_64-pc-windows-gnu` +- `npx tsc --noEmit` in `mobile/` +- `npm run test:security` in `mobile/` +- `npm run preflight:release` in `mobile/` with expected warnings for unavailable EAS env access and dirty worktree +- `npm run build` in `website/` +- `bash -n install.sh` +- `bash -n website/public/install.sh` +- `diff -u install.sh website/public/install.sh` +- `bash scripts/test-installer-checksum.sh` +- workflow YAML parse across root, mobile, and website workflows +- `git diff --check`, `git -C mobile diff --check`, `git -C website diff --check` + +## Not Yet Achieved + +The overall release objective is not complete until these are done outside this sandbox: + +- iOS physical-device smoke on LAN and Tailscale +- Android physical-device smoke if Android is in release scope +- EAS/TestFlight/internal build evidence for the new mobile app build +- production Android cleartext decision and smoke evidence +- macOS Apple Silicon and Intel release-runner evidence +- Linux x86_64 and ARM64 release artifact smoke +- Windows x64 release artifact smoke and config ACL inspection +- actual GitHub Release `SHA256SUMS.txt` verification +- installer corrupt/missing checksum negative tests against draft release artifacts +- final transport security stance: trusted LAN/Tailscale only, or add `wss://`/message encryption before claiming MITM resistance +- final sign-off table in `SECURITY_RELEASE_RUNBOOK_ISSUE_33.md` + +## Completion Decision + +Do not mark the active goal complete yet. The local implementation and local verification are substantially complete, but the stated objective includes all devices and cross-platform compatibility. Those requirements still need physical-device, EAS/store, release-artifact, and real platform evidence. diff --git a/SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md b/SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md new file mode 100644 index 0000000..8b32d51 --- /dev/null +++ b/SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md @@ -0,0 +1,120 @@ +# Security Release Evidence Template - Issue #33 + +Copy this template for the actual release candidate evidence packet. Do not use it as a substitute for running `SECURITY_RELEASE_RUNBOOK_ISSUE_33.md`; this file is where the resulting evidence is recorded. + +## Release Manifest + +| Surface | Value | Evidence | +| --- | --- | --- | +| Root repository commit | | `git rev-parse HEAD` | +| Root release tag | | `git tag --points-at HEAD` or release URL | +| CLI crate version | | `cli/Cargo.toml` package version | +| Mobile repository commit | | `git -C mobile rev-parse HEAD` | +| iOS build | | EAS/TestFlight build URL and build number | +| Android build | | EAS/internal build URL and versionCode, or deferred decision | +| Website repository commit | | `git -C website rev-parse HEAD` | +| Website deploy URL | | deploy preview or production URL | +| Transport stance | | trusted LAN/Tailscale only, Tailscale-only, or `wss://`/message encryption | +| Android release stance | | public, internal-only, or deferred | + +## Local Preflight Evidence + +| Gate | Command | Result | Evidence path/link | +| --- | --- | --- | --- | +| CLI formatting | `cd cli && cargo fmt --check` | | | +| CLI lint | `cd cli && cargo clippy --all-targets -- -D warnings -A dead-code` | | | +| CLI tests | `cd cli && cargo test` | | | +| Linux target check | `cd cli && cargo check --target x86_64-unknown-linux-gnu` | | | +| Windows target check | `cd cli && cargo check --target x86_64-pc-windows-gnu` | | | +| Mobile TypeScript | `cd mobile && npx tsc --noEmit` | | | +| Mobile security tests | `cd mobile && npm run test:security` | | | +| Mobile release preflight | `cd mobile && npm run preflight:release` | | | +| Website build | `cd website && npm run build` | | | +| Installer syntax | `bash -n install.sh && bash -n website/public/install.sh` | | | +| Installer parity | `diff -u install.sh website/public/install.sh` | | | +| Installer checksum tests | `bash scripts/test-installer-checksum.sh` | | | +| Workflow YAML parse | see runbook command | | | +| Whitespace diff check | root/mobile/website `git diff --check` | | | + +## Mobile Build Evidence + +| Platform | Required value | Recorded value | Result | +| --- | --- | --- | --- | +| iOS marketing version | matches app release | | | +| iOS build number | `112` or higher | | | +| iOS EAS build URL | production/TestFlight candidate | | | +| iOS App Store/TestFlight status | installable by testers | | | +| Android versionName | matches app release if shipped | | | +| Android versionCode | `92` or higher if shipped | | | +| Android EAS build URL | internal candidate or deferred | | | +| Android signing evidence | not debug-signed if shipped | | | +| Android production cleartext decision | verified or deferred | | | + +## Desktop Release Artifact Evidence + +| Archive | Present in release | SHA256SUMS entry | Install smoke | Result | +| --- | --- | --- | --- | --- | +| Linux x86_64 `.tar.gz` | | | | | +| Linux ARM64 `.tar.gz` | | | | | +| macOS Intel `.tar.gz` | | | | | +| macOS Apple Silicon `.tar.gz` | | | | | +| Windows x64 `.zip` | | | | | + +Negative installer tests against draft release: + +| Scenario | Expected | Result | Evidence | +| --- | --- | --- | --- | +| Corrupted archive | fails before extraction | | | +| Missing checksum entry | fails before extraction | | | +| Invalid checksum entry | fails before extraction | | | +| Wrong archive name | fails before extraction | | | + +## Physical Device Smoke Evidence + +| Host | Mobile | Network | Auth pair | Restart reconnect | Bad token | Revoked credential | Sessions/control | Filesystem | Push | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| macOS Apple Silicon | iOS | LAN | | | | | | | | | +| macOS Apple Silicon | iOS | Tailscale | | | | | | | | | +| macOS Apple Silicon | Android | LAN/Tailscale or deferred | | | | | | | | | +| macOS Intel | iOS | LAN/Tailscale | | | | | | | | | +| Linux x86_64 | iOS | LAN/Tailscale | | | | | | | | | +| Linux ARM64 | iOS | LAN/Tailscale | | | | | | | | | +| Windows x64 | iOS | LAN/Tailscale | | | | | | | | | + +## Security-Specific Evidence + +| Check | Expected | Result | Evidence | +| --- | --- | --- | --- | +| Legacy `hello` first message | `auth_required`, no sensitive data | | | +| Invalid proof | `auth_invalid`, no client registration | | | +| Revoked credential | no reconnect, no PTY/filesystem/push data | | | +| PTY registration without local proof | rejected | | | +| Mobile spawn without working dir | uses approved root or fails | | | +| MobileCLI config path read | denied | | | +| OS secret paths | denied | | | +| Delete/rename default | denied | | | +| Delete/rename opt-in | allowed only in approved throwaway root | | | +| Push opt-out while offline | unregister drains after reconnect | | | +| AsyncStorage device metadata | no `authToken` or `auth_token` | | | + +## Final Sign-Off + +| Area | Owner | Status | Evidence | +| --- | --- | --- | --- | +| Desktop CLI release | | | | +| Mobile iOS release | | | | +| Mobile Android release/defer decision | | | | +| Website/docs release | | | | +| Transport stance approved | | | | +| Security smoke complete | | | | +| Rollback plan ready | | | | + +Release decision: + +- [ ] Ship stable release +- [ ] Ship prerelease only +- [ ] Block release pending fixes + +Decision notes: + +- diff --git a/SECURITY_RELEASE_RUNBOOK_ISSUE_33.md b/SECURITY_RELEASE_RUNBOOK_ISSUE_33.md new file mode 100644 index 0000000..8f284f0 --- /dev/null +++ b/SECURITY_RELEASE_RUNBOOK_ISSUE_33.md @@ -0,0 +1,327 @@ +# Security Release Runbook - Issue #33 + +Date: 2026-05-15 +Audience: MobileCLI maintainers running the release candidate on real desktop and mobile devices + +## Purpose + +This runbook turns the issue #33 remediation plan into an executable release checklist. The release is not ready until every supported host/mobile/network cell below has a recorded pass or an explicit product decision that the cell is out of scope for this release. + +The reader should be able to take a desktop release candidate, a mobile build candidate, and real devices, then prove the new auth-v2, bind, filesystem, push, installer, and documentation behavior before shipping. + +Use `SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md` to record the evidence produced while running this checklist. + +## Release Candidates Under Test + +Record the exact artifacts before testing starts. + +| Component | Candidate | Required evidence | +| --- | --- | --- | +| Desktop CLI | Git SHA and release archive name | `mobilecli --version`, archive checksum, host OS | +| iOS app | TestFlight/App Store candidate | marketing version, build number, device model, iOS version | +| Android app | Internal/App Bundle candidate | marketing version, versionCode, device model, Android version | +| Website/docs | Deploy preview or commit SHA | URL or commit SHA | + +Expected mobile build floor for this remediation: + +- iOS build: `112` or higher. +- Android versionCode: `92` or higher. + +## Cross-Repository Ownership + +This checkout contains three separate release surfaces. The root repository release workflow does not automatically publish or gate the nested mobile and website workspaces. + +| Workspace | Release owner | Required before stable desktop release | +| --- | --- | --- | +| Root/CLI | Desktop release owner | Root CI green, tag matches `cli/Cargo.toml`, release artifacts and checksums verified | +| Mobile | Mobile release owner | Mobile CI green, EAS/TestFlight/internal build available, physical smoke complete | +| Website | Website release owner | Website CI green, deploy preview/stable deploy uses matching installer and security docs | + +Record the root commit/tag, mobile commit/build IDs, and website commit/deploy URL together. Do not ship the desktop auth-enforcing stable release before the compatible mobile build is installable by the intended testers/users. + +## Transport Security Decision + +Auth-v2 authenticates the paired app but does not make `ws://` confidential. Record one release stance before manual testing starts: + +| Stance | Allowed release claim | +| --- | --- | +| LAN plus Tailscale, no message-layer encryption | Safe on trusted LANs and Tailnets; not safe against hostile same-LAN MITM | +| Tailscale-only for remote/untrusted networks | Remote access requires Tailnet membership; same-LAN remains trusted-network only | +| `wss://` or encrypted message layer added | May claim protection against network observers only after MITM tests pass | + +For the current remediation, use Tailscale for untrusted networks and do not recommend public tunnels or untrusted WiFi LAN use. + +## Mobile Compatibility Release Order + +Run this before the stable desktop release: + +1. Verify mobile build numbers are at or above the floor listed above. +2. Run mobile CI and local preflight. +3. Confirm EAS production environment values and signing credentials. +4. Produce iOS TestFlight/App Store candidate. +5. Produce Android internal candidate only if Android is in scope for this release; otherwise record Android as deferred/internal-only in the final sign-off. +6. Install the candidate on real devices and complete the pairing/auth smoke matrix below. +7. Only after compatible mobile builds pass, publish the desktop release that enforces auth-v2. + +## Preflight Gates + +Run these before manual device testing. Failures block release-candidate testing unless the maintainer records why the failed gate is unrelated. + +Desktop CLI: + +```bash +cd cli +cargo fmt --check +cargo clippy --all-targets -- -D warnings -A dead-code +cargo test +cargo check --target x86_64-unknown-linux-gnu +``` + +Mobile app: + +```bash +cd mobile +npx tsc --noEmit +npm run test:security +npm run preflight:release +``` + +Android release candidates must not use `signingConfigs.debug`. If Android is in scope, run a signing report or EAS build evidence check and attach the result. + +Website/docs: + +```bash +cd website +npm run build +``` + +Installer parity: + +```bash +bash -n install.sh +bash -n website/public/install.sh +diff -u install.sh website/public/install.sh +``` + +Workflow syntax: + +```bash +python3 -c 'import pathlib, yaml; [yaml.safe_load(p.read_text()) for p in pathlib.Path(".github/workflows").glob("*.yml")]' +python3 -c 'import pathlib, yaml; [yaml.safe_load(p.read_text()) for p in pathlib.Path("mobile/.github/workflows").glob("*.yml")]' +python3 -c 'import pathlib, yaml; [yaml.safe_load(p.read_text()) for p in pathlib.Path("website/.github/workflows").glob("*.yml")]' +``` + +## Host And Device Matrix + +Supported host cells: + +| Host | Required? | Notes | +| --- | --- | --- | +| macOS Apple Silicon | Yes | Primary local/iMac path. | +| macOS Intel | Yes, if release archive is shipped | Can be CI plus manual smoke if no physical host is available. | +| Linux x86_64 | Yes | Include a machine without Tailscale and one with Tailscale where possible. | +| Linux ARM64 | Yes, if release archive is shipped | CI build plus at least install/start smoke on real hardware or VM. | +| Windows x86_64 | Yes | Must run in user session, not as a Windows service. | + +Supported mobile cells: + +| Mobile | Required? | Notes | +| --- | --- | --- | +| iOS candidate | Yes | Test LAN and Tailscale. | +| Android candidate | Yes if Android ships; otherwise document internal-only status | Must verify production `ws://` behavior. | + +Network cells: + +| Network | Required? | Expected security property | +| --- | --- | --- | +| Same LAN | Yes | App-layer auth blocks unauthenticated clients on reachable LAN. | +| Tailscale | Yes | Tailnet limits reachability, auth-v2 remains enforced. | + +## Desktop Install And Setup + +Run once per host OS. + +1. Install the desktop candidate from the release archive or local build. +2. Confirm `mobilecli --version` reports the candidate version. +3. Run `mobilecli setup`. +4. Select the intended connection mode for the current test cell. +5. Confirm the daemon does not silently bind `0.0.0.0` in local or Tailscale mode. +6. Confirm setup creates a first mobile credential and shows an auth-v2 QR. +7. Run `mobilecli credentials list` and confirm the credential appears without a raw token. +8. Confirm the config file contains credentials/verifiers but no raw `auth_token`. +9. On Unix hosts, confirm the config file is owner-only readable/writable. +10. On Windows, confirm the config file is under the user profile and not readable by ordinary unrelated users. + +Evidence to record: + +| Host | Mode | Bound addresses | Credential ID | Config permission checked | Result | +| --- | --- | --- | --- | --- | --- | + +## Pairing And Auth + +Run for every host/mobile/network cell. + +1. Start the daemon. +2. Pair by scanning the QR code. +3. Confirm the mobile app does not show connected until after the daemon sends authenticated welcome. +4. Confirm sessions are listed after auth succeeds. +5. Restart the mobile app and confirm it reconnects without rescanning. +6. Restart the daemon and confirm the mobile app reconnects without rescanning. +7. Pair manually using URL, credential ID, server ID, and token. Confirm URL-only manual pairing is rejected or impossible. +8. Attempt to connect with an altered token. Expected: no welcome, no sessions, no push registration, and a visible auth error. +9. Run `mobilecli credentials revoke ` for the paired device, then attempt reconnect. Expected: revoked device cannot reconnect or receive further data. +10. Run `mobilecli pair --rotate`, pair again, and confirm the old credential remains revoked. + +Evidence to record: + +| Host | Mobile | Network | QR pair | Manual pair | App restart | Daemon restart | Bad token rejected | Revoked rejected | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## Session And Terminal Controls + +Run for every host/mobile/network cell where pairing passes. + +1. Start an existing terminal session from desktop. +2. Confirm the session appears on mobile only after auth. +3. Subscribe from mobile and confirm PTY output appears. +4. Send input from mobile. +5. Resize/rotate the mobile terminal and confirm the host session remains usable. +6. Trigger a tool-approval or wait-state notification path and confirm the mobile prompt flow works. +7. Spawn each supported profile from mobile: Claude, Codex, Gemini, OpenCode where installed, and shell. +8. Confirm unsupported absolute paths and interpreter flags cannot be spawned from mobile. +9. Close a session from mobile and confirm the desktop state updates. +10. Confirm a second mobile device with a revoked or missing credential cannot receive PTY bytes for subscribed sessions. + +Evidence to record: + +| Host | Mobile | Network | Session list | Subscribe | Input | Resize | Approval | Spawn profiles | Unauthorized blocked | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## Push Notifications + +Run on iOS and Android if that platform ships. + +1. Grant push permission on the mobile app. +2. Confirm push token registration happens only after auth. +3. Trigger a waiting state and confirm a notification arrives. +4. Re-register after app restart and confirm duplicate tokens do not accumulate for the same installation. +5. Revoke the credential and trigger another waiting state. Expected: no push to the revoked installation. +6. Unregister/disable push from mobile and confirm future waiting states do not notify that device. + +Evidence to record: + +| Mobile | Host | Network | Register after auth | Notification received | No duplicate token | Revocation suppresses push | Unregister works | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## Filesystem Bridge + +Run for at least one host per OS family, and for every mobile platform that ships. + +1. Confirm fresh setup exposes only approved/project roots, not the entire home directory. +2. List an allowed root. +3. Read an allowed file. +4. Write a small allowed file. +5. Upload an attachment into the project upload cache. +6. Attempt to read MobileCLI config/auth files. Expected: denied. +7. Attempt to read common secret paths for that OS, such as SSH keys, shell history, cloud credentials, keychains, or Windows credential locations. Expected: denied. +8. Attempt search with an excessive result limit. Expected: server clamps to configured maximum. +9. Attempt copy with content over the configured limit. Expected: denied. +10. Confirm delete and rename are unavailable or denied by default. +11. Explicitly enable destructive operations in setup/config on a throwaway directory, then confirm delete/rename work only inside the approved root. +12. If whole-home access is enabled for testing, confirm the warning is shown and sensitive denylist paths remain blocked. +13. On Windows, test drive-prefix, case-insensitive, UNC/verbatim, junction, and reserved-name behavior where practical. + +Evidence to record: + +| Host | Mobile | Allowed read/write | Upload | Config denied | Secret denied | Search clamped | Copy capped | Delete default denied | Opt-in delete scoped | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## Network And Bind Behavior + +Run once per host OS, then spot-check with mobile. + +1. Local mode binds loopback plus the selected LAN address. +2. Local mode does not bind all interfaces unless explicitly configured as advanced opt-in. +3. Tailscale mode binds loopback plus the selected Tailscale address. +4. Tailscale mode fails closed if Tailscale is unavailable or disconnected. +5. Custom mode warns when the address is public or all-interface. +6. PTY registration is accepted from loopback and rejected from non-loopback. +7. A legacy client that sends `hello` first receives `auth_required` and no sensitive data. +8. An idle unauthenticated socket closes after the first-message timeout. +9. An oversized first message is rejected before auth. + +Evidence to record: + +| Host | Mode | Loopback | Selected bind | No implicit all-interface | Tailscale fail-closed | Legacy hello rejected | Idle socket closed | Oversized first message rejected | Result | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## Installer And Release Integrity + +Run against the actual draft GitHub Release before publishing. + +1. Confirm `SHA256SUMS.txt` exists in the release. +2. Confirm every published archive appears exactly once in `SHA256SUMS.txt`. +3. Download the installer from the website path and root path if both are distributed. +4. Install a valid archive and confirm checksum verification passes before extraction. +5. Corrupt the archive and confirm install fails before extraction. +6. Remove or rename the archive checksum entry and confirm install fails. +7. Confirm Linux, macOS, and Windows archives unpack to the expected binary name. +8. Confirm no install path executes downloaded archive contents before hash verification. + +Evidence to record: + +| Archive | Checksum present | Valid install | Corrupt fails | Missing checksum fails | Binary smoke | Result | +| --- | --- | --- | --- | --- | --- | --- | + +## Upgrade And Migration + +Run with backups of old configs/devices. + +1. Upgrade a desktop config that has no credentials. +2. Confirm network/device fields are preserved. +3. Confirm remote mobile bind is locked until `mobilecli pair` or `mobilecli setup` creates credentials. +4. Upgrade a mobile app with an existing linked device that has no auth-v2 fields. +5. Confirm the app does not send privileged queued messages to the auth-v2 daemon before re-pairing. +6. Re-pair and confirm old mobile settings are replaced with auth-v2 fields. +7. Confirm uninstall/reinstall behavior is acceptable for SecureStore and AsyncStorage fallback cases on both iOS and Android. + +Evidence to record: + +| Scenario | Desktop state preserved | Remote bind locked | Mobile queue gated | Re-pair succeeds | Result | +| --- | --- | --- | --- | --- | --- | + +## Docs And Privacy Review + +Before stable release: + +1. Read root README, CLI README, architecture docs, Windows docs, website docs, website privacy/terms, and LLM artifacts. +2. Confirm no page claims that auth was already token-based before this release. +3. Confirm no page claims URL-only manual pairing is sufficient. +4. Confirm no page recommends public tunnels for normal use. +5. Confirm push documentation names Expo push while Expo remains in use. +6. Confirm filesystem docs say whole-home and destructive operations are opt-in. +7. Confirm installer docs say archives are checksum verified. + +Evidence to record: + +| Surface | Auth-v2 accurate | Tailscale/LAN accurate | Expo push disclosed | Filesystem accurate | Installer accurate | Result | +| --- | --- | --- | --- | --- | --- | --- | + +## Final Sign-Off + +Release may proceed only after this table is complete. + +| Area | Owner | Evidence link/path | Status | +| --- | --- | --- | --- | +| Desktop CLI local checks | | | | +| Desktop cross-platform build/test | | | | +| iOS physical smoke | | | | +| Android physical smoke | | | | +| LAN auth/bind smoke | | | | +| Tailscale auth/bind smoke | | | | +| Filesystem bridge smoke | | | | +| Push notification smoke | | | | +| Installer checksum smoke | | | | +| Website/docs truth sweep | | | | + +Any failed row must produce either a fix before release or an explicit release-scope decision recorded in the remediation plan. diff --git a/SECURITY_REMEDIATION_PLAN_ISSUE_33.md b/SECURITY_REMEDIATION_PLAN_ISSUE_33.md new file mode 100644 index 0000000..bbc84e0 --- /dev/null +++ b/SECURITY_REMEDIATION_PLAN_ISSUE_33.md @@ -0,0 +1,534 @@ +# Security Remediation Plan - Issue #33 + +Date: 2026-05-15 +Status: local remediation implemented in this checkout; release/manual-device gates still required before shipping +Audience: MobileCLI maintainers and implementation agents + +## Summary + +Issue #33 was substantially true against the pre-remediation code. The desktop daemon treated network reachability as the security boundary, while public docs described application-layer token authentication that did not exist. The fix needed to be coordinated across the desktop daemon, protocol, mobile app, installer/release pipeline, and public docs. + +This is not a daemon-only change. A new mobile build is required because the bundled app code controls QR parsing, token storage, WebSocket handshake order, queued message flushing, push-token registration, and reconnect behavior. OTA updates are not available in the current native app configuration, so the mobile compatibility release must ship before the desktop daemon enforces fail-closed auth. + +The implemented local end state is protocol v2 challenge-response auth, not a raw bearer token sent over plaintext `ws://`. Tailscale remains recommended transport isolation, but Tailscale is not the only auth boundary. + +## Local Implementation Status + +Implemented locally on 2026-05-15: + +- Desktop auth-v2 protocol messages, credential generation, verifier storage, fail-closed mobile handshake, and post-auth welcome/session flow. +- Authenticated local PTY registration for desktop wrapper sessions so arbitrary loopback WebSocket clients cannot register fake sessions or poison project roots. +- First-message timeout and first-message size cap for unauthenticated WebSockets. +- `mobilecli pair`, `mobilecli pair --rotate`, `mobilecli credentials list`, and `mobilecli credentials revoke `. +- Mobile QR parsing for auth-v2 fields, SecureStore-only per-device pairing-token storage, metadata-only AsyncStorage fallback, challenge-response proof generation, authenticated connection state, queued-message gating, and push registration after auth. +- Durable mobile push opt-out state so offline unregister requests survive app termination and drain after the next authenticated reconnect. +- Loopback plus configured LAN/Tailscale bind policy; no default all-interface bind. +- Active-credential filtering on sensitive broadcasts, PTY output, and file-watch sends so revoked credentials stop receiving data after revocation. +- Session-control messages now require both the `session:control` scope and an active subscription to the target session. +- Filesystem hardening defaults, expanded sensitive-path denylist including MobileCLI auth config, safe project-root inclusion for active sessions, search clamp, copy-size enforcement, destructive-operation config enforcement with delete/rename off by default, explicit empty-root deny-all preservation, and mobile spawn profile normalization. +- Push-token ownership, per-credential caps, and token format validation. +- Unix private config writes and Windows ACL tightening for config files/directories; Windows save now fails visibly if ACL hardening fails. +- Windows filesystem pattern matching now normalizes verbatim paths, separators, and case before denied/read-only glob checks. +- Auth material no longer falls back to a project-local `./.mobilecli` directory when the home directory is unavailable; the CLI uses a platform config directory or fails closed. +- Android release builds no longer use the debug signing config; mobile release preflight fails if that pattern returns. +- Linux Tailscale setup now gives manual installation instructions instead of running `curl | sh`. +- Installer checksum verification in both root and website installer scripts. +- Root release workflow gates for CLI fmt/clippy/test, root installer syntax, tag/version sync, native runner tests on release builders, and checksum completeness across expected Linux/macOS/Windows archives. +- Installer checksum helper now has a local shell test covering valid, star-prefixed, missing, invalid, wrong, and empty checksum manifests. +- Per-workspace CI gates: root Linux/macOS/Windows desktop compile checks; mobile TypeScript/security/preflight checks; website build and public installer syntax. +- Public docs/website/LLM artifacts updated from temporary network-only language to the implemented auth-v2 model. +- Mobile native build numbers synchronized to iOS build 112 and Android versionCode 92. + +Still required before shipping: + +- Physical-device smoke tests on iOS and Android for LAN and Tailscale, including app restart, daemon restart, bad token, revoked credential, push registration, attachments, filesystem allowed/denied roots, and production Android cleartext behavior. +- Cross-platform desktop test/build matrix on Linux, macOS Apple Silicon/Intel, and Windows x64. +- Release-artifact verification against the actual GitHub Release `SHA256SUMS.txt`. +- Transport-confidentiality release decision: auth-v2 authenticates the mobile client but does not encrypt `ws://` terminal/filesystem frames. Same-LAN use must be limited to trusted networks, Tailscale must be required for untrusted networks, or an encrypted/authenticated message layer must be added before claiming protection against LAN MITM. +- Dedicated full hook/integration tests for mobile WebSocket behavior and SecureStore migration are still recommended; the current local security test script covers QR parsing, auth-v2 proof fixtures, redundant storage fallback semantics, and pure auth state-machine gates used by `useSync`. +- Execute the release-candidate runbook in `SECURITY_RELEASE_RUNBOOK_ISSUE_33.md` and record pass/fail evidence for every supported host/mobile/network cell. +- Record release-candidate evidence in `SECURITY_RELEASE_EVIDENCE_TEMPLATE_ISSUE_33.md`. +- Use `SECURITY_COMPLETION_AUDIT_ISSUE_33.md` as the prompt-to-artifact checklist before marking the remediation complete. + +## Pre-Remediation Truths Confirmed + +- Desktop WebSocket auth is missing. The daemon accepts mobile clients, registers them, and sends `welcome`, session lists, and waiting states before any credential check. +- QR pairing currently contains connection metadata only. It does not contain a secret. +- `Config` stores device/network fields only. It does not store an auth token hash or verifier. +- Mobile already has partial `authToken` plumbing, but it marks the raw socket as connected and sends `hello`, `get_sessions`, push registration, and queued commands before auth is proven. +- Mobile parses `auth_token`, not Claude's proposed `token`. The daemon should emit `auth_token`; the app should accept both `auth_token` and `token` for migration tolerance. +- Push notifications use Expo push service. Docs claiming direct APNs or no third-party service are false while Expo remains the push path. +- The mobile app, website, and root CLI are separate workspaces in this checkout. Root GitHub CI currently gates only the CLI repository surface. + +## Target Architecture + +### Auth Model + +Implement protocol v2 challenge-response authentication for all non-PTY mobile WebSocket clients. + +Pairing creates a credential: + +- `server_id`: stable desktop daemon identity. +- `credential_id`: stable ID for one paired mobile installation. +- `auth_token`: 32 bytes from OS RNG, base64url encoded, shown only in the QR/manual pairing flow. +- `verifier`: derived from `auth_token` and stored on desktop. Treat this verifier as sensitive because it is pass-the-hash material for challenge-response verification. +- `scopes`: explicit allowed capabilities for that credential. +- `created_at`, `last_used_at`, `revoked_at`, and display name. + +Protocol flow: + +1. Client opens WebSocket. +2. Client sends `auth_start` with `auth_version: 2`, `credential_id`, `client_nonce`, `mobile_installation_id`, `sender_id`, `client_version`, and `client_capabilities`. +3. Server replies with `auth_challenge` containing `server_nonce`, `server_id`, and `credential_id`. +4. Client sends `auth_response` with `proof = HMAC-SHA256(verifier, transcript)`. +5. Server constant-time compares the proof, updates credential last-used metadata, registers the mobile client, and only then sends `welcome`, session list, waiting states, and any queued authorized data. + +Fail closed: + +- No `welcome`, sessions, waiting states, filesystem watcher, push registration, PTY output, or client registration before auth succeeds. +- Legacy first-message `hello` without v2 auth gets `auth_required` and a policy-violation close. +- Wrong proof gets `auth_invalid` and a policy-violation close. +- Missing local credential storage or tokenless migrated config locks remote mobile bind until the user runs `mobilecli setup` or `mobilecli pair`. + +Compatibility: + +- The new mobile app should support legacy daemons and auth-v2 daemons. +- The new desktop daemon should not support unauthenticated mobile clients. +- If an emergency server-only bridge is needed, `hello.auth_token` can be considered as a temporary auth-v1 path, but it should not be the final design because it sends a reusable bearer token over `ws://`. + +### Transport Confidentiality + +Auth-v2 proves that a paired mobile installation knows the pairing secret, but `ws://` is still cleartext. A hostile same-LAN intermediary could read or tamper with post-auth terminal and filesystem frames unless the network itself is trusted or protected by Tailscale/WireGuard. + +Release posture: + +- Same-LAN mode is acceptable only as a trusted-network convenience, not as protection against a LAN MITM. +- Tailscale is the recommended remote/untrusted-network transport because it gives confidentiality and peer authentication below the WebSocket. +- Public tunnels and untrusted WiFi must not be recommended unless the connection is `wss://` with a trusted endpoint or MobileCLI adds its own encrypted/authenticated message layer after pairing. +- Android cleartext support must remain a deliberate compatibility decision for LAN/Tailscale `ws://`, not an accidental blanket security claim. + +### Desktop Config + +Extend the desktop config format: + +- Add `config_version`. +- Add `server_id`. +- Add `auth_version`. +- Add `credentials`. +- Add `filesystem.allowed_roots` and destructive-operation policy. +- Preserve existing `device_id`, `device_name`, `connection_mode`, `tailscale_ip`, and `local_ip`. + +Persistence requirements: + +- Atomic writes. +- Unix config files written with owner-only permissions. +- Best-effort private ACLs on Windows. +- No raw `auth_token` stored on desktop. +- `mobilecli pair` creates a fresh credential every time because old raw tokens cannot be redisplayed safely. + +New CLI operations: + +- `mobilecli pair`: create and show a new credential QR. +- `mobilecli pair --rotate`: revoke old mobile credentials for this desktop and create a new one. +- `mobilecli credentials list`: show paired devices without secrets. +- `mobilecli credentials revoke `: revoke one paired mobile device. +- `mobilecli setup`: migrate tokenless config, select connection mode, create first credential, and show QR. + +### Bind Policy + +Replace unconditional `0.0.0.0:` mobile exposure with explicit bind selection. + +Required behavior: + +- PTY registration remains loopback-only. +- The daemon always has a loopback listener for desktop wrapper/link traffic. +- Local mode binds the selected LAN IP, not every interface. +- Tailscale mode binds the Tailscale IP and fails closed if Tailscale is unavailable. +- Custom mode binds only according to explicit user configuration and warns if public exposure is possible. +- `0.0.0.0` becomes explicit advanced opt-in, not the default. + +Cross-platform considerations: + +- Linux/macOS/Windows must all support loopback plus selected mobile bind. +- IP changes require a clear restart/re-pair message. +- Windows users must keep the daemon in the user session, not a Windows service, so spawned terminals remain visible. +- Firewall docs should say exactly which address and port are bound. + +## Implementation Slices + +### Slice 0 - Release And Branch Hygiene + +- Freeze feature work touching daemon protocol, mobile sync, QR pairing, filesystem bridge, installer, and docs until the remediation lands. +- Treat current docs as unsafe until updated. +- Keep `SECURITY_AUDIT_ISSUE_33.md` as evidence and use this file as the execution plan. +- Decide release numbers before code starts. Assuming iOS build `111` has already been used, the next mobile build should be at least `112`; Android versionCode should be at least `92`. Use the next marketing version that matches App Store / Play Console state. + +### Slice 1 - Mobile Compatibility Release + +Goal: ship an app that can talk to both legacy daemons and auth-v2 daemons before the desktop daemon starts enforcing auth. + +Mobile changes: + +- Clean up QR parsing so special `mobilecli://relay`, `mobilecli://tailscale`, and `mobilecli://direct` formats are not swallowed by the generic compact parser. +- Parse `auth=v2`, `credential_id`, `server_id`, `auth_token`, `token`, `wss=1`, and `ws_url`. +- Avoid logging full QR payloads or auth tokens, even in dev logs. +- Store pairing secrets with existing `expo-secure-store` keychain service. Do not enable biometric `requireAuthentication`; reconnect/background behavior depends on silent access. +- Add a SecureStore-backed `mobile_installation_id` that survives app restarts and is distinct from the desktop `device_id`. +- Add HMAC-SHA256 support for auth-v2, likely with a small audited JS dependency such as `@noble/hashes` unless the native stack already provides a reliable HMAC API. +- Change connection state from `socket open == connected` to `authenticated welcome == connected`. +- On WebSocket open, send auth-v2 messages when the active device has v2 fields. +- Wait for auth success / `welcome` before setting `isConnected`, requesting sessions, registering/unregistering push tokens, sending filesystem requests, or flushing queued messages. +- Clear queued privileged messages on auth failure. +- Handle `auth_required`, `auth_invalid`, `auth_revoked`, JSON error messages, and close-code-only failures without retry loops. +- Add a visible manual token entry field in Settings or make manual URL pairing explicitly QR-only. Recommended: add manual fields for URL, credential ID, and token. +- Register push tokens only after auth succeeds and include `mobile_installation_id`. +- Fix the current TypeScript blocker: `TMUX_SWIPE_COOLDOWN_MS` undefined. +- Fix native version/build sync before any build: app config, iOS project, iOS Info.plist, and Android Gradle must agree. +- Test Android production `ws://` behavior. Debug allows cleartext; production must be verified for LAN/Tailscale WebSockets. + +Mobile acceptance tests: + +- QR parser unit tests for JSON, compact URL, `auth_token`, `token`, `auth=v2`, `wss=1`, relay/tailscale/direct, IPv6, and malformed values. +- Mock WebSocket tests proving auth is sent first, `isConnected` stays false until `welcome`, push registration waits for auth, queued messages flush only after auth, and auth failure clears privileged queues. +- SecureStore migration tests for existing linked devices without tokens and rescans with new credentials. +- Physical-device smoke tests for iOS LAN, iOS Tailscale, Android production LAN, Android production Tailscale, app restart, daemon restart, and push opt-in. + +Release order: + +- Build locally on the iMac for smoke testing if useful. +- Use the same signing/env/version inputs as production EAS. +- EAS remains the release path unless the team explicitly switches release process. +- Ship TestFlight/internal build first; only enforce desktop auth after the compatible mobile build is available. + +### Slice 2 - Desktop Auth And Pairing + +Goal: the daemon must reject unauthenticated mobile clients before any sensitive data leaves the desktop. + +Desktop changes: + +- Add auth-v2 protocol messages and server messages. +- Refactor `handle_connection` so mobile auth completes before calling the current mobile client loop. +- Do not create filesystem watcher subscriptions, broadcast receivers, mobile client map entries, sender IDs, or capability records until auth succeeds. +- Reduce unauthenticated WebSocket message/frame limits. The current 96 MB cap should apply only after auth or be replaced by authenticated chunked upload. +- Add token generation, verifier derivation, credential storage, credential list/revoke/rotate, config migration, and secure config persistence. +- `mobilecli setup` must create credentials before daemon state is initialized, or restart/reload the daemon after setup so the running daemon has the new auth state. +- Existing tokenless configs preserve network/device fields but lock remote mobile bind until pairing creates credentials. +- `mobilecli pair` should create a new credential and show a QR once; it should not attempt to redisplay old secrets. +- Never log full QR URLs, raw tokens, HMAC proofs, or verifiers. +- Return stable auth errors: `auth_required`, `auth_invalid`, `auth_revoked`, `auth_unsupported`, and `auth_locked`. + +Desktop auth tests: + +- No-token client gets no `welcome`, no sessions, no waiting states, and closes. +- Bad proof closes without registering a mobile client. +- Good proof gets `welcome` and can request sessions. +- Legacy `hello` gets `auth_required` and closes. +- Revoked credential cannot reconnect. +- Config migration locks remote bind until pairing. +- Config writes contain no raw token and use expected permissions. +- PTY `register_pty` remains loopback-only. + +### Slice 3 - Authorization And Capability Scopes + +Goal: after auth, the daemon still enforces least privilege by credential/scope instead of trusting arbitrary session IDs. + +Scopes: + +- `session:read` +- `session:control` +- `session:spawn` +- `fs:read` +- `fs:write` +- `fs:delete` +- `fs:watch` +- `fs:upload` +- `push:register` + +Session controls: + +- Gate `Subscribe`, `GetSessionHistory`, `SendInput`, `ToolApproval`, `PtyResize`, `TmuxViewport`, `RenameSession`, and `CloseSession`. +- Do not use `owner_connection_id` as the primary authorization model. It breaks reconnects and multi-device use. Use authenticated `credential_id`, scopes, and session ACLs where needed. +- For input/resize/viewport, require the connection to be subscribed or explicitly controlling the session. +- Ensure old/no-capability clients do not receive `PtyBytes` for sessions they did not subscribe to. + +Push tokens: + +- Bind tokens to `credential_id` and `mobile_installation_id`. +- Validate `token_type` and token format. +- Cap tokens per credential. +- Re-registration replaces the same mobile installation's token. +- Unregister only removes tokens owned by the authenticated credential. +- Credential revocation purges related push tokens. +- Rate-limit by credential and IP. +- Keep docs honest that Expo push is used unless the implementation changes. + +### Slice 4 - Filesystem Hardening + +Goal: the filesystem bridge should be useful without defaulting to whole-home access. + +Desktop changes: + +- Change default roots from whole home to active session project roots plus explicit setup-approved roots. +- Make whole-home access an explicit opt-in with a clear warning. +- Add per-root permissions: read, write, delete, watch, upload. +- Keep an expanded denylist as defense-in-depth, not as the primary security boundary. +- Block known secret locations across Linux, macOS, and Windows by default, including SSH, GPG, AWS, kube, Docker, gcloud, shell histories, netrc, Git credentials, Vault, Terraform state, browser credential stores, keychains, and password-manager config. +- Clamp client-provided search result limits to the configured maximum. +- Enforce write-size limits for copy operations too. +- Validate mobile `working_dir` for spawned sessions against approved roots or session project roots. +- Harden Windows paths: case-insensitive matching, drive prefixes, UNC/verbatim paths, junctions/reparse points, reserved names, and symlink race behavior. + +Mobile changes: + +- Treat denied roots as normal UX, not connection failure. +- Show explicit root/permission state in the file browser where needed. +- Do not offer destructive file actions when the authenticated credential lacks `fs:delete`. + +Filesystem tests: + +- Fresh config cannot read arbitrary home files. +- Session project roots are readable where intended. +- Whole-home opt-in works only after explicit setup approval. +- Sensitive paths are denied even under allowed roots. +- Copy honors size limits. +- Search clamps `max_results`. +- Windows junction/reparse/UNC cases are denied or handled as specified. + +### Slice 5 - Command Spawn Hardening + +Goal: mobile spawn should start supported tools, not arbitrary interpreter payloads. + +Desktop changes: + +- Replace free-form mobile `command,args` with server-defined spawn profiles: `claude`, `codex`, `gemini`, `opencode`, and `shell`. +- Existing mobile requests with `command` and empty `args` may be translated to profiles for compatibility. +- Reject absolute command paths from mobile clients. +- Reject non-empty args by default unless a profile explicitly allows them. +- For the `shell` profile, spawn the user's default shell without accepting client-provided flags. +- If advanced arbitrary command spawning is kept, put it behind an explicit config flag and a separate high-risk scope. +- Fix basename allowlist bypasses such as `/tmp/bash`. +- Do not rely on `is_shell_safe` for security. Escaping is still useful, but interpreter flags are the real risk. + +Tests: + +- Allowed profile spawns work on Linux, macOS, and Windows. +- `/tmp/bash`, `bash -c`, `python -c`, `node -e`, `powershell -Command`, and `powershell -EncodedCommand` are rejected. +- Working directories outside approved roots are rejected. +- Existing mobile UI spawn buttons continue to work. + +### Slice 6 - Bind, Setup, Autostart, And Cross-Platform UX + +Goal: safe defaults work on Linux, macOS, and Windows. + +Desktop changes: + +- Implement pure bind-address selection logic and test it independently. +- Local mode binds selected LAN IP. +- Tailscale mode binds selected Tailscale IP and fails closed if disconnected. +- Custom mode warns and requires explicit confirmation for public or all-interface exposure. +- Never silently fall back from Tailscale to LAN when Tailscale is selected. +- Setup should default to Tailscale as the recommended remote mode and explain LAN risk plainly. +- Auto-launch shell hook should remain opt-in and default to `No` during setup. +- Tailscale Linux install helper should give manual package-manager/download instructions and not run `curl | sh`. +- Audit autostart outputs while touching setup: macOS launchd plist, Windows Task Scheduler command, Linux systemd user service. + +Tests: + +- Bind selection: loopback always available, LAN binds selected local IP, Tailscale binds selected `100.x` address, no implicit `0.0.0.0`. +- Tailscale disconnected in Tailscale mode refuses pairing instead of showing LAN QR. +- Windows setup docs and behavior keep daemon in the user session. +- Shell hook install/uninstall remains reversible. + +### Slice 7 - Installer And Release Integrity + +Goal: downloads are verified before execution or privileged install. + +Changes: + +- Update root `install.sh` and `website/public/install.sh` together. +- Use the existing `SHA256SUMS.txt` from GitHub Releases or switch release workflow to per-archive `.sha256`; pick one and test it. Recommended: use existing `SHA256SUMS.txt` to minimize release workflow churn. +- Download checksum file before extraction. +- Verify the selected archive hash before `tar`/`unzip` and before `sudo`. +- Fail closed if checksum is missing or verification fails. +- Prefer fail-closed if no checksum tool exists; if a bypass flag is added, make it explicit and noisy. +- Keep the sudo warning, but the main supply-chain fix is checksum verification. +- Add release workflow checks that every archive appears in checksum output. +- Add signing/notarization as a follow-up hardening track: cosign/Sigstore for artifacts, macOS codesign/notarization if distributing outside Homebrew, Windows Authenticode if distributing `.exe`. + +Tests: + +- Valid checksum installs. +- Corrupted archive fails. +- Missing checksum fails. +- Wrong archive name fails. +- Website installer copy matches root installer. + +### Slice 8 - Docs, Website, Privacy, And Disclosure + +Goal: docs must describe the implemented security model, not the intended one. + +Before code ships: + +- Replace current auth-token claims with a temporary warning: current released daemon relies on network isolation and should be used only on trusted LANs or locked-down Tailnets. +- Remove public tunnel recommendations until app-layer auth is released. +- State that QR currently contains connection metadata only. +- State that the daemon exposes high-privilege terminal and filesystem capabilities to reachable clients. +- State that push notifications use Expo push service. +- State that installer checksum verification is being added if not yet shipped. + +After fixes ship: + +- Add a canonical Security and Privacy Model section reused across root README, CLI README, docs, and website. +- Document auth-v2 pairing, token storage, QR/manual entry, rotation, revocation, failed-auth logs, and re-pairing. +- Document LAN, Tailscale, and custom modes honestly. +- Keep Expo in privacy/docs unless push implementation changes. +- Document filesystem root approval and whole-home opt-in. +- Document release checksum verification. + +Sweep areas: + +- `README.md` +- `cli/README.md` +- `docs/ARCHITECTURE_QUICK_REFERENCE.md` +- `docs/WINDOWS_SETUP.md` +- `website/src/pages/index.astro` +- `website/src/pages/docs/*` +- `website/src/pages/features.astro` +- `website/src/pages/pricing.astro` +- `website/src/pages/privacy.astro` +- `website/src/pages/terms.astro` +- `website/src/lib/constants.ts` +- `website/public/llms.txt` +- `website/public/llms-full.txt` +- blog and comparison pages with durable no-cloud/no-third-party/no-shell claims +- `website/README.md` + +## Verification Gates + +### CLI Gates + +- `cargo fmt --check` +- `cargo clippy --all-targets -- -D warnings` +- `cargo clippy --all-targets -- -D warnings -A dead-code` +- `cargo test` +- `cargo check --target x86_64-unknown-linux-gnu` +- `cargo check --target x86_64-pc-windows-gnu` when the target is available locally. +- Cross-platform build/test on Linux, macOS, and Windows. +- Tmux-dependent tests skip cleanly or run in an environment where tmux sockets are permitted. + +Local status: + +- `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings -A dead-code`, and `cargo test` passed in this checkout on 2026-05-15. +- `cargo check --target x86_64-unknown-linux-gnu` and `cargo check --target x86_64-pc-windows-gnu` passed locally on 2026-05-15. +- Desktop auth parsing/verification now has pure unit coverage for legacy first-message rejection, unsupported auth versions, unknown/revoked credentials, transcript mismatch, invalid proof, and a valid bound proof. +- Local PTY registration proof, daemon-side PTY registration validation, and safe session-root filtering have focused unit coverage; explicit empty filesystem roots stay deny-all. +- Windows-style filesystem match normalization and config-directory fallback behavior have focused unit coverage. +- Root PR CI now includes `cargo check` on `ubuntu-latest`, `macos-latest`, and `windows-latest`. +- Full cross-platform Linux/macOS/Windows release build/test still needs to run outside this sandbox because macOS and Windows MSVC runners are not available here. + +### Mobile Gates + +- `npx tsc --noEmit` +- `npm run preflight:release` +- `npm run test:security` for QR parser, auth-v2 proof fixtures, redundant storage fallback semantics, and pure auth state-machine gates. +- WebSocket auth state machine tests. +- SecureStore/AsyncStorage fallback tests. +- Physical iOS and Android smoke tests. + +Local status: + +- `TMUX_SWIPE_COOLDOWN_MS` is defined. +- Native versions/build numbers are synchronized to iOS build 112 and Android versionCode 92. +- `npx tsc --noEmit`, `npm run test:security`, and `npm run preflight:release` passed locally. +- Pairing tokens are stored per device in SecureStore only; AsyncStorage stores device metadata without `authToken`/`auth_token`. Old inline token JSON is migrated and scrubbed. +- Pending push-token unregister state is persisted so notification opt-out can be drained after app restart and the next authenticated reconnect. +- Full mock WebSocket hook tests and SecureStore migration integration tests are not yet automated, but pure state gates, token-free metadata serialization, and redundant storage behavior are covered by `npm run test:security`. +- EAS production env var checks still warn if EAS auth/network is unavailable. +- Mobile workspace PR CI now runs dependency install, TypeScript check, `npm run test:security`, and release preflight. + +### Website Gates + +- `npm run build` +- No false security/privacy claims after the docs sweep. +- LLM artifacts regenerated or updated if they are generated manually. +- Website install script matches root install script. + +Local status: + +- `npm run build` passed in `website/`. +- Root and website installer scripts are byte-for-byte identical and pass `bash -n`. +- Targeted stale-claim scan found no remaining network-only auth claims or URL-only manual-pairing instructions in root docs, CLI docs, website source, or LLM artifacts. +- Website workspace PR CI now checks public installer syntax and website build before merge. +- Cross-repository installer parity still requires local/release coordination because root, website, and mobile are separate Git workspaces in this checkout. + +### Manual E2E Matrix + +The executable release checklist lives in `SECURITY_RELEASE_RUNBOOK_ISSUE_33.md`. The matrix below is the release-blocking coverage summary; use the runbook for exact commands, scenarios, and evidence tables. + +Host operating systems: + +- macOS Apple Silicon +- macOS Intel +- Linux x86_64 +- Linux ARM64 +- Windows x86_64 + +Mobile clients: + +- iOS TestFlight/App Store candidate +- Android internal candidate if Android is still in scope + +Networks: + +- Same LAN +- Tailscale + +Per-cell scenarios: + +- Fresh setup +- QR scan +- token persistence +- app restart reconnect +- daemon restart reconnect +- bad token rejection +- revoked credential rejection +- session list +- subscribe to existing session +- spawn Claude/Codex/Gemini/shell profile +- send input +- approval flow +- close session +- push register/unregister +- file list/read/write in allowed root +- denied sensitive path +- whole-home opt-in if enabled +- desktop upgrade from tokenless config +- mobile upgrade from existing pairing + +Release is blocked unless every supported cell passes or the unsupported cell is explicitly documented. + +## Rollout Order + +1. Patch public docs with temporary truth if we need to reduce user risk before code ships. +2. Build and release the mobile compatibility app first. Use at least the next unused iOS build number after `111` and the next unused Android versionCode after `91`. +3. Ship a desktop prerelease with auth-v2 enforced, safe migration, credential pairing, bind policy, command spawn hardening, filesystem root changes, push-token ownership, and installer checksum verification. +4. Run the full CLI, mobile, website, and manual E2E gates. +5. Update docs from temporary warnings to the final auth-v2 security model. +6. Publish stable CLI release. +7. Monitor support channels for auth failures, pairing confusion, Tailscale bind failures, mobile reconnect loops, and installer verification failures. + +## Explicit Non-Goals For The First Remediation Release + +- Do not implement a cloud relay. +- Do not claim end-to-end encryption beyond the chosen transport and auth properties. +- Do not claim “no third-party services” while Expo push, App Store, RevenueCat, or other processors remain in use. +- Do not implement arbitrary mobile command execution as a default feature. +- Do not expose public tunnels or reverse proxies as recommended setup paths. +- Do not silently preserve unauthenticated legacy mobile access for convenience. + +## Open Decisions To Confirm Before Release + +- Whether to ship an emergency desktop-only `hello.auth_token` hotfix before the full auth-v2 rollout. Recommended default: skip unless there is an immediate release pressure. +- Whether Android is part of this security release or remains internal only. If it is included, production cleartext WebSocket behavior must be verified. +- Whether whole-home filesystem access should remain available as an advanced opt-in. Recommended default: yes, but off by default with a strong warning. +- Whether artifact signing is required in the same release as checksum verification. Recommended default: checksum verification now, signing as the next hardening milestone. diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 7639746..e63a197 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -430,6 +430,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -695,6 +696,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1221,6 +1231,7 @@ dependencies = [ "futures-util", "glob", "glob-match", + "hmac", "hostname", "ignore", "infer", @@ -1239,7 +1250,9 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "strip-ansi-escapes", + "subtle", "tempfile", "term_size", "thiserror 2.0.18", @@ -1990,6 +2003,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 5ca117f..8837f9a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -45,6 +45,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } local-ip-address = "0.6" base64 = "0.22" rand = "0.8" +sha2 = "0.10" +hmac = "0.12" +subtle = "2.6" strip-ansi-escapes = "0.2" walkdir = "2.5" ignore = "0.4" diff --git a/cli/README.md b/cli/README.md index 90aa456..ddcc8d8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -78,7 +78,7 @@ Terminal 3 ──┘ ## Mobile App -Scan the QR code with the MobileCLI mobile app during setup, or enter the daemon URL/IP manually in app settings. The app connects to the daemon and shows all active terminal sessions. +Scan the QR code with the MobileCLI mobile app during setup. If you cannot scan the QR code, use `mobilecli pair` and enter the full manual pairing details in app settings: WebSocket URL, credential id, server id, and pairing token. URL-only manual setup is not enough for auth-v2 daemons. ## Session Management @@ -95,13 +95,13 @@ Sessions: 2 active session(s): ## Security Model -MobileCLI uses network-level access control, with optional QR pairing metadata: +MobileCLI uses auth-v2 QR pairing plus constrained network binds: -- **Pairing Token (Optional)**: `mobilecli setup` (or `mobilecli --setup`) generates an `auth_token` and embeds it in the QR code for convenience. -- **Local Network**: Only devices on the same WiFi can connect -- **Tailscale**: Only authenticated Tailscale network members can connect - -The daemon binds to all interfaces (0.0.0.0) intentionally so mobile devices can connect. For remote access, use Tailscale or terminate TLS (`wss://`) with a reverse proxy. +- **QR Pairing Credential**: `mobilecli setup` (or `mobilecli --setup`) creates a fresh mobile credential and embeds the daemon URL, device id/name, server id, credential id, and one-time pairing token in the QR code. The desktop config stores only a derived verifier. +- **Challenge-response auth**: mobile clients must send `auth_start`, answer `auth_challenge`, and prove possession of the pairing token before receiving `welcome`, sessions, terminal output, filesystem data, or push-token registration. +- **Credential management**: use `mobilecli credentials list`, `mobilecli credentials revoke `, or `mobilecli pair --rotate` to manage paired devices. +- **Constrained bind policy**: the daemon always binds loopback for desktop traffic, then binds the configured LAN or Tailscale address for mobile access. It does not silently bind `0.0.0.0`. +- **Network isolation**: auth is not a reason to expose the daemon directly to the public internet. For remote access, use Tailscale or a protected `wss://` endpoint you operate. ## Protocol @@ -109,6 +109,8 @@ The WebSocket server uses a JSON protocol compatible with the MobileCLI mobile a ### Client → Server +- `auth_start` - Begin auth-v2 challenge-response pairing proof +- `auth_response` - Complete auth-v2 proof - `send_input` - Send keyboard input - `pty_resize` - Resize terminal (cols, rows) - `get_sessions` - List available sessions @@ -118,6 +120,7 @@ The WebSocket server uses a JSON protocol compatible with the MobileCLI mobile a ### Server → Client +- `auth_challenge` - Auth-v2 server challenge - `welcome` - Connection established - `session_info` - Session details - `pty_bytes` - Terminal output (base64) diff --git a/cli/src/auth.rs b/cli/src/auth.rs new file mode 100644 index 0000000..31161d2 --- /dev/null +++ b/cli/src/auth.rs @@ -0,0 +1,249 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; + +pub const AUTH_VERSION: u8 = 2; +const TOKEN_BYTES: usize = 32; +const VERIFIER_DOMAIN: &[u8] = b"mobilecli-auth-v2-verifier\0"; +const TRANSCRIPT_DOMAIN: &str = "mobilecli-auth-v2"; +const LOCAL_PTY_DOMAIN: &str = "mobilecli-local-pty-v1"; +const LOCAL_PTY_KEY_DOMAIN: &[u8] = b"mobilecli-local-pty-v1-key\0"; + +pub const LOCAL_PTY_AUTH_VERSION: u8 = 1; + +pub const SCOPE_SESSION_READ: &str = "session:read"; +pub const SCOPE_SESSION_CONTROL: &str = "session:control"; +pub const SCOPE_SESSION_SPAWN: &str = "session:spawn"; +pub const SCOPE_FS_READ: &str = "fs:read"; +pub const SCOPE_FS_WRITE: &str = "fs:write"; +pub const SCOPE_FS_DELETE: &str = "fs:delete"; +pub const SCOPE_FS_WATCH: &str = "fs:watch"; +pub const SCOPE_FS_UPLOAD: &str = "fs:upload"; +pub const SCOPE_PUSH_REGISTER: &str = "push:register"; + +pub fn default_scopes() -> Vec { + [ + SCOPE_SESSION_READ, + SCOPE_SESSION_CONTROL, + SCOPE_SESSION_SPAWN, + SCOPE_FS_READ, + SCOPE_FS_WRITE, + SCOPE_FS_DELETE, + SCOPE_FS_WATCH, + SCOPE_FS_UPLOAD, + SCOPE_PUSH_REGISTER, + ] + .into_iter() + .map(str::to_string) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthCredential { + pub credential_id: String, + pub verifier: String, + pub name: String, + pub scopes: Vec, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, +} + +impl AuthCredential { + pub fn is_active(&self) -> bool { + self.revoked_at.is_none() + } + + pub fn revoke(&mut self) { + if self.revoked_at.is_none() { + self.revoked_at = Some(chrono::Utc::now().to_rfc3339()); + } + } +} + +#[derive(Debug, Clone)] +pub struct PairingCredential { + pub credential: AuthCredential, + pub auth_token: String, +} + +#[derive(Debug, Clone)] +pub struct AuthenticatedClient { + pub credential_id: String, + pub mobile_installation_id: String, + pub sender_id: Option, + pub client_version: String, + pub client_capabilities: Option, + pub scopes: Vec, +} + +impl AuthenticatedClient { + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.iter().any(|s| s == scope) + } +} + +pub fn generate_pairing_credential(name: impl Into) -> PairingCredential { + let mut token_bytes = [0u8; TOKEN_BYTES]; + rand::rngs::OsRng.fill_bytes(&mut token_bytes); + let auth_token = URL_SAFE_NO_PAD.encode(token_bytes); + let verifier = verifier_from_token(&auth_token); + let now = chrono::Utc::now().to_rfc3339(); + PairingCredential { + credential: AuthCredential { + credential_id: uuid::Uuid::new_v4().to_string(), + verifier, + name: name.into(), + scopes: default_scopes(), + created_at: now, + last_used_at: None, + revoked_at: None, + }, + auth_token, + } +} + +pub fn verifier_from_token(auth_token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(VERIFIER_DOMAIN); + hasher.update(auth_token.as_bytes()); + URL_SAFE_NO_PAD.encode(hasher.finalize()) +} + +pub fn build_auth_transcript( + server_id: &str, + credential_id: &str, + client_nonce: &str, + server_nonce: &str, + mobile_installation_id: &str, +) -> String { + [ + TRANSCRIPT_DOMAIN, + server_id, + credential_id, + client_nonce, + server_nonce, + mobile_installation_id, + ] + .join("\n") +} + +pub fn generate_nonce() -> String { + let mut nonce = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + URL_SAFE_NO_PAD.encode(nonce) +} + +pub fn proof_from_verifier(verifier: &str, transcript: &str) -> Option { + let key = URL_SAFE_NO_PAD.decode(verifier.as_bytes()).ok()?; + let mut mac = HmacSha256::new_from_slice(&key).ok()?; + mac.update(transcript.as_bytes()); + Some(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())) +} + +pub fn verify_proof(verifier: &str, transcript: &str, proof: &str) -> bool { + let Some(expected) = proof_from_verifier(verifier, transcript) else { + return false; + }; + expected.as_bytes().ct_eq(proof.as_bytes()).into() +} + +pub fn build_pty_registration_transcript( + session_id: &str, + name: &str, + command: &str, + project_path: &str, + runtime: &str, + desktop: bool, +) -> String { + [ + LOCAL_PTY_DOMAIN, + session_id, + name, + command, + project_path, + runtime, + if desktop { "desktop" } else { "headless" }, + ] + .join("\n") +} + +pub fn local_pty_proof_from_token(token: &str, transcript: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(LOCAL_PTY_KEY_DOMAIN); + hasher.update(token.as_bytes()); + let key = hasher.finalize(); + let mut mac = HmacSha256::new_from_slice(&key).expect("sha256 key is valid hmac key"); + mac.update(transcript.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +pub fn verify_local_pty_proof(token: &str, transcript: &str, proof: &str) -> bool { + let expected = local_pty_proof_from_token(token, transcript); + expected.as_bytes().ct_eq(proof.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_credential_verifies_challenge_response() { + let pairing = generate_pairing_credential("phone"); + let transcript = build_auth_transcript( + "server", + &pairing.credential.credential_id, + "client_nonce", + "server_nonce", + "mobile_installation", + ); + let proof = proof_from_verifier(&pairing.credential.verifier, &transcript).unwrap(); + + assert!(verify_proof( + &pairing.credential.verifier, + &transcript, + &proof + )); + assert!(!verify_proof( + &pairing.credential.verifier, + &transcript, + "wrong-proof" + )); + } + + #[test] + fn verifier_derivation_is_deterministic_and_token_bound() { + let token = "sample-token"; + assert_eq!(verifier_from_token(token), verifier_from_token(token)); + assert_ne!( + verifier_from_token(token), + verifier_from_token("other-token") + ); + } + + #[test] + fn local_pty_proof_is_bound_to_registration_transcript() { + let token = generate_nonce(); + let transcript = build_pty_registration_transcript( + "session", + "name", + "claude", + "/tmp/project", + "pty", + true, + ); + let proof = local_pty_proof_from_token(&token, &transcript); + assert!(verify_local_pty_proof(&token, &transcript, &proof)); + + let other_transcript = + build_pty_registration_transcript("session", "name", "claude", "/", "pty", true); + assert!(!verify_local_pty_proof(&token, &other_transcript, &proof)); + } +} diff --git a/cli/src/daemon.rs b/cli/src/daemon.rs index 048e849..4e9b578 100644 --- a/cli/src/daemon.rs +++ b/cli/src/daemon.rs @@ -3,6 +3,7 @@ //! Single WebSocket server that all terminal sessions stream to. //! Mobile connects once and sees all active sessions. +use crate::auth::{self, AuthenticatedClient}; use crate::detection::{ detect_wait_event, strip_ansi_and_normalize, ApprovalModel, CliTracker, CliType, WaitType, }; @@ -17,7 +18,7 @@ use crate::tmux::sanitize_tmux_token; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use chrono::Utc; use futures_util::{SinkExt, StreamExt}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{BTreeSet, HashMap, VecDeque}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; @@ -140,6 +141,8 @@ pub struct PushToken { pub token_type: String, // "expo" | "apns" | "fcm" #[allow(dead_code)] pub platform: String, // "ios" | "android" + pub credential_id: String, + pub mobile_installation_id: String, } /// Default scrollback buffer size (8MB). @@ -169,6 +172,30 @@ const CLIENT_CAP_ATTACH_V2: u32 = 1 << 0; const TMUX_VIEWPORT_MIN_MAJOR: u32 = 3; const TMUX_VIEWPORT_DEFAULT_COUNT: u16 = 1; const TMUX_VIEWPORT_MAX_COUNT: u16 = 20; +const FIRST_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10); +const FIRST_MESSAGE_MAX_BYTES: usize = 128 * 1024; + +#[derive(Debug, Clone)] +struct AuthStartRequest { + credential_id: String, + client_nonce: String, + mobile_installation_id: String, + sender_id: Option, + client_version: String, + client_capabilities: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AuthFailure { + code: &'static str, + message: &'static str, +} + +impl AuthFailure { + const fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AttachProtocolMode { @@ -286,6 +313,7 @@ pub struct DaemonState { pub next_attach_id: u64, pub mobile_clients: HashMap>, pub mobile_client_capabilities: HashMap, + pub mobile_auth: HashMap, pub mobile_attach_ids: HashMap>, /// Mapping from logical mobile sender ID to current socket address. /// Used to evict stale/replaced websocket addresses on reconnect. @@ -308,12 +336,13 @@ pub struct DaemonState { pub device_id: Option, /// Device name (hostname) pub device_name: Option, + /// Stable auth server identity. + pub server_id: Option, } impl DaemonState { pub fn new(port: u16) -> Self { let (pty_broadcast, _) = broadcast::channel(256); - let file_system = std::sync::Arc::new(FileSystemService::new(FileSystemConfig::default())); let overhaul_flags = OverhaulFlags::from_env(); let tmux_viewport_supported = detect_tmux_viewport_support(overhaul_flags.tmux_shared_viewport); @@ -325,7 +354,19 @@ impl DaemonState { let _ = crate::setup::save_config(&cfg); cfg }); - let (device_id, device_name) = (Some(cfg.device_id), Some(cfg.device_name)); + if cfg.config_version < 2 { + tracing::warn!( + config_version = cfg.config_version, + "Loaded legacy MobileCLI config; mobile auth will remain locked until pairing creates credentials" + ); + } + let file_system = + std::sync::Arc::new(FileSystemService::new(file_system_config_from_setup(&cfg))); + let (device_id, device_name, server_id) = ( + Some(cfg.device_id), + Some(cfg.device_name), + Some(cfg.server_id), + ); Self { sessions: HashMap::new(), @@ -334,6 +375,7 @@ impl DaemonState { next_attach_id: 1, mobile_clients: HashMap::new(), mobile_client_capabilities: HashMap::new(), + mobile_auth: HashMap::new(), mobile_attach_ids: HashMap::new(), mobile_sender_addrs: HashMap::new(), pty_broadcast, @@ -349,14 +391,101 @@ impl DaemonState { file_rate_limiters: HashMap::new(), device_id, device_name, + server_id, + } + } +} + +fn file_system_config_from_setup(cfg: &crate::setup::Config) -> FileSystemConfig { + file_system_config_from_setup_and_projects(cfg, std::iter::empty::<&String>()) +} + +fn file_system_config_from_setup_and_projects<'a>( + cfg: &crate::setup::Config, + project_paths: impl IntoIterator, +) -> FileSystemConfig { + let mut fs_config = FileSystemConfig::default(); + let mut roots: Vec = cfg + .filesystem + .allowed_roots + .iter() + .map(PathBuf::from) + .collect(); + roots.extend( + project_paths + .into_iter() + .filter(|p| !p.trim().is_empty()) + .filter(|p| is_safe_session_project_root(cfg, p)) + .map(PathBuf::from), + ); + if cfg.filesystem.whole_home_enabled { + if let Some(home) = dirs_next::home_dir() { + roots.push(home); } } + dedupe_roots(&mut roots); + if !roots.is_empty() { + fs_config.allowed_roots = roots; + } + fs_config +} + +fn is_safe_session_project_root(cfg: &crate::setup::Config, path: &str) -> bool { + let path = PathBuf::from(path); + if !path.is_absolute() { + return false; + } + let Ok(canonical) = path.canonicalize() else { + return false; + }; + if canonical.parent().is_none() { + return false; + } + if !cfg.filesystem.whole_home_enabled { + if let Some(home) = dirs_next::home_dir().and_then(|p| p.canonicalize().ok()) { + if canonical == home { + return false; + } + } + } + true +} + +fn dedupe_roots(roots: &mut Vec) { + let mut seen = BTreeSet::new(); + roots.retain(|root| { + let key = root + .canonicalize() + .unwrap_or_else(|_| root.clone()) + .to_string_lossy() + .to_string(); + seen.insert(key) + }); +} + +fn refresh_file_system_roots(st: &mut DaemonState) { + let Some(cfg) = crate::setup::load_config() else { + return; + }; + let project_paths: Vec = st + .sessions + .values() + .map(|session| session.project_path.clone()) + .filter(|path| !path.trim().is_empty()) + .collect(); + st.file_system = std::sync::Arc::new(FileSystemService::new( + file_system_config_from_setup_and_projects(&cfg, project_paths.iter()), + )); } pub type SharedState = Arc>; /// Start the daemon (blocking - run in background) pub async fn run(port: u16) -> std::io::Result<()> { + if crate::setup::load_config().is_some() { + crate::setup::ensure_desktop_link_token()?; + } + // Write PID file let pid_path = pid_file(); if let Some(parent) = pid_path.parent() { @@ -385,21 +514,19 @@ pub async fn run(port: u16) -> std::io::Result<()> { // Limit concurrent connections to prevent resource exhaustion let conn_limit = Arc::new(tokio::sync::Semaphore::new(64)); - // Start WebSocket server on all interfaces (0.0.0.0) - // This is intentional - mobile clients need network access to connect. - // Security model: Access is controlled at the network level via: - // - Local network: Only devices on same WiFi can connect - // - Tailscale: Only authenticated Tailscale network members can connect - // Users explicitly choose their connection mode in setup wizard. - let listener = TcpListener::bind(format!("0.0.0.0:{}", port)).await?; - tracing::info!("Daemon WebSocket server on port {}", port); + let listeners = bind_configured_listeners(port).await?; + tracing::info!( + listener_count = listeners.len(), + port, + "Daemon WebSocket listeners ready" + ); // Run the main loop with platform-specific signal handling #[cfg(unix)] - run_server_loop_unix(listener, state, conn_limit).await; + run_server_loop_unix(listeners, state, conn_limit).await; #[cfg(not(unix))] - run_server_loop_ctrlc_only(listener, state, conn_limit).await; + run_server_loop_ctrlc_only(listeners, state, conn_limit).await; // Cleanup let _ = std::fs::remove_file(&pid_path); @@ -407,10 +534,71 @@ pub async fn run(port: u16) -> std::io::Result<()> { Ok(()) } +async fn bind_configured_listeners(port: u16) -> std::io::Result> { + let cfg = crate::setup::load_config().unwrap_or_default(); + let mut addrs = BTreeSet::new(); + addrs.insert(format!("127.0.0.1:{}", port)); + + if !cfg.auth_configured() { + tracing::warn!( + "No mobile credentials are configured; only loopback listener will be enabled" + ); + return bind_listeners(addrs).await; + } + + match &cfg.connection_mode { + crate::setup::ConnectionMode::Local => { + if let Some(ip) = crate::setup::get_local_ip().or(cfg.local_ip.clone()) { + addrs.insert(format!("{}:{}", ip, port)); + } + } + crate::setup::ConnectionMode::Tailscale => { + let ts = crate::setup::check_tailscale(); + if ts.logged_in { + if let Some(ip) = ts.ip.or(cfg.tailscale_ip.clone()) { + addrs.insert(format!("{}:{}", ip, port)); + } + } else { + tracing::warn!( + "Tailscale mode selected but Tailscale is not connected; mobile listener is disabled" + ); + } + } + crate::setup::ConnectionMode::Custom(url) => { + tracing::warn!( + url = %url, + "Custom connection mode does not imply all-interface binding; configure a local proxy to loopback or rerun setup for LAN/Tailscale" + ); + } + } + + bind_listeners(addrs).await +} + +async fn bind_listeners(addrs: BTreeSet) -> std::io::Result> { + let mut listeners = Vec::new(); + for addr in addrs { + match TcpListener::bind(&addr).await { + Ok(listener) => { + tracing::info!(addr = %addr, "Bound daemon listener"); + listeners.push(listener); + } + Err(err) => { + tracing::warn!(addr = %addr, error = %err, "Failed to bind daemon listener"); + if addr.starts_with("127.0.0.1:") { + return Err(err); + } + } + } + } + + Ok(listeners) +} + /// Server loop with Unix signal handling (SIGTERM + Ctrl+C) #[cfg(unix)] async fn run_server_loop_unix( - listener: TcpListener, + listeners: Vec, state: SharedState, conn_limit: Arc, ) { @@ -424,73 +612,76 @@ async fn run_server_loop_unix( sigterm_result.err() ); // Fall back to generic loop with just Ctrl+C - run_server_loop_ctrlc_only(listener, state, conn_limit).await; + run_server_loop_ctrlc_only(listeners, state, conn_limit).await; return; } let mut sigterm = sigterm_result.unwrap(); + let handles = spawn_accept_loops(listeners, state, conn_limit); - loop { - tokio::select! { - result = listener.accept() => { - if let Ok((stream, addr)) = result { - let state = state.clone(); - let permit = conn_limit.clone().try_acquire_owned(); - match permit { - Ok(permit) => { - tokio::spawn(async move { - let _permit = permit; // held until connection ends - let _ = handle_connection(stream, addr, state).await; - }); - } - Err(_) => { - tracing::warn!("Connection limit reached, rejecting {}", addr); - } - } - } - } - _ = tokio::signal::ctrl_c() => { - tracing::info!("Daemon shutting down (Ctrl+C)"); - break; - } - _ = sigterm.recv() => { - tracing::info!("Daemon shutting down (SIGTERM)"); - break; - } + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("Daemon shutting down (Ctrl+C)"); + } + _ = sigterm.recv() => { + tracing::info!("Daemon shutting down (SIGTERM)"); } } + for handle in handles { + handle.abort(); + } } /// Server loop with Ctrl+C only (fallback or non-Unix) async fn run_server_loop_ctrlc_only( - listener: TcpListener, + listeners: Vec, state: SharedState, conn_limit: Arc, ) { - loop { - tokio::select! { - result = listener.accept() => { - if let Ok((stream, addr)) = result { - let state = state.clone(); - let permit = conn_limit.clone().try_acquire_owned(); - match permit { - Ok(permit) => { - tokio::spawn(async move { - let _permit = permit; - let _ = handle_connection(stream, addr, state).await; - }); + let handles = spawn_accept_loops(listeners, state, conn_limit); + let _ = tokio::signal::ctrl_c().await; + tracing::info!("Daemon shutting down (Ctrl+C)"); + for handle in handles { + handle.abort(); + } +} + +fn spawn_accept_loops( + listeners: Vec, + state: SharedState, + conn_limit: Arc, +) -> Vec> { + listeners + .into_iter() + .map(|listener| { + let state = state.clone(); + let conn_limit = conn_limit.clone(); + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, addr)) => { + let state = state.clone(); + let permit = conn_limit.clone().try_acquire_owned(); + match permit { + Ok(permit) => { + tokio::spawn(async move { + let _permit = permit; + let _ = handle_connection(stream, addr, state).await; + }); + } + Err(_) => { + tracing::warn!("Connection limit reached, rejecting {}", addr); + } + } } - Err(_) => { - tracing::warn!("Connection limit reached, rejecting {}", addr); + Err(err) => { + tracing::warn!(error = %err, "Daemon listener accept failed"); + break; } } } - } - _ = tokio::signal::ctrl_c() => { - tracing::info!("Daemon shutting down (Ctrl+C)"); - break; - } - } - } + }) + }) + .collect() } /// Handle WebSocket connection (could be mobile client or PTY session) @@ -499,21 +690,32 @@ async fn handle_connection( addr: SocketAddr, state: SharedState, ) -> Result<(), Box> { - // Uploads are sent base64-encoded over websocket. Keep headroom above the - // 50MB file cap to avoid protocol-level disconnects. + // Keep inbound messages bounded before authentication. Mobile attachments + // are capped to fit inside this after base64 encoding. let ws_config = WebSocketConfig { - max_message_size: Some(96 * 1024 * 1024), - max_frame_size: Some(96 * 1024 * 1024), + max_message_size: Some(24 * 1024 * 1024), + max_frame_size: Some(24 * 1024 * 1024), ..Default::default() }; let ws = accept_async_with_config(stream, Some(ws_config)).await?; - let (tx, mut rx) = ws.split(); - - // Wait for first message to determine client type - let first_msg = rx.next().await; + let (mut tx, mut rx) = ws.split(); + + // Wait briefly for the first message to determine client type. Without + // this timeout, idle unauthenticated sockets can hold all connection slots. + let first_msg = match tokio::time::timeout(FIRST_MESSAGE_TIMEOUT, rx.next()).await { + Ok(value) => value, + Err(_) => { + let _ = tx.send(Message::Close(None)).await; + return Ok(()); + } + }; match first_msg { Some(Ok(Message::Text(text))) => { + if text.len() > FIRST_MESSAGE_MAX_BYTES { + send_auth_error(&mut tx, "message_too_large", "First message is too large").await?; + return Ok(()); + } if let Ok(msg) = serde_json::from_str::(&text) { if msg.get("type").and_then(|v| v.as_str()) == Some("register_pty") { // This is a PTY session registering @@ -524,25 +726,327 @@ async fn handle_connection( ); return Ok(()); } + if let Err(reason) = validate_pty_registration(&msg) { + tracing::warn!( + addr = %addr, + reason = reason, + "Rejecting unauthenticated PTY registration" + ); + let _ = tx + .send(Message::Text( + serde_json::json!({ + "type": "error", + "code": "pty_auth_required", + "message": "PTY registration auth failed" + }) + .to_string(), + )) + .await; + let _ = tx.send(Message::Close(None)).await; + return Ok(()); + } return handle_pty_session(msg, tx, rx, addr, state).await; } } - // Assume it's a mobile client - handle_mobile_client(Some(text), tx, rx, addr, state).await + let auth_client = authenticate_mobile_client(text, &mut tx, &mut rx, addr).await?; + handle_mobile_client(tx, rx, addr, state, auth_client).await } _ => Ok(()), } } +async fn send_auth_error( + tx: &mut futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream, + Message, + >, + code: &str, + message: &str, +) -> Result<(), Box> { + let msg = ServerMessage::Error { + code: code.to_string(), + message: message.to_string(), + }; + let _ = tx.send(Message::Text(serde_json::to_string(&msg)?)).await; + let _ = tx.send(Message::Close(None)).await; + Ok(()) +} + +fn parse_auth_start_request(first_text: &str) -> Result { + match serde_json::from_str::(first_text) { + Ok(ClientMessage::AuthStart { + auth_version, + credential_id, + client_nonce, + mobile_installation_id, + sender_id, + client_version, + client_capabilities, + }) => { + if auth_version != auth::AUTH_VERSION { + return Err(AuthFailure::new( + "auth_unsupported", + "Unsupported auth protocol version", + )); + } + Ok(AuthStartRequest { + credential_id, + client_nonce, + mobile_installation_id, + sender_id, + client_version, + client_capabilities, + }) + } + _ => Err(AuthFailure::new( + "auth_required", + "MobileCLI daemon requires auth-v2 pairing. Run `mobilecli pair` and scan the QR code.", + )), + } +} + +fn active_credential_index( + cfg: &crate::setup::Config, + credential_id: &str, +) -> Result { + cfg.credentials + .iter() + .position(|c| c.credential_id == credential_id && c.is_active()) + .ok_or_else(|| AuthFailure::new("auth_invalid", "Unknown or revoked mobile credential")) +} + +fn validate_auth_response_text( + response_text: &str, + cfg: &crate::setup::Config, + credential: &auth::AuthCredential, + start: &AuthStartRequest, + server_nonce: &str, +) -> Result { + let response = match serde_json::from_str::(response_text) { + Ok(ClientMessage::AuthResponse { + credential_id, + client_nonce, + server_nonce, + mobile_installation_id, + proof, + }) => ( + credential_id, + client_nonce, + server_nonce, + mobile_installation_id, + proof, + ), + _ => return Err(AuthFailure::new("auth_required", "Expected auth_response")), + }; + + let ( + response_credential_id, + response_client_nonce, + response_server_nonce, + response_mobile_installation_id, + proof, + ) = response; + + if response_credential_id != start.credential_id + || response_client_nonce != start.client_nonce + || response_server_nonce != server_nonce + || response_mobile_installation_id != start.mobile_installation_id + { + return Err(AuthFailure::new( + "auth_invalid", + "Auth response transcript mismatch", + )); + } + + let transcript = auth::build_auth_transcript( + &cfg.server_id, + &start.credential_id, + &start.client_nonce, + server_nonce, + &start.mobile_installation_id, + ); + if !auth::verify_proof(&credential.verifier, &transcript, &proof) { + return Err(AuthFailure::new( + "auth_invalid", + "Invalid mobile credential proof", + )); + } + + Ok(AuthenticatedClient { + credential_id: start.credential_id.clone(), + mobile_installation_id: start.mobile_installation_id.clone(), + sender_id: start.sender_id.clone(), + client_version: start.client_version.clone(), + client_capabilities: start.client_capabilities, + scopes: credential.scopes.clone(), + }) +} + +fn validate_pty_registration(reg_msg: &serde_json::Value) -> Result<(), &'static str> { + let token = crate::setup::ensure_desktop_link_token().map_err(|_| "missing_local_secret")?; + validate_pty_registration_with_token(reg_msg, &token) +} + +fn validate_pty_registration_with_token( + reg_msg: &serde_json::Value, + token: &str, +) -> Result<(), &'static str> { + let version = reg_msg + .get("pty_auth_version") + .and_then(|v| v.as_u64()) + .ok_or("missing_version")?; + if version != auth::LOCAL_PTY_AUTH_VERSION as u64 { + return Err("unsupported_version"); + } + + let proof = reg_msg + .get("pty_proof") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .ok_or("missing_proof")?; + if token.trim().is_empty() { + return Err("missing_local_secret"); + } + + let session_id = reg_msg + .get("session_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or("missing_session_id")?; + let name = reg_msg + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Terminal"); + let command = reg_msg + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("shell"); + let project_path = reg_msg + .get("project_path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let runtime = reg_msg + .get("runtime") + .and_then(|v| v.as_str()) + .unwrap_or("pty") + .to_lowercase(); + let has_desktop = reg_msg + .get("desktop") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let transcript = auth::build_pty_registration_transcript( + session_id, + name, + command, + project_path, + &runtime, + has_desktop, + ); + + if auth::verify_local_pty_proof(token, &transcript, proof) { + Ok(()) + } else { + Err("invalid_proof") + } +} + +async fn authenticate_mobile_client( + first_text: String, + tx: &mut futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream, + Message, + >, + rx: &mut futures_util::stream::SplitStream>, + addr: SocketAddr, +) -> Result> { + let start = match parse_auth_start_request(&first_text) { + Ok(start) => start, + Err(failure) => { + send_auth_error(tx, failure.code, failure.message).await?; + return Err(failure.code.into()); + } + }; + + let mut cfg = match crate::setup::load_config() { + Some(cfg) if cfg.auth_configured() => cfg, + _ => { + send_auth_error( + tx, + "auth_locked", + "No mobile credentials are configured. Run `mobilecli pair` to create one.", + ) + .await?; + return Err("auth locked".into()); + } + }; + + let index = match active_credential_index(&cfg, &start.credential_id) { + Ok(index) => index, + Err(failure) => { + send_auth_error(tx, failure.code, failure.message).await?; + return Err(failure.code.into()); + } + }; + + let server_nonce = auth::generate_nonce(); + let challenge = ServerMessage::AuthChallenge { + auth_version: auth::AUTH_VERSION, + server_id: cfg.server_id.clone(), + credential_id: start.credential_id.clone(), + server_nonce: server_nonce.clone(), + }; + tx.send(Message::Text(serde_json::to_string(&challenge)?)) + .await?; + + let response = tokio::time::timeout(Duration::from_secs(10), rx.next()).await; + let response_text = match response { + Ok(Some(Ok(Message::Text(text)))) => text, + _ => { + send_auth_error(tx, "auth_required", "Timed out waiting for auth response").await?; + return Err("auth response timeout".into()); + } + }; + + let credential = cfg.credentials[index].clone(); + let auth_client = + match validate_auth_response_text(&response_text, &cfg, &credential, &start, &server_nonce) + { + Ok(client) => client, + Err(failure) => { + if failure.code == "auth_invalid" { + tracing::warn!( + addr = %addr, + credential_id = %start.credential_id, + "Rejected invalid mobile auth proof" + ); + } + send_auth_error(tx, failure.code, failure.message).await?; + return Err(failure.code.into()); + } + }; + + cfg.credentials[index].last_used_at = Some(Utc::now().to_rfc3339()); + if let Err(err) = crate::setup::save_config(&cfg) { + tracing::warn!(error = %err, "Failed to update credential last_used_at"); + } + + Ok(auth_client) +} + /// Handle mobile client connection async fn handle_mobile_client( - first_msg: Option, mut tx: futures_util::stream::SplitSink, Message>, mut rx: futures_util::stream::SplitStream>, addr: SocketAddr, state: SharedState, + auth_client: AuthenticatedClient, ) -> Result<(), Box> { - tracing::info!("Mobile client connected: {}", addr); + tracing::info!( + addr = %addr, + credential_id = %auth_client.credential_id, + client_version = %auth_client.client_version, + "Authenticated mobile client connected" + ); let (client_tx, mut client_rx) = mpsc::channel::(4096); let watch_tx = client_tx.clone(); @@ -551,6 +1055,24 @@ async fn handle_mobile_client( // Register client and get broadcast receiver let mut pty_rx = { let mut st = state.write().await; + if let Some(capabilities) = auth_client.client_capabilities { + st.mobile_client_capabilities.insert(addr, capabilities); + } + if let Some(sender_id) = auth_client + .sender_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + { + if let Some(previous_addr) = st.mobile_sender_addrs.insert(sender_id, addr) { + if previous_addr != addr { + drop(st); + evict_mobile_addr(&state, previous_addr).await; + st = state.write().await; + } + } + } + st.mobile_auth.insert(addr, auth_client.clone()); st.mobile_clients.insert(addr, client_tx); st.pty_broadcast.subscribe() }; @@ -584,7 +1106,10 @@ async fn handle_mobile_client( let fs = { let st = watch_state.read().await; - if !st.mobile_clients.contains_key(&addr) { + let active_ids = active_credential_ids_on_disk(); + if !st.mobile_clients.contains_key(&addr) + || !is_mobile_client_active(&st, &addr, &active_ids) + { break 'watch; } let watched = match st.file_watch_subscriptions.get(&addr) { @@ -633,14 +1158,28 @@ async fn handle_mobile_client( }); // Send welcome with device info - let (device_id, device_name) = { + let (device_id, device_name, server_id) = { let st = state.read().await; - (st.device_id.clone(), st.device_name.clone()) + if let Some(cfg) = crate::setup::load_config() { + ( + Some(cfg.device_id), + Some(cfg.device_name), + Some(cfg.server_id), + ) + } else { + ( + st.device_id.clone(), + st.device_name.clone(), + st.server_id.clone(), + ) + } }; let welcome = ServerMessage::Welcome { server_version: env!("CARGO_PKG_VERSION").to_string(), device_id, device_name, + server_id, + auth_version: Some(auth::AUTH_VERSION), }; tx.send(Message::Text(serde_json::to_string(&welcome)?)) .await?; @@ -651,31 +1190,35 @@ async fn handle_mobile_client( // Send current waiting states for all sessions (for late-joining clients) send_waiting_states(&state, &mut tx).await?; - // Process first message if it was a client message - if let Some(text) = first_msg { - match serde_json::from_str::(&text) { - Ok(msg) => process_client_msg(msg, &state, &mut tx, addr).await?, - Err(e) => { - tracing::debug!("Ignoring unparsable client message from {}: {}", addr, e); - } - } - } - loop { tokio::select! { // PTY output result = pty_rx.recv() => { match result { Ok((session_id, seq, data)) => { - let (flags, capabilities, attach_id) = { + let (flags, capabilities, attach_id, is_viewing, is_client_active) = { let st = state.read().await; + let active_ids = active_credential_ids_on_disk(); let caps = st.mobile_client_capabilities.get(&addr).copied().unwrap_or(0); let attach_id = st .mobile_attach_ids .get(&addr) .and_then(|sessions| sessions.get(&session_id).copied()); - (st.overhaul_flags, caps, attach_id) + let is_viewing = st + .mobile_views + .get(&addr) + .is_some_and(|sessions| sessions.contains(&session_id)); + ( + st.overhaul_flags, + caps, + attach_id, + is_viewing, + is_mobile_client_active(&st, &addr, &active_ids), + ) }; + if !is_client_active { + break; + } let msg = if should_use_attach_v2(flags, capabilities) { let Some(attach_id) = attach_id else { @@ -689,6 +1232,9 @@ async fn handle_mobile_client( timestamp_ms: Utc::now().timestamp_millis().max(0) as u64, } } else { + if !is_viewing { + continue; + } ServerMessage::PtyBytes { session_id, data: BASE64.encode(&data), @@ -706,6 +1252,14 @@ async fn handle_mobile_client( // Queued messages Some(msg) = client_rx.recv() => { + let is_client_active = { + let st = state.read().await; + let active_ids = active_credential_ids_on_disk(); + is_mobile_client_active(&st, &addr, &active_ids) + }; + if !is_client_active { + break; + } if tx.send(msg).await.is_err() { break; } @@ -812,6 +1366,7 @@ async fn handle_pty_session( has_desktop_wrapper: has_desktop, }, ); + refresh_file_system_roots(&mut st); st.pty_broadcast.clone() }; @@ -931,7 +1486,8 @@ async fn handle_pty_session( // Send push notifications (async to avoid blocking PTY) let tokens = { - let st = state.read().await; + let mut st = state.write().await; + retain_active_push_tokens(&mut st); st.push_tokens.clone() }; let session_id_clone = session_id.clone(); @@ -1080,14 +1636,18 @@ async fn handle_pty_session( if st.sessions.remove(&session_id).is_some() { st.tmux_viewport_controllers.remove(&session_id); clear_mobile_attach_for_session(&mut st, &session_id); + refresh_file_system_roots(&mut st); // Notify about session end let msg = ServerMessage::SessionEnded { session_id: session_id.clone(), exit_code, }; let msg_str = serde_json::to_string(&msg)?; - for client in st.mobile_clients.values() { - let _ = client.try_send(Message::Text(msg_str.clone())); + let active_ids = active_credential_ids_on_disk(); + for (addr, client) in &st.mobile_clients { + if is_mobile_client_active(&st, addr, &active_ids) { + let _ = client.try_send(Message::Text(msg_str.clone())); + } } true } else { @@ -1109,11 +1669,16 @@ async fn handle_pty_session( /// Validate command name - only allow known safe CLI commands fn is_allowed_command(command: &str) -> bool { + let path = std::path::Path::new(command); + if path.is_absolute() || command.contains('/') || command.contains('\\') { + return false; + } const ALLOWED_COMMANDS: &[&str] = &[ "claude", "codex", "gemini", "opencode", + "shell", "bash", "zsh", "sh", @@ -1134,6 +1699,36 @@ fn is_allowed_command(command: &str) -> bool { ALLOWED_COMMANDS.contains(&base) } +fn normalize_mobile_spawn_request( + command: &str, + args: &[String], +) -> Result<(String, Vec), Box> { + if !args.is_empty() { + return Err( + "Mobile-spawn arguments are not accepted; choose a supported spawn profile".into(), + ); + } + if !is_allowed_command(command) { + return Err(format!("Command '{}' is not in the allowed profile list", command).into()); + } + let normalized = match command { + "claude" | "codex" | "gemini" | "opencode" => command.to_string(), + "shell" | "bash" | "sh" | "zsh" | "fish" | "nu" | "pwsh" | "powershell" => { + let default_shell = platform::default_shell(); + let base = shell_base_name(&default_shell); + if is_allowed_command(&base) { + base + } else if cfg!(windows) { + "powershell".to_string() + } else { + "sh".to_string() + } + } + _ => return Err("Unsupported mobile spawn profile".into()), + }; + Ok((normalized, Vec::new())) +} + /// Validate that a string is safe for shell interpolation /// Rejects newlines, null bytes, and other problematic characters fn is_shell_safe(s: &str) -> bool { @@ -1145,6 +1740,7 @@ fn is_shell_safe(s: &str) -> bool { } /// POSIX-safe single-quote wrapper for shell tokens. +#[cfg(not(windows))] fn shell_quote_posix(s: &str) -> String { if s.is_empty() { return "''".to_string(); @@ -1170,6 +1766,7 @@ fn shell_base_name(shell: &str) -> String { .to_lowercase() } +#[cfg(not(windows))] fn shell_args_for_command(shell: &str, command: &str) -> Vec { let base = shell_base_name(shell); let supports_login = matches!( @@ -1189,12 +1786,14 @@ fn shell_args_for_command(shell: &str, command: &str) -> Vec { /// Escape a string for embedding inside AppleScript double-quoted literals. /// Handles backslashes, double quotes, and strips newlines to prevent injection. +#[cfg(not(windows))] fn escape_for_applescript(s: &str) -> String { s.replace('\\', "\\\\") .replace('"', "\\\"") .replace(['\n', '\r'], " ") } +#[cfg(not(windows))] fn shell_command_line(shell: &str, args: &[String]) -> String { let mut parts = Vec::with_capacity(args.len() + 1); parts.push(shell_quote_posix(shell)); @@ -1242,6 +1841,7 @@ fn resolve_mobilecli_bin() -> String { } /// Build the shell command to run inside a terminal emulator. +#[cfg(not(windows))] fn build_wrap_shell_command( mobilecli_bin: &str, session_name: &str, @@ -1323,6 +1923,10 @@ async fn spawn_session_from_mobile( name: Option<&str>, working_dir: Option<&str>, ) -> Result<(), Box> { + let (command, normalized_args) = normalize_mobile_spawn_request(command, args)?; + let command = command.as_str(); + let args = normalized_args.as_slice(); + // Security: Validate command is in allowlist if !is_allowed_command(command) { return Err(format!("Command '{}' is not in the allowed list", command).into()); @@ -1342,7 +1946,7 @@ async fn spawn_session_from_mobile( return Err("Name contains unsafe characters".into()); } } - if let Some(dir) = working_dir { + let effective_working_dir = if let Some(dir) = working_dir { if !is_shell_safe(dir) { return Err("Working directory contains unsafe characters".into()); } @@ -1354,182 +1958,237 @@ async fn spawn_session_from_mobile( if !path.is_dir() { return Err("Working directory does not exist or is not a directory".into()); } - } + if !is_path_within_approved_roots(path) { + return Err("Working directory is outside approved filesystem roots".into()); + } + Some(dir.to_string()) + } else { + Some(default_mobile_spawn_working_dir()?) + }; + let effective_working_dir = effective_working_dir.as_deref(); #[cfg(windows)] { - return spawn_session_windows(command, args, name, working_dir); + return spawn_session_windows(command, args, name, effective_working_dir); } - // Try to detect terminal emulator (headless servers won't have one) - let terminal = detect_terminal_emulator().ok(); - - // Build the command to run inside the terminal - let session_name = name.unwrap_or(command); - let mobilecli_bin = resolve_mobilecli_bin(); - let shell_candidate = platform::default_shell(); - let shell = if std::path::Path::new(&shell_candidate).exists() - || which::which(&shell_candidate).is_ok() + #[cfg(not(windows))] { - shell_candidate - } else { - "/bin/sh".to_string() - }; - // Default to home directory when no working_dir is specified. - // Without this, the spawned terminal inherits the daemon's CWD, - // which is wherever the daemon was launched from (often the project dir). - let home_dir_buf = dirs_next::home_dir(); - let effective_working_dir: Option<&str> = - working_dir.or_else(|| home_dir_buf.as_ref().and_then(|p| p.to_str())); - - let wrap_cmd = build_wrap_shell_command( - &mobilecli_bin, - session_name, - command, - args, - effective_working_dir, - ); - let shell_args = shell_args_for_command(&shell, &wrap_cmd); + // Try to detect terminal emulator (headless servers won't have one) + let terminal = detect_terminal_emulator().ok(); + + // Build the command to run inside the terminal + let session_name = name.unwrap_or(command); + let mobilecli_bin = resolve_mobilecli_bin(); + let shell_candidate = platform::default_shell(); + let shell = if std::path::Path::new(&shell_candidate).exists() + || which::which(&shell_candidate).is_ok() + { + shell_candidate + } else { + "/bin/sh".to_string() + }; + let wrap_cmd = build_wrap_shell_command( + &mobilecli_bin, + session_name, + command, + args, + effective_working_dir, + ); + let shell_args = shell_args_for_command(&shell, &wrap_cmd); - let mut cmd = if let Some(ref terminal) = terminal { - tracing::info!("Spawning session: {} via {}", wrap_cmd, terminal.name); + let mut cmd = if let Some(ref terminal) = terminal { + tracing::info!("Spawning session: {} via {}", wrap_cmd, terminal.name); - // Build terminal command based on detected emulator - let mut c = std::process::Command::new(&terminal.binary); + // Build terminal command based on detected emulator + let mut c = std::process::Command::new(&terminal.binary); - match terminal.name.as_str() { - "kitty" => { - c.arg("--").arg(&shell).args(&shell_args); - } - "alacritty" => { - c.arg("-e").arg(&shell).args(&shell_args); - } - "gnome-terminal" | "tilix" => { - c.arg("--").arg(&shell).args(&shell_args); - } - "konsole" => { - // Konsole's -e consumes the rest of the command line - // Pass all args as a single coherent command to avoid quote issues - let full_cmd = format!("{} {}", shell, shell_args.join(" ")); - c.arg("-e").arg("/bin/sh").arg("-c").arg(&full_cmd); - } - "xterm" => { - // In headless Xvfb environments, xterm needs explicit geometry - // and font settings to render correctly without a real display. - // -geometry: Set terminal size (160 columns x 50 rows is generous for modern apps) - // -fa: Use a standard monospace font to avoid font rendering issues - // -fg/-bg: Explicit foreground/background colors - c.args([ - "-geometry", - "160x50", - "-fa", - "Monospace", - "-fg", - "white", - "-bg", - "black", - "-e", - &shell, - ]) - .args(&shell_args); - } - "urxvt" => { - c.arg("-e").arg(&shell).args(&shell_args); - } - "wezterm" => { - c.args(["start", "--"]).arg(&shell).args(&shell_args); - } - "iterm" => { - // macOS iTerm2 - use osascript to open a new window - let shell_cmd = escape_for_applescript(&shell_command_line(&shell, &shell_args)); - let script = format!( - r#"tell application "iTerm" + match terminal.name.as_str() { + "kitty" => { + c.arg("--").arg(&shell).args(&shell_args); + } + "alacritty" => { + c.arg("-e").arg(&shell).args(&shell_args); + } + "gnome-terminal" | "tilix" => { + c.arg("--").arg(&shell).args(&shell_args); + } + "konsole" => { + // Konsole's -e consumes the rest of the command line + // Pass all args as a single coherent command to avoid quote issues + let full_cmd = format!("{} {}", shell, shell_args.join(" ")); + c.arg("-e").arg("/bin/sh").arg("-c").arg(&full_cmd); + } + "xterm" => { + // In headless Xvfb environments, xterm needs explicit geometry + // and font settings to render correctly without a real display. + // -geometry: Set terminal size (160 columns x 50 rows is generous for modern apps) + // -fa: Use a standard monospace font to avoid font rendering issues + // -fg/-bg: Explicit foreground/background colors + c.args([ + "-geometry", + "160x50", + "-fa", + "Monospace", + "-fg", + "white", + "-bg", + "black", + "-e", + &shell, + ]) + .args(&shell_args); + } + "urxvt" => { + c.arg("-e").arg(&shell).args(&shell_args); + } + "wezterm" => { + c.args(["start", "--"]).arg(&shell).args(&shell_args); + } + "iterm" => { + // macOS iTerm2 - use osascript to open a new window + let shell_cmd = + escape_for_applescript(&shell_command_line(&shell, &shell_args)); + let script = format!( + r#"tell application "iTerm" create window with default profile tell current session of current window write text "{shell_cmd}" end tell end tell"#, - ); - c = std::process::Command::new("osascript"); - c.args(["-e", &script]); - } - "terminal" => { - // macOS Terminal.app - let shell_cmd = escape_for_applescript(&shell_command_line(&shell, &shell_args)); - let script = format!( - r#"tell application "Terminal" + ); + c = std::process::Command::new("osascript"); + c.args(["-e", &script]); + } + "terminal" => { + // macOS Terminal.app + let shell_cmd = + escape_for_applescript(&shell_command_line(&shell, &shell_args)); + let script = format!( + r#"tell application "Terminal" do script "{shell_cmd}" activate end tell"#, - ); - c = std::process::Command::new("osascript"); - c.args(["-e", &script]); - } - _ => { - // Generic fallback: try -e flag - c.arg("-e").arg(&shell).args(&shell_args); + ); + c = std::process::Command::new("osascript"); + c.args(["-e", &script]); + } + _ => { + // Generic fallback: try -e flag + c.arg("-e").arg(&shell).args(&shell_args); + } } - } - c - } else { - // Headless mode: no terminal emulator available. - // Use tmux for session management if available, otherwise spawn directly. - // The mobilecli pty-wrap command creates its own PTY, so a terminal - // window is not required — the mobile app views output via WebSocket. - if which::which("tmux").is_ok() { - let tmux_name = format!( - "mcli-{}", - session_name.replace(|ch: char| !ch.is_alphanumeric() && ch != '-', "-") - ); - let shell_cmd = shell_command_line(&shell, &shell_args); - tracing::info!("Spawning session headless (tmux): {}", wrap_cmd); - let mut c = std::process::Command::new("tmux"); - c.args(["new-session", "-d", "-s", &tmux_name, &shell_cmd]); c } else { - // Direct spawn: mobilecli pty-wrap creates its own PTY - tracing::info!("Spawning session headless (direct): {}", wrap_cmd); - let mut c = std::process::Command::new(&shell); - c.args(&shell_args); - c + // Headless mode: no terminal emulator available. + // Use tmux for session management if available, otherwise spawn directly. + // The mobilecli pty-wrap command creates its own PTY, so a terminal + // window is not required — the mobile app views output via WebSocket. + if which::which("tmux").is_ok() { + let tmux_name = format!( + "mcli-{}", + session_name.replace(|ch: char| !ch.is_alphanumeric() && ch != '-', "-") + ); + let shell_cmd = shell_command_line(&shell, &shell_args); + tracing::info!("Spawning session headless (tmux): {}", wrap_cmd); + let mut c = std::process::Command::new("tmux"); + c.args(["new-session", "-d", "-s", &tmux_name, &shell_cmd]); + c + } else { + // Direct spawn: mobilecli pty-wrap creates its own PTY + tracing::info!("Spawning session headless (direct): {}", wrap_cmd); + let mut c = std::process::Command::new(&shell); + c.args(&shell_args); + c + } + }; + + // Set working directory (defaults to home dir when not specified by caller) + if let Some(dir) = effective_working_dir { + cmd.current_dir(dir); } - }; - // Set working directory (defaults to home dir when not specified by caller) - if let Some(dir) = effective_working_dir { - cmd.current_dir(dir); + // Spawn detached + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + // Create new session to detach from parent + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?; + + Ok(()) } +} - // Spawn detached - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - // Create new session to detach from parent - if libc::setsid() == -1 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); +fn is_path_within_approved_roots(path: &std::path::Path) -> bool { + let Ok(canonical_path) = path.canonicalize() else { + return false; + }; + let Some(cfg) = crate::setup::load_config() else { + return false; + }; + if cfg.filesystem.whole_home_enabled { + if let Some(home) = dirs_next::home_dir().and_then(|p| p.canonicalize().ok()) { + if canonical_path.starts_with(home) { + return true; + } } } + cfg.filesystem.allowed_roots.iter().any(|root| { + std::path::Path::new(root) + .canonicalize() + .is_ok_and(|canonical_root| canonical_path.starts_with(canonical_root)) + }) +} - cmd.stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn()?; +fn default_mobile_spawn_working_dir() -> Result> { + let cfg = crate::setup::load_config().ok_or("MobileCLI config is missing")?; - Ok(()) + if let Some(root) = cfg.filesystem.allowed_roots.iter().find_map(|root| { + let path = std::path::Path::new(root); + if path.is_dir() && is_path_within_approved_roots(path) { + path.canonicalize() + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())) + } else { + None + } + }) { + return Ok(root); + } + + if cfg.filesystem.whole_home_enabled { + if let Some(home) = dirs_next::home_dir().filter(|p| p.is_dir()) { + if is_path_within_approved_roots(&home) { + return Ok(home.to_string_lossy().to_string()); + } + } + } + + Err("Mobile spawn requires an approved working directory".into()) } /// Terminal emulator detection result +#[cfg(not(windows))] struct TerminalInfo { name: String, binary: String, } /// Detect available terminal emulator +#[cfg(not(windows))] fn detect_terminal_emulator() -> Result> { // Check for common terminal emulators in order of preference let terminals = [ @@ -1616,7 +2275,39 @@ async fn process_client_msg( >, addr: SocketAddr, ) -> Result<(), Box> { + if let Some(scope) = required_scope_for_message(&msg) { + if !client_has_scope(state, addr, scope).await { + let err = ServerMessage::Error { + code: "forbidden".to_string(), + message: format!("Credential does not have required scope: {}", scope), + }; + tx.send(Message::Text(serde_json::to_string(&err)?)).await?; + return Ok(()); + } + } + + if let Some(session_id) = session_control_target(&msg) { + if !client_is_subscribed_to_session(state, addr, session_id).await { + let err = ServerMessage::Error { + code: "session_not_subscribed".to_string(), + message: format!( + "Session control requires an active subscription to session {}", + session_id + ), + }; + tx.send(Message::Text(serde_json::to_string(&err)?)).await?; + return Ok(()); + } + } + match msg { + ClientMessage::AuthStart { .. } | ClientMessage::AuthResponse { .. } => { + let err = ServerMessage::Error { + code: "already_authenticated".to_string(), + message: "Auth messages are only accepted during the handshake".to_string(), + }; + tx.send(Message::Text(serde_json::to_string(&err)?)).await?; + } ClientMessage::Hello { client_version, sender_id, @@ -2426,7 +3117,8 @@ async fn process_client_msg( .unwrap_or_default(); if !viewport_state.following_live { - if let Some(frame_bytes) = capture_tmux_viewport_simple(socket, name).await { + if let Some(frame_bytes) = capture_tmux_viewport_simple(socket, name).await + { let mut payload = Vec::with_capacity(frame_bytes.len() + 16); payload.extend_from_slice(b"\x1b[2J\x1b[H"); payload.extend_from_slice(&frame_bytes); @@ -2477,15 +3169,19 @@ async fn process_client_msg( // Update scroll offset and extract viewport chunk in one write lock. let mut st = state.write().await; - let buf_len = st.sessions.get(&session_id) - .map(|s| s.scrollback.len()).unwrap_or(0); + let buf_len = st + .sessions + .get(&session_id) + .map(|s| s.scrollback.len()) + .unwrap_or(0); if buf_len > 0 { let max_offset = buf_len.saturating_sub(page_bytes); // Update scroll offset based on action. let current_offset = { - let offset = st.pty_scroll_offsets + let offset = st + .pty_scroll_offsets .entry(session_id.clone()) .or_insert(0usize); match action { @@ -2515,7 +3211,11 @@ async fn process_client_msg( }; let history_size = buf_len / 80; - let scroll_position = if following_live { 0 } else { current_offset / 80 }; + let scroll_position = if following_live { + 0 + } else { + current_offset / 80 + }; drop(st); // Send viewport frame if scrolled (not following live). @@ -2604,6 +3304,7 @@ async fn process_client_msg( for views in st.mobile_views.values_mut() { views.remove(&session_id); } + refresh_file_system_roots(&mut st); true } else { false @@ -2625,8 +3326,11 @@ async fn process_client_msg( exit_code: -1, }; let end_str = serde_json::to_string(&end_msg)?; - for client in st.mobile_clients.values() { - let _ = client.try_send(Message::Text(end_str.clone())); + let active_ids = active_credential_ids_on_disk(); + for (addr, client) in &st.mobile_clients { + if is_mobile_client_active(&st, addr, &active_ids) { + let _ = client.try_send(Message::Text(end_str.clone())); + } } } @@ -2649,19 +3353,58 @@ async fn process_client_msg( platform, } => { let mut st = state.write().await; - // Remove existing token with same value to avoid duplicates - st.push_tokens.retain(|t| t.token != token); + if !is_valid_push_token(&token_type, &token) { + let msg = ServerMessage::Error { + code: "invalid_push_token".to_string(), + message: "Unsupported or malformed push token".to_string(), + }; + tx.send(Message::Text(serde_json::to_string(&msg)?)).await?; + return Ok(()); + } + let Some(client) = st.mobile_auth.get(&addr).cloned() else { + let msg = ServerMessage::Error { + code: "auth_required".to_string(), + message: "Push token registration requires auth".to_string(), + }; + tx.send(Message::Text(serde_json::to_string(&msg)?)).await?; + return Ok(()); + }; + // Replace this mobile installation's token and dedupe exact token values. + st.push_tokens.retain(|t| { + t.token != token + && !(t.credential_id == client.credential_id + && t.mobile_installation_id == client.mobile_installation_id) + }); + let credential_token_count = st + .push_tokens + .iter() + .filter(|t| t.credential_id == client.credential_id) + .count(); + if credential_token_count >= 3 { + let msg = ServerMessage::Error { + code: "push_token_limit".to_string(), + message: "Too many push tokens registered for this credential".to_string(), + }; + tx.send(Message::Text(serde_json::to_string(&msg)?)).await?; + return Ok(()); + } st.push_tokens.push(PushToken { token: token.clone(), token_type: token_type.clone(), platform: platform.clone(), + credential_id: client.credential_id, + mobile_installation_id: client.mobile_installation_id, }); tracing::info!("Registered push token ({}/{})", token_type, platform); } ClientMessage::UnregisterPushToken { token } => { let mut st = state.write().await; + let Some(client) = st.mobile_auth.get(&addr).cloned() else { + return Ok(()); + }; let before = st.push_tokens.len(); - st.push_tokens.retain(|t| t.token != token); + st.push_tokens + .retain(|t| !(t.token == token && t.credential_id == client.credential_id)); let after = st.push_tokens.len(); tracing::info!( "Unregistered push token (removed {})", @@ -3066,6 +3809,20 @@ async fn process_client_msg( path, recursive, } => { + if !destructive_operations_enabled() { + send_fs_error( + tx, + request_id, + "delete_path", + &path, + FileSystemError::PermissionDenied { + path: path.clone(), + reason: "Destructive filesystem operations are disabled".to_string(), + }, + ) + .await?; + return Ok(()); + } if let Err(retry_after_ms) = check_fs_rate_limit(state, addr).await { send_fs_error( tx, @@ -3098,6 +3855,20 @@ async fn process_client_msg( old_path, new_path, } => { + if !destructive_operations_enabled() { + send_fs_error( + tx, + request_id, + "rename_path", + &old_path, + FileSystemError::PermissionDenied { + path: old_path.clone(), + reason: "Destructive filesystem operations are disabled".to_string(), + }, + ) + .await?; + return Ok(()); + } if let Err(retry_after_ms) = check_fs_rate_limit(state, addr).await { send_fs_error( tx, @@ -3205,7 +3976,9 @@ async fn process_client_msg( return Ok(()); } let fs = { state.read().await.file_system.clone() }; - let max_results = max_results.unwrap_or(fs.config().max_search_results); + let max_results = max_results + .unwrap_or(fs.config().max_search_results) + .min(fs.config().max_search_results); match fs .search() .search_files( @@ -3362,7 +4135,7 @@ async fn process_client_msg( return Ok(()); } let fs = { state.read().await.file_system.clone() }; - let home = dirs_next::home_dir().or_else(|| fs.config().allowed_roots.first().cloned()); + let home = fs.config().allowed_roots.first().cloned(); if let Some(path) = home { let msg = ServerMessage::HomeDirectory { request_id, @@ -3408,6 +4181,146 @@ async fn process_client_msg( Ok(()) } +fn required_scope_for_message(msg: &ClientMessage) -> Option<&'static str> { + match msg { + ClientMessage::AuthStart { .. } + | ClientMessage::AuthResponse { .. } + | ClientMessage::Hello { .. } + | ClientMessage::Ping => None, + ClientMessage::GetSessions + | ClientMessage::Subscribe { .. } + | ClientMessage::Unsubscribe { .. } + | ClientMessage::GetSessionHistory { .. } => Some(auth::SCOPE_SESSION_READ), + ClientMessage::SendInput { .. } + | ClientMessage::PtyResize { .. } + | ClientMessage::TmuxViewport { .. } + | ClientMessage::RenameSession { .. } + | ClientMessage::CloseSession { .. } + | ClientMessage::ToolApproval { .. } => Some(auth::SCOPE_SESSION_CONTROL), + ClientMessage::SpawnSession { .. } => Some(auth::SCOPE_SESSION_SPAWN), + ClientMessage::RegisterPushToken { .. } | ClientMessage::UnregisterPushToken { .. } => { + Some(auth::SCOPE_PUSH_REGISTER) + } + ClientMessage::ListDirectory { .. } + | ClientMessage::ReadFile { .. } + | ClientMessage::ReadFileChunk { .. } + | ClientMessage::GetFileInfo { .. } + | ClientMessage::SearchFiles { .. } + | ClientMessage::GetHomeDirectory { .. } + | ClientMessage::GetAllowedRoots { .. } => Some(auth::SCOPE_FS_READ), + ClientMessage::WriteFile { .. } + | ClientMessage::CreateDirectory { .. } + | ClientMessage::RenamePath { .. } + | ClientMessage::CopyPath { .. } => Some(auth::SCOPE_FS_WRITE), + ClientMessage::DeletePath { .. } => Some(auth::SCOPE_FS_DELETE), + ClientMessage::WatchDirectory { .. } | ClientMessage::UnwatchDirectory { .. } => { + Some(auth::SCOPE_FS_WATCH) + } + ClientMessage::UploadFile { .. } => Some(auth::SCOPE_FS_UPLOAD), + } +} + +fn session_control_target(msg: &ClientMessage) -> Option<&str> { + match msg { + ClientMessage::SendInput { session_id, .. } + | ClientMessage::PtyResize { session_id, .. } + | ClientMessage::TmuxViewport { session_id, .. } + | ClientMessage::RenameSession { session_id, .. } + | ClientMessage::CloseSession { session_id } + | ClientMessage::ToolApproval { session_id, .. } => Some(session_id.as_str()), + _ => None, + } +} + +async fn client_has_scope(state: &SharedState, addr: SocketAddr, scope: &str) -> bool { + let client = state.read().await.mobile_auth.get(&addr).cloned(); + let Some(client) = client else { + return false; + }; + client.has_scope(scope) && is_credential_active_on_disk(&client.credential_id) +} + +async fn client_is_subscribed_to_session( + state: &SharedState, + addr: SocketAddr, + session_id: &str, +) -> bool { + state + .read() + .await + .mobile_views + .get(&addr) + .is_some_and(|views| views.contains(session_id)) +} + +fn is_credential_active_on_disk(credential_id: &str) -> bool { + crate::setup::load_config().is_some_and(|cfg| { + cfg.credentials + .iter() + .any(|credential| credential.credential_id == credential_id && credential.is_active()) + }) +} + +fn retain_active_push_tokens(st: &mut DaemonState) { + let Some(cfg) = crate::setup::load_config() else { + st.push_tokens.clear(); + return; + }; + let active_ids: BTreeSet = cfg + .credentials + .iter() + .filter(|credential| credential.is_active()) + .map(|credential| credential.credential_id.clone()) + .collect(); + st.push_tokens + .retain(|token| active_ids.contains(&token.credential_id)); +} + +fn active_credential_ids_on_disk() -> BTreeSet { + crate::setup::load_config() + .map(|cfg| { + cfg.credentials + .iter() + .filter(|credential| credential.is_active()) + .map(|credential| credential.credential_id.clone()) + .collect() + }) + .unwrap_or_default() +} + +fn is_mobile_client_active( + st: &DaemonState, + addr: &SocketAddr, + active_ids: &BTreeSet, +) -> bool { + st.mobile_auth + .get(addr) + .is_some_and(|client| active_ids.contains(&client.credential_id)) +} + +fn is_valid_push_token(token_type: &str, token: &str) -> bool { + let token = token.trim(); + if token.is_empty() || token.len() > 4096 || token.chars().any(char::is_whitespace) { + return false; + } + match token_type { + "expo" => { + ((token.starts_with("ExponentPushToken[") && token.ends_with(']')) + || (token.starts_with("ExpoPushToken[") && token.ends_with(']'))) + && token.len() <= 256 + } + "apns" => token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()), + "fcm" => token.len() >= 20, + _ => false, + } +} + +fn destructive_operations_enabled() -> bool { + crate::setup::load_config() + .map(|cfg| cfg.filesystem.destructive_operations) + .unwrap_or(false) +} + async fn send_fs_error( tx: &mut futures_util::stream::SplitSink< tokio_tungstenite::WebSocketStream, @@ -3630,8 +4543,11 @@ async fn broadcast_sessions_update(state: &SharedState) { .collect(); let msg = ServerMessage::Sessions { sessions: items }; if let Ok(msg_str) = serde_json::to_string(&msg) { - for client in st.mobile_clients.values() { - let _ = client.try_send(Message::Text(msg_str.clone())); + let active_ids = active_credential_ids_on_disk(); + for (addr, client) in &st.mobile_clients { + if is_mobile_client_active(&st, addr, &active_ids) { + let _ = client.try_send(Message::Text(msg_str.clone())); + } } } } @@ -3679,8 +4595,11 @@ async fn broadcast_waiting_for_input(state: &SharedState, session_id: &str) { cli_type: session.cli_tracker.current().as_str().to_string(), }; if let Ok(msg_str) = serde_json::to_string(&msg) { - for client in st.mobile_clients.values() { - let _ = client.try_send(Message::Text(msg_str.clone())); + let active_ids = active_credential_ids_on_disk(); + for (addr, client) in &st.mobile_clients { + if is_mobile_client_active(&st, addr, &active_ids) { + let _ = client.try_send(Message::Text(msg_str.clone())); + } } } } @@ -3693,8 +4612,11 @@ async fn broadcast_waiting_cleared(state: &SharedState, session_id: &str) { timestamp: Utc::now().to_rfc3339(), }; if let Ok(msg_str) = serde_json::to_string(&msg) { - for client in st.mobile_clients.values() { - let _ = client.try_send(Message::Text(msg_str.clone())); + let active_ids = active_credential_ids_on_disk(); + for (addr, client) in &st.mobile_clients { + if is_mobile_client_active(&st, addr, &active_ids) { + let _ = client.try_send(Message::Text(msg_str.clone())); + } } } } @@ -3719,15 +4641,8 @@ async fn broadcast_pty_resized( let ack_clients: Vec> = { let st = state.read().await; - st.mobile_views - .iter() - .filter_map(|(addr, views)| { - if !views.contains(session_id) { - return None; - } - st.mobile_clients.get(addr).cloned() - }) - .collect() + let active_ids = active_credential_ids_on_disk(); + pty_resized_ack_clients(&st, session_id, &active_ids) }; tracing::debug!( @@ -3745,6 +4660,25 @@ async fn broadcast_pty_resized( } } +fn pty_resized_ack_clients( + st: &DaemonState, + session_id: &str, + active_ids: &BTreeSet, +) -> Vec> { + st.mobile_views + .iter() + .filter_map(|(addr, views)| { + if !views.contains(session_id) { + return None; + } + if !is_mobile_client_active(st, addr, active_ids) { + return None; + } + st.mobile_clients.get(addr).cloned() + }) + .collect() +} + /// Send current waiting states to a newly connected mobile client. async fn send_waiting_states( state: &SharedState, @@ -4390,6 +5324,7 @@ async fn cleanup_client_state(state: &SharedState, addr: SocketAddr) { st.mobile_sender_addrs.remove(&sender_id); } st.mobile_client_capabilities.remove(&addr); + st.mobile_auth.remove(&addr); st.mobile_attach_ids.remove(&addr); let (sessions_to_restore, sessions_detached) = match st.mobile_views.remove(&addr) { @@ -4551,25 +5486,413 @@ async fn send_push_notifications(tokens: &[PushToken], title: &str, body: &str, #[cfg(test)] mod tests { use super::{ - broadcast_pty_resized, build_upload_destination_path, capture_tmux_history, - clear_mobile_attach_for_session, is_noop_resize, is_stale_resize_epoch, - is_windows_reserved_device_name, resolve_resize_reason, sanitize_upload_file_name, - should_ignore_resize_without_viewers, should_ignore_restore_resize, + active_credential_index, build_upload_destination_path, capture_tmux_history, + clear_mobile_attach_for_session, file_system_config_from_setup_and_projects, + is_noop_resize, is_safe_session_project_root, is_stale_resize_epoch, is_valid_push_token, + is_windows_reserved_device_name, normalize_mobile_spawn_request, parse_auth_start_request, + pty_resized_ack_clients, resolve_resize_reason, sanitize_upload_file_name, + session_control_target, should_ignore_resize_without_viewers, should_ignore_restore_resize, should_mobile_enter_alt_screen, should_treat_as_tui_for_mobile, should_use_attach_v2, strip_terminal_report_sequences, strip_terminal_report_sequences_stateful, - update_alt_screen_state, AttachProtocolMode, DaemonState, OverhaulFlags, PtyResizeReason, - CLIENT_CAP_ATTACH_V2, DEFAULT_SCROLLBACK_MAX_BYTES, MAX_UPLOAD_FILE_NAME_BYTES, + update_alt_screen_state, validate_auth_response_text, validate_pty_registration_with_token, + AttachProtocolMode, AuthStartRequest, AuthenticatedClient, ClientMessage, DaemonState, + OverhaulFlags, PtyResizeReason, TmuxViewportAction, CLIENT_CAP_ATTACH_V2, + DEFAULT_SCROLLBACK_MAX_BYTES, MAX_UPLOAD_FILE_NAME_BYTES, }; - use std::sync::Arc; + use crate::{auth, setup::Config}; + use std::collections::BTreeSet; use tempfile::TempDir; - use tokio::sync::{mpsc, RwLock}; - use tokio::time::{timeout, Duration}; + use tokio::time::Duration; use tokio_tungstenite::tungstenite::Message; + fn test_config_with_credential() -> (Config, auth::PairingCredential) { + let mut cfg = Config { + server_id: "server-id".to_string(), + ..Config::default() + }; + let pairing = cfg.create_pairing_credential("test phone"); + (cfg, pairing) + } + + fn auth_start_message(credential_id: &str, auth_version: u8) -> String { + serde_json::to_string(&ClientMessage::AuthStart { + auth_version, + credential_id: credential_id.to_string(), + client_nonce: "client-nonce".to_string(), + mobile_installation_id: "mobile-installation".to_string(), + sender_id: Some("sender-id".to_string()), + client_version: "mobile-test".to_string(), + client_capabilities: Some(7), + }) + .expect("auth start json") + } + + fn auth_start_request(credential_id: &str) -> AuthStartRequest { + AuthStartRequest { + credential_id: credential_id.to_string(), + client_nonce: "client-nonce".to_string(), + mobile_installation_id: "mobile-installation".to_string(), + sender_id: Some("sender-id".to_string()), + client_version: "mobile-test".to_string(), + client_capabilities: Some(7), + } + } + + fn auth_response_message( + cfg: &Config, + credential: &auth::AuthCredential, + start: &AuthStartRequest, + server_nonce: &str, + ) -> String { + let transcript = auth::build_auth_transcript( + &cfg.server_id, + &start.credential_id, + &start.client_nonce, + server_nonce, + &start.mobile_installation_id, + ); + let proof = + auth::proof_from_verifier(&credential.verifier, &transcript).expect("valid proof"); + serde_json::to_string(&ClientMessage::AuthResponse { + credential_id: start.credential_id.clone(), + client_nonce: start.client_nonce.clone(), + server_nonce: server_nonce.to_string(), + mobile_installation_id: start.mobile_installation_id.clone(), + proof, + }) + .expect("auth response json") + } + + #[test] + fn auth_start_rejects_legacy_or_malformed_first_messages() { + let legacy_hello = serde_json::json!({ + "type": "hello", + "client_version": "legacy" + }) + .to_string(); + assert_eq!( + parse_auth_start_request(&legacy_hello).unwrap_err().code, + "auth_required" + ); + + assert_eq!( + parse_auth_start_request("not-json").unwrap_err().code, + "auth_required" + ); + } + + #[test] + fn auth_start_rejects_unsupported_versions_before_config_lookup() { + let (cfg, _) = test_config_with_credential(); + let start = auth_start_message(&cfg.credentials[0].credential_id, auth::AUTH_VERSION - 1); + + let failure = parse_auth_start_request(&start).unwrap_err(); + + assert_eq!(failure.code, "auth_unsupported"); + } + + #[test] + fn auth_start_accepts_auth_v2_fields() { + let (cfg, _) = test_config_with_credential(); + let start = parse_auth_start_request(&auth_start_message( + &cfg.credentials[0].credential_id, + auth::AUTH_VERSION, + )) + .expect("auth start"); + + assert_eq!(start.credential_id, cfg.credentials[0].credential_id); + assert_eq!(start.client_nonce, "client-nonce"); + assert_eq!(start.mobile_installation_id, "mobile-installation"); + assert_eq!(start.sender_id.as_deref(), Some("sender-id")); + assert_eq!(start.client_version, "mobile-test"); + assert_eq!(start.client_capabilities, Some(7)); + } + + #[test] + fn active_credential_lookup_rejects_unknown_and_revoked_credentials() { + let (mut cfg, pairing) = test_config_with_credential(); + assert_eq!( + active_credential_index(&cfg, &pairing.credential.credential_id), + Ok(0) + ); + + assert_eq!( + active_credential_index(&cfg, "missing").unwrap_err().code, + "auth_invalid" + ); + + cfg.credentials[0].revoke(); + assert_eq!( + active_credential_index(&cfg, &pairing.credential.credential_id) + .unwrap_err() + .code, + "auth_invalid" + ); + } + + #[test] + fn auth_response_rejects_transcript_mismatch() { + let (cfg, pairing) = test_config_with_credential(); + let mut start = auth_start_request(&pairing.credential.credential_id); + let response = auth_response_message(&cfg, &pairing.credential, &start, "server-nonce"); + start.client_nonce = "different-client-nonce".to_string(); + + let failure = validate_auth_response_text( + &response, + &cfg, + &pairing.credential, + &start, + "server-nonce", + ) + .unwrap_err(); + + assert_eq!(failure.code, "auth_invalid"); + assert_eq!(failure.message, "Auth response transcript mismatch"); + } + + #[test] + fn auth_response_rejects_invalid_proof() { + let (cfg, pairing) = test_config_with_credential(); + let start = auth_start_request(&pairing.credential.credential_id); + let response = serde_json::to_string(&ClientMessage::AuthResponse { + credential_id: start.credential_id.clone(), + client_nonce: start.client_nonce.clone(), + server_nonce: "server-nonce".to_string(), + mobile_installation_id: start.mobile_installation_id.clone(), + proof: "not-a-valid-proof".to_string(), + }) + .expect("auth response json"); + + let failure = validate_auth_response_text( + &response, + &cfg, + &pairing.credential, + &start, + "server-nonce", + ) + .unwrap_err(); + + assert_eq!(failure.code, "auth_invalid"); + assert_eq!(failure.message, "Invalid mobile credential proof"); + } + + #[test] + fn auth_response_accepts_bound_proof_and_preserves_client_identity() { + let (cfg, pairing) = test_config_with_credential(); + let start = auth_start_request(&pairing.credential.credential_id); + let response = auth_response_message(&cfg, &pairing.credential, &start, "server-nonce"); + + let client = validate_auth_response_text( + &response, + &cfg, + &pairing.credential, + &start, + "server-nonce", + ) + .expect("authenticated client"); + + assert_eq!(client.credential_id, pairing.credential.credential_id); + assert_eq!(client.mobile_installation_id, "mobile-installation"); + assert_eq!(client.sender_id.as_deref(), Some("sender-id")); + assert_eq!(client.client_version, "mobile-test"); + assert_eq!(client.client_capabilities, Some(7)); + assert_eq!(client.scopes, pairing.credential.scopes); + } + + fn pty_registration_message(token: &str) -> serde_json::Value { + let session_id = "session-1"; + let name = "Claude"; + let command = "claude"; + let project_path = "/tmp/project"; + let runtime = "pty"; + let desktop = true; + let transcript = auth::build_pty_registration_transcript( + session_id, + name, + command, + project_path, + runtime, + desktop, + ); + let proof = auth::local_pty_proof_from_token(token, &transcript); + serde_json::json!({ + "type": "register_pty", + "pty_auth_version": auth::LOCAL_PTY_AUTH_VERSION, + "pty_proof": proof, + "session_id": session_id, + "name": name, + "command": command, + "project_path": project_path, + "runtime": runtime, + "desktop": desktop, + }) + } + + #[test] + fn pty_registration_requires_auth_fields() { + let token = auth::generate_nonce(); + let mut msg = pty_registration_message(&token); + + msg.as_object_mut() + .expect("object") + .remove("pty_auth_version"); + assert_eq!( + validate_pty_registration_with_token(&msg, &token), + Err("missing_version") + ); + + let mut msg = pty_registration_message(&token); + msg.as_object_mut().expect("object").remove("pty_proof"); + assert_eq!( + validate_pty_registration_with_token(&msg, &token), + Err("missing_proof") + ); + } + + #[test] + fn pty_registration_rejects_bad_or_tampered_proofs() { + let token = auth::generate_nonce(); + let mut msg = pty_registration_message(&token); + msg["pty_proof"] = serde_json::Value::String("bad-proof".to_string()); + assert_eq!( + validate_pty_registration_with_token(&msg, &token), + Err("invalid_proof") + ); + + let mut msg = pty_registration_message(&token); + msg["project_path"] = serde_json::Value::String("/".to_string()); + assert_eq!( + validate_pty_registration_with_token(&msg, &token), + Err("invalid_proof") + ); + } + + #[test] + fn pty_registration_accepts_bound_local_proof() { + let token = auth::generate_nonce(); + let msg = pty_registration_message(&token); + + assert_eq!(validate_pty_registration_with_token(&msg, &token), Ok(())); + } + + #[test] + fn session_project_roots_reject_home_and_filesystem_root_by_default() { + let cfg = Config::default(); + let temp = TempDir::new().expect("tempdir"); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("project dir"); + + assert!(is_safe_session_project_root( + &cfg, + &project.to_string_lossy() + )); + + let root = project + .ancestors() + .last() + .expect("filesystem root") + .to_path_buf(); + assert!(!is_safe_session_project_root(&cfg, &root.to_string_lossy())); + + if let Some(home) = dirs_next::home_dir().filter(|p| p.is_dir()) { + assert!(!is_safe_session_project_root(&cfg, &home.to_string_lossy())); + } + } + + #[test] + fn filesystem_roots_include_safe_session_projects_only() { + let mut cfg = Config::default(); + cfg.filesystem.allowed_roots = Vec::new(); + cfg.filesystem.whole_home_enabled = false; + + let temp = TempDir::new().expect("tempdir"); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("project dir"); + let root = project + .ancestors() + .last() + .expect("filesystem root") + .to_string_lossy() + .to_string(); + let project = project.to_string_lossy().to_string(); + let paths = [root, project.clone()]; + + let fs_config = file_system_config_from_setup_and_projects(&cfg, paths.iter()); + + assert_eq!(fs_config.allowed_roots.len(), 1); + assert_eq!( + fs_config.allowed_roots[0], + std::path::PathBuf::from(project) + ); + } + #[test] fn default_scrollback_is_large_enough_for_frame_clis() { - assert_eq!(DEFAULT_SCROLLBACK_MAX_BYTES, 8 * 1024 * 1024); - assert!(DEFAULT_SCROLLBACK_MAX_BYTES > 64 * 1024); + let default_scrollback = DEFAULT_SCROLLBACK_MAX_BYTES; + assert_eq!(default_scrollback, 8 * 1024 * 1024); + assert!(default_scrollback > 64 * 1024); + } + + #[test] + fn session_control_target_only_marks_mutating_session_messages() { + assert_eq!( + session_control_target(&ClientMessage::SendInput { + session_id: "s1".to_string(), + text: "x".to_string(), + raw: false, + client_msg_id: None, + }), + Some("s1") + ); + assert_eq!( + session_control_target(&ClientMessage::PtyResize { + session_id: "s2".to_string(), + cols: 80, + rows: 24, + epoch: None, + reason: Some(PtyResizeReason::AttachInit), + }), + Some("s2") + ); + assert_eq!( + session_control_target(&ClientMessage::TmuxViewport { + session_id: "s3".to_string(), + action: TmuxViewportAction::Follow, + count: None, + }), + Some("s3") + ); + assert_eq!(session_control_target(&ClientMessage::GetSessions), None); + assert_eq!( + session_control_target(&ClientMessage::GetSessionHistory { + session_id: "s4".to_string(), + max_bytes: None, + }), + None + ); + } + + #[test] + fn push_token_validation_is_format_aware() { + assert!(is_valid_push_token( + "expo", + "ExponentPushToken[abcdefghijklmnopqrstuvwxyz]" + )); + assert!(is_valid_push_token( + "expo", + "ExpoPushToken[abcdefghijklmnopqrstuvwxyz]" + )); + assert!(is_valid_push_token( + "apns", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )); + assert!(is_valid_push_token("fcm", "abc123abc123abc123abc123")); + + assert!(!is_valid_push_token("expo", "plain-token")); + assert!(!is_valid_push_token("apns", "not-hex")); + assert!(!is_valid_push_token("fcm", "short")); + assert!(!is_valid_push_token("unknown", "ExpoPushToken[abc]")); + assert!(!is_valid_push_token("expo", "ExpoPushToken[has space]")); } #[test] @@ -4738,6 +6061,27 @@ mod tests { assert!(last.ends_with("-image.png")); } + #[test] + fn mobile_spawn_rejects_paths_and_interpreter_args() { + assert!(normalize_mobile_spawn_request("/tmp/bash", &[]).is_err()); + assert!(normalize_mobile_spawn_request("bash", &["-c".to_string()]).is_err()); + assert!(normalize_mobile_spawn_request("python", &["-c".to_string()]).is_err()); + assert!(normalize_mobile_spawn_request("node", &["-e".to_string()]).is_err()); + assert!(normalize_mobile_spawn_request("powershell", &["-Command".to_string()]).is_err()); + } + + #[test] + fn mobile_spawn_allows_supported_profiles_without_client_args() { + assert_eq!( + normalize_mobile_spawn_request("claude", &[]).expect("claude profile"), + ("claude".to_string(), Vec::::new()) + ); + assert_eq!( + normalize_mobile_spawn_request("codex", &[]).expect("codex profile"), + ("codex".to_string(), Vec::::new()) + ); + } + #[tokio::test] async fn unicode_upload_name_stays_within_filesystem_limits() { let temp = TempDir::new().expect("tempdir"); @@ -4923,68 +6267,76 @@ mod tests { ); } - #[tokio::test] - async fn pty_resized_broadcasts_ack_only() { - let state = Arc::new(RwLock::new(DaemonState::new(9847))); - let addr: std::net::SocketAddr = "127.0.0.1:40001".parse().expect("socket addr"); + #[test] + fn pty_resize_ack_targets_active_viewers_only() { + let mut state = DaemonState::new(9847); + let active_addr: std::net::SocketAddr = + "127.0.0.1:40001".parse().expect("active socket addr"); + let revoked_addr: std::net::SocketAddr = + "127.0.0.1:40002".parse().expect("revoked socket addr"); + let idle_addr: std::net::SocketAddr = "127.0.0.1:40003".parse().expect("idle socket addr"); let session_id = "test-session".to_string(); - let (client_tx, mut client_rx) = mpsc::channel::(32); - - { - let mut st = state.write().await; - st.mobile_clients.insert(addr, client_tx); - st.mobile_views - .entry(addr) - .or_default() - .insert(session_id.clone()); - st.session_view_counts.insert(session_id.clone(), 1); - } - - broadcast_pty_resized(&state, &session_id, 95, 27, Some(1)).await; - - let mut first_ack = 0usize; - let mut first_replay = 0usize; - loop { - match timeout(Duration::from_millis(50), client_rx.recv()).await { - Ok(Some(Message::Text(text))) => { - let msg: serde_json::Value = - serde_json::from_str(text.as_ref()).expect("valid json"); - match msg.get("type").and_then(|t| t.as_str()) { - Some("pty_resized") => first_ack += 1, - Some("pty_bytes") => first_replay += 1, - _ => {} - } - } - Ok(Some(_)) => {} - Ok(None) | Err(_) => break, - } - } + let (active_tx, mut active_rx) = tokio::sync::mpsc::channel::(8); + let (revoked_tx, mut revoked_rx) = tokio::sync::mpsc::channel::(8); + let (idle_tx, mut idle_rx) = tokio::sync::mpsc::channel::(8); + + state.mobile_clients.insert(active_addr, active_tx); + state.mobile_clients.insert(revoked_addr, revoked_tx); + state.mobile_clients.insert(idle_addr, idle_tx); + state + .mobile_views + .entry(active_addr) + .or_default() + .insert(session_id.clone()); + state + .mobile_views + .entry(revoked_addr) + .or_default() + .insert(session_id.clone()); + state.mobile_auth.insert( + active_addr, + AuthenticatedClient { + credential_id: "active-credential".to_string(), + mobile_installation_id: "mobile-a".to_string(), + sender_id: None, + client_version: "test".to_string(), + client_capabilities: None, + scopes: vec![], + }, + ); + state.mobile_auth.insert( + revoked_addr, + AuthenticatedClient { + credential_id: "revoked-credential".to_string(), + mobile_installation_id: "mobile-b".to_string(), + sender_id: None, + client_version: "test".to_string(), + client_capabilities: None, + scopes: vec![], + }, + ); + state.mobile_auth.insert( + idle_addr, + AuthenticatedClient { + credential_id: "active-credential".to_string(), + mobile_installation_id: "mobile-c".to_string(), + sender_id: None, + client_version: "test".to_string(), + client_capabilities: None, + scopes: vec![], + }, + ); - assert_eq!(first_ack, 1); - assert_eq!(first_replay, 0); - - broadcast_pty_resized(&state, &session_id, 96, 27, Some(2)).await; - - let mut second_ack = 0usize; - let mut second_replay = 0usize; - loop { - match timeout(Duration::from_millis(50), client_rx.recv()).await { - Ok(Some(Message::Text(text))) => { - let msg: serde_json::Value = - serde_json::from_str(text.as_ref()).expect("valid json"); - match msg.get("type").and_then(|t| t.as_str()) { - Some("pty_resized") => second_ack += 1, - Some("pty_bytes") => second_replay += 1, - _ => {} - } - } - Ok(Some(_)) => {} - Ok(None) | Err(_) => break, - } - } + let active_ids = BTreeSet::from(["active-credential".to_string()]); + let clients = pty_resized_ack_clients(&state, &session_id, &active_ids); + assert_eq!(clients.len(), 1); - assert_eq!(second_ack, 1); - assert_eq!(second_replay, 0); + clients[0] + .try_send(Message::Text("ack".into())) + .expect("send ack"); + assert!(active_rx.try_recv().is_ok()); + assert!(revoked_rx.try_recv().is_err()); + assert!(idle_rx.try_recv().is_err()); } } diff --git a/cli/src/filesystem/config.rs b/cli/src/filesystem/config.rs index d25da19..ff76285 100644 --- a/cli/src/filesystem/config.rs +++ b/cli/src/filesystem/config.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; /// Configuration for file system access #[derive(Debug, Clone)] pub struct FileSystemConfig { - /// Allowed root directories (default: home directory) + /// Allowed root directories (default: current working directory) pub allowed_roots: Vec, /// Denied file patterns (glob) @@ -30,24 +30,65 @@ pub struct FileSystemConfig { impl Default for FileSystemConfig { fn default() -> Self { - let home = dirs_next::home_dir().or_else(|| std::env::current_dir().ok()); + let cwd = std::env::current_dir().ok(); + let home = dirs_next::home_dir(); + let cwd_is_home = cwd.as_ref().is_some_and(|cwd| { + home.as_ref().is_some_and(|home| { + cwd.canonicalize().unwrap_or_else(|_| cwd.clone()) + == home.canonicalize().unwrap_or_else(|_| home.clone()) + }) + }); + let allowed_roots = if cwd_is_home { + Vec::new() + } else { + cwd.into_iter().collect() + }; + let mut denied_patterns = vec![ + "**/.ssh/*".to_string(), + "**/*.pem".to_string(), + "**/*.key".to_string(), + "**/id_rsa*".to_string(), + "**/.gnupg/*".to_string(), + "**/.aws/**".to_string(), + "**/.kube/**".to_string(), + "**/.docker/config.json".to_string(), + "**/.docker/contexts/**".to_string(), + "**/.config/gcloud/**".to_string(), + "**/.env".to_string(), + "**/.env.*".to_string(), + "**/secrets.*".to_string(), + "**/*.secret".to_string(), + "**/token*".to_string(), + "**/.npmrc".to_string(), + "**/.pypirc".to_string(), + "**/.bash_history".to_string(), + "**/.zsh_history".to_string(), + "**/.sh_history".to_string(), + "**/.fish/fish_history".to_string(), + "**/.local/share/fish/fish_history".to_string(), + "**/.psql_history".to_string(), + "**/.python_history".to_string(), + "**/.node_repl_history".to_string(), + "**/.irb_history".to_string(), + "**/.git-credentials".to_string(), + "**/.config/git/credentials".to_string(), + "**/.netrc".to_string(), + "**/.vault-token".to_string(), + "**/*.tfstate".to_string(), + "**/*.tfstate.backup".to_string(), + "**/Library/Keychains/**".to_string(), + "**/.config/google-chrome/**/Login Data".to_string(), + "**/.config/chromium/**/Login Data".to_string(), + "**/.mozilla/firefox/**/*.sqlite".to_string(), + ]; + if let Some(home) = home.as_ref() { + let mobilecli_dir = home.join(".mobilecli").to_string_lossy().replace('\\', "/"); + denied_patterns.push(format!("{}/**", mobilecli_dir)); + denied_patterns.push(format!("{}/*", mobilecli_dir)); + } Self { - allowed_roots: home.into_iter().collect(), - denied_patterns: vec![ - "**/.ssh/*".to_string(), - "**/*.pem".to_string(), - "**/*.key".to_string(), - "**/id_rsa*".to_string(), - "**/.gnupg/*".to_string(), - "**/.aws/credentials".to_string(), - "**/.env".to_string(), - "**/.env.*".to_string(), - "**/secrets.*".to_string(), - "**/*.secret".to_string(), - "**/token*".to_string(), - "**/.npmrc".to_string(), - "**/.pypirc".to_string(), - ], + allowed_roots, + denied_patterns, max_read_size: 50 * 1024 * 1024, max_write_size: 50 * 1024 * 1024, follow_symlinks: false, diff --git a/cli/src/filesystem/operations.rs b/cli/src/filesystem/operations.rs index 6ddbd2a..8f59d65 100644 --- a/cli/src/filesystem/operations.rs +++ b/cli/src/filesystem/operations.rs @@ -598,6 +598,18 @@ impl FileOperations { ) .await?; } else { + let metadata = fs::metadata(&source) + .await + .map_err(|e| FileSystemError::IoError { + message: e.to_string(), + })?; + if metadata.len() > self.config.max_write_size { + return Err(FileSystemError::FileTooLarge { + path: path_utils::to_protocol_path(&source), + size: metadata.len(), + max_size: self.config.max_write_size, + }); + } fs::copy(&source, &destination) .await .map_err(|e| FileSystemError::IoError { diff --git a/cli/src/filesystem/platform.rs b/cli/src/filesystem/platform.rs index 1eb25bf..5f51c53 100644 --- a/cli/src/filesystem/platform.rs +++ b/cli/src/filesystem/platform.rs @@ -39,6 +39,7 @@ pub fn format_permissions(metadata: &std::fs::Metadata) -> String { if readonly { "r--" } else { "rw-" }.to_string() } +#[cfg(unix)] fn format_rwx(bits: u32) -> String { format!( "{}{}{}", diff --git a/cli/src/filesystem/security.rs b/cli/src/filesystem/security.rs index 9282336..d74033f 100644 --- a/cli/src/filesystem/security.rs +++ b/cli/src/filesystem/security.rs @@ -140,7 +140,7 @@ impl PathValidator { pub fn is_writable(&self, path: &Path) -> bool { let normalized = normalize_for_match(path); for pattern in &self.config.read_only_patterns { - if glob_match(pattern, &normalized) { + if glob_match(&normalize_pattern_for_match(pattern), &normalized) { return false; } } @@ -153,7 +153,7 @@ impl PathValidator { self.config .denied_patterns .iter() - .any(|pattern| glob_match(pattern, &normalized)) + .any(|pattern| glob_match(&normalize_pattern_for_match(pattern), &normalized)) } fn ensure_allowed(&self, path: &Path) -> Result<(), FileSystemError> { @@ -172,7 +172,7 @@ impl PathValidator { fn ensure_not_denied(&self, path: &Path) -> Result<(), FileSystemError> { let normalized = normalize_for_match(path); for pattern in &self.config.denied_patterns { - if glob_match(pattern, &normalized) { + if glob_match(&normalize_pattern_for_match(pattern), &normalized) { return Err(FileSystemError::PermissionDenied { path: path_utils::to_protocol_path(path), reason: format!("Path matches denied pattern: {}", pattern), @@ -217,7 +217,31 @@ fn contains_parent_dir(path: &Path) -> bool { } fn normalize_for_match(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") + normalize_match_text(&path.to_string_lossy()) +} + +fn normalize_pattern_for_match(pattern: &str) -> String { + normalize_match_text(pattern) +} + +fn normalize_match_text(raw: &str) -> String { + normalize_match_text_with_case(raw, cfg!(windows)) +} + +fn normalize_match_text_with_case(raw: &str, case_insensitive: bool) -> String { + let mut normalized = raw.replace('\\', "/"); + + if let Some(rest) = normalized.strip_prefix("//?/UNC/") { + normalized = format!("//{}", rest); + } else if let Some(rest) = normalized.strip_prefix("//?/") { + normalized = rest.to_string(); + } + + if case_insensitive { + normalized = normalized.to_ascii_lowercase(); + } + + normalized } fn find_existing_ancestor(path: &Path) -> Option { @@ -225,3 +249,28 @@ fn find_existing_ancestor(path: &Path) -> Option { .find(|p| p.exists()) .map(|p| p.to_path_buf()) } + +#[cfg(test)] +mod tests { + use super::normalize_match_text_with_case; + + #[test] + fn windows_match_normalization_strips_verbatim_prefixes_and_lowercases() { + assert_eq!( + normalize_match_text_with_case(r"\\?\C:\Windows\System32", true), + "c:/windows/system32" + ); + assert_eq!( + normalize_match_text_with_case(r"\\?\UNC\Server\Share\File", true), + "//server/share/file" + ); + } + + #[test] + fn unix_match_normalization_keeps_case() { + assert_eq!( + normalize_match_text_with_case("/Users/Alice/File", false), + "/Users/Alice/File" + ); + } +} diff --git a/cli/src/filesystem/tests.rs b/cli/src/filesystem/tests.rs index f1e49c6..4aec8b4 100644 --- a/cli/src/filesystem/tests.rs +++ b/cli/src/filesystem/tests.rs @@ -35,6 +35,33 @@ fn test_path_validation_allows_valid_paths() { .is_ok()); } +#[test] +fn test_path_validation_blocks_mobilecli_config_secrets() { + let Some(home) = dirs_next::home_dir() else { + return; + }; + + let config = Arc::new(FileSystemConfig { + allowed_roots: vec![home.clone()], + ..Default::default() + }); + let validator = PathValidator::new(config); + let config_path = home.join(".mobilecli").join("config.json"); + assert!(validator.is_denied(&config_path)); +} + +#[test] +fn test_path_validation_allows_project_mobilecli_upload_cache() { + let temp = TempDir::new().unwrap(); + let upload_path = temp.path().join(".mobilecli/uploads/image.png"); + let config = Arc::new(FileSystemConfig { + allowed_roots: vec![temp.path().to_path_buf()], + ..Default::default() + }); + let validator = PathValidator::new(config); + assert!(!validator.is_denied(&upload_path)); +} + #[tokio::test] async fn test_list_directory_sorts_directories_first() { let temp = TempDir::new().unwrap(); diff --git a/cli/src/link.rs b/cli/src/link.rs index c67af5d..7e52908 100644 --- a/cli/src/link.rs +++ b/cli/src/link.rs @@ -2,8 +2,8 @@ //! //! Similar to `screen -x` or `tmux attach` - joins an existing PTY session. -use crate::daemon; use crate::protocol::{ClientMessage, ServerMessage, SessionListItem}; +use crate::{auth, daemon, setup}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use colored::Colorize; use futures_util::{SinkExt, StreamExt}; @@ -21,15 +21,7 @@ pub async fn run(session_id: Option) -> Result<(), Box = Vec::new(); @@ -168,18 +160,10 @@ async fn run_linked_mode( session: &SessionListItem, ) -> Result<(), Box> { // Connect to daemon - let (ws, _) = connect_async(ws_url).await?; + let (mut ws, _) = connect_async(ws_url).await?; + authenticate_local_client(&mut ws).await?; let (mut tx, mut rx) = ws.split(); - // Send hello - let hello = ClientMessage::Hello { - client_version: env!("CARGO_PKG_VERSION").to_string(), - sender_id: None, - client_capabilities: None, - }; - tx.send(Message::Text(serde_json::to_string(&hello)?)) - .await?; - // Subscribe to session let subscribe = ClientMessage::Subscribe { session_id: session.session_id.clone(), @@ -325,6 +309,72 @@ async fn run_linked_mode( Ok(()) } +async fn authenticate_local_client( + ws: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> Result<(), Box> { + let cfg = setup::load_config().ok_or("MobileCLI is not configured. Run `mobilecli pair`.")?; + let credential = cfg + .credentials + .iter() + .find(|credential| credential.is_active()) + .ok_or("No active MobileCLI credentials. Run `mobilecli pair`.")?; + let client_nonce = auth::generate_nonce(); + let mobile_installation_id = "desktop-link".to_string(); + let start = ClientMessage::AuthStart { + auth_version: auth::AUTH_VERSION, + credential_id: credential.credential_id.clone(), + client_nonce: client_nonce.clone(), + mobile_installation_id: mobile_installation_id.clone(), + sender_id: Some("desktop-link".to_string()), + client_version: env!("CARGO_PKG_VERSION").to_string(), + client_capabilities: None, + }; + ws.send(Message::Text(serde_json::to_string(&start)?)) + .await?; + + let challenge = loop { + let Some(msg) = ws.next().await else { + return Err("Daemon closed before auth challenge".into()); + }; + if let Message::Text(text) = msg? { + match serde_json::from_str::(&text)? { + ServerMessage::AuthChallenge { + server_id, + credential_id, + server_nonce, + .. + } => break (server_id, credential_id, server_nonce), + ServerMessage::Error { code, message } => { + return Err(format!("Daemon auth error {}: {}", code, message).into()); + } + _ => continue, + } + } + }; + let (server_id, credential_id, server_nonce) = challenge; + let transcript = auth::build_auth_transcript( + &server_id, + &credential_id, + &client_nonce, + &server_nonce, + &mobile_installation_id, + ); + let proof = auth::proof_from_verifier(&credential.verifier, &transcript) + .ok_or("Could not compute auth proof")?; + let response = ClientMessage::AuthResponse { + credential_id, + client_nonce, + server_nonce, + mobile_installation_id, + proof, + }; + ws.send(Message::Text(serde_json::to_string(&response)?)) + .await?; + Ok(()) +} + /// Set up raw terminal mode (Unix) #[cfg(unix)] fn setup_raw_mode(fd: i32) -> io::Result { diff --git a/cli/src/main.rs b/cli/src/main.rs index 8317b2a..9fbaa88 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -9,6 +9,7 @@ //! mobilecli daemon # Run the background server //! mobilecli --help # Show help +mod auth; mod autostart; mod daemon; mod detection; @@ -72,7 +73,16 @@ enum Commands { /// Run the setup wizard and show QR code for pairing Setup, /// Show QR code for mobile pairing - Pair, + Pair { + /// Revoke all existing mobile credentials before creating a new pairing QR + #[arg(long)] + rotate: bool, + }, + /// Manage paired mobile credentials + Credentials { + #[command(subcommand)] + command: CredentialCommand, + }, /// Start the background daemon server Daemon { /// Port to listen on @@ -101,6 +111,17 @@ enum Commands { }, } +#[derive(Subcommand)] +enum CredentialCommand { + /// List paired mobile credentials without showing secrets + List, + /// Revoke one paired mobile credential + Revoke { + /// Credential ID to revoke + credential_id: String, + }, +} + #[tokio::main] async fn main() -> ExitCode { // Enable ANSI colors on Windows @@ -147,13 +168,20 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, - Commands::Pair => match show_pair_qr().await { + Commands::Pair { rotate } => match show_pair_qr(*rotate).await { Ok(_) => ExitCode::SUCCESS, Err(e) => { eprintln!("{}: {}", "Error".red().bold(), e); ExitCode::FAILURE } }, + Commands::Credentials { command } => match handle_credentials(command) { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{}: {}", "Credentials error".red().bold(), e); + ExitCode::FAILURE + } + }, Commands::Daemon { port } => { if daemon::is_running() { eprintln!("{}", "Daemon is already running".yellow()); @@ -199,15 +227,6 @@ async fn main() -> ExitCode { // Get run args (or defaults) let run_args = cli.run_args.unwrap_or_default(); - // Ensure daemon is running - if !daemon::is_running() { - // Start daemon in background - if let Err(e) = start_daemon_background().await { - eprintln!("{}: {}", "Failed to start daemon".red().bold(), e); - return ExitCode::FAILURE; - } - } - // Check for first run - show setup wizard if setup::is_first_run() && run_args.args.is_empty() { println!(); @@ -224,6 +243,15 @@ async fn main() -> ExitCode { } } + // Ensure daemon is running after setup/pairing has had a chance to create credentials. + if !daemon::is_running() { + // Start daemon in background + if let Err(e) = start_daemon_background().await { + eprintln!("{}: {}", "Failed to start daemon".red().bold(), e); + return ExitCode::FAILURE; + } + } + // Determine what command to run let (command, args) = if run_args.args.is_empty() { // Use cross-platform shell detection @@ -384,12 +412,8 @@ async fn run_setup() -> Result<(), Box> { // Run the interactive setup let _config = setup::run_setup_wizard()?; - // Ensure daemon is running - if !daemon::is_running() { - start_daemon_background().await?; - } - - // Show QR code for pairing + // Create the mobile credential before starting the daemon so remote mobile + // listeners do not start in auth-locked mode. println!(); println!( "{}", @@ -397,15 +421,20 @@ async fn run_setup() -> Result<(), Box> { ); println!(); - show_pair_qr().await?; + show_pair_qr(false).await?; + + // Ensure daemon is running after credentials have been saved. + if !daemon::is_running() { + start_daemon_background().await?; + } Ok(()) } /// Show QR code for pairing -async fn show_pair_qr() -> Result<(), Box> { +async fn show_pair_qr(rotate: bool) -> Result<(), Box> { // Get connection config (includes device_id and device_name) - let config = setup::load_config().unwrap_or_default(); + let mut config = setup::load_config().unwrap_or_default(); // Get the actual daemon port (fallback to default if not running) let port = daemon::get_port().unwrap_or(daemon::DEFAULT_PORT); @@ -425,10 +454,11 @@ async fn show_pair_qr() -> Result<(), Box> { setup::ConnectionMode::Tailscale => { let ts = setup::check_tailscale(); if ts.logged_in { - ts.ip.or_else(setup::get_local_ip) + ts.ip } else { - eprintln!("{}", "⚠ Tailscale not connected".yellow()); - setup::get_local_ip() + return Err( + "Tailscale mode is selected but Tailscale is not connected".into() + ); } } setup::ConnectionMode::Custom(_) => None, @@ -441,6 +471,16 @@ async fn show_pair_qr() -> Result<(), Box> { }; if !ws_url.is_empty() { + if rotate { + for credential in &mut config.credentials { + credential.revoke(); + } + } + let pairing = config.create_pairing_credential("Mobile app"); + setup::save_config(&config)?; + if rotate { + println!("{}", "Revoked existing mobile credentials.".yellow()); + } let info = protocol::ConnectionInfo { ws_url, session_id: String::new(), // Not session-specific @@ -449,6 +489,10 @@ async fn show_pair_qr() -> Result<(), Box> { version: env!("CARGO_PKG_VERSION").to_string(), device_id: Some(config.device_id), device_name: Some(config.device_name), + auth_version: Some(crate::auth::AUTH_VERSION), + server_id: Some(config.server_id), + credential_id: Some(pairing.credential.credential_id), + auth_token: Some(pairing.auth_token), }; qr::display_session_qr(&info); @@ -458,3 +502,69 @@ async fn show_pair_qr() -> Result<(), Box> { Ok(()) } + +fn handle_credentials(command: &CredentialCommand) -> Result<(), Box> { + match command { + CredentialCommand::List => list_credentials(), + CredentialCommand::Revoke { credential_id } => revoke_credential(credential_id), + } +} + +fn list_credentials() -> Result<(), Box> { + let Some(config) = setup::load_config() else { + println!("{}", "No MobileCLI config found.".yellow()); + println!( + " Run {} to create a pairing credential.", + "mobilecli pair".cyan() + ); + return Ok(()); + }; + + if config.credentials.is_empty() { + println!("{}", "No mobile credentials are paired.".yellow()); + println!(" Run {} to create one.", "mobilecli pair".cyan()); + return Ok(()); + } + + println!("{}", "Paired Mobile Credentials".cyan().bold()); + for credential in &config.credentials { + let status = if credential.is_active() { + "active".green() + } else { + "revoked".red() + }; + let last_used = credential.last_used_at.as_deref().unwrap_or("never"); + println!(); + println!(" {} {}", "ID:".bold(), credential.credential_id); + println!(" {} {}", "Name:".bold(), credential.name); + println!(" {} {}", "Status:".bold(), status); + println!(" {} {}", "Created:".bold(), credential.created_at); + println!(" {} {}", "Last used:".bold(), last_used); + if let Some(revoked_at) = &credential.revoked_at { + println!(" {} {}", "Revoked:".bold(), revoked_at); + } + } + + Ok(()) +} + +fn revoke_credential(credential_id: &str) -> Result<(), Box> { + let mut config = setup::load_config().ok_or("No MobileCLI config found")?; + let Some(credential) = config + .credentials + .iter_mut() + .find(|c| c.credential_id == credential_id) + else { + return Err(format!("Credential not found: {}", credential_id).into()); + }; + + if credential.is_active() { + credential.revoke(); + setup::save_config(&config)?; + println!("{} Revoked {}", "✓".green(), credential_id); + } else { + println!("{} Credential already revoked", "•".dimmed()); + } + + Ok(()) +} diff --git a/cli/src/platform.rs b/cli/src/platform.rs index a33cdeb..57ba28c 100644 --- a/cli/src/platform.rs +++ b/cli/src/platform.rs @@ -29,9 +29,15 @@ pub fn home_dir() -> Option { /// Note: We use a dot-prefix directory on all platforms for consistency. /// On Windows, this won't be hidden by default, but keeps paths predictable. pub fn config_dir() -> PathBuf { - home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".mobilecli") + if let Some(home) = home_dir() { + return home.join(".mobilecli"); + } + + if let Some(config) = dirs_next::config_dir() { + return config.join("mobilecli"); + } + + panic!("Cannot determine a safe MobileCLI config directory"); } /// Get the default shell for the current platform. @@ -97,7 +103,6 @@ pub fn is_process_alive(pid: u32) -> bool { fn WaitForSingleObject(hHandle: *mut std::ffi::c_void, dwMilliseconds: u32) -> u32; } - const WAIT_OBJECT_0: u32 = 0; const WAIT_TIMEOUT: u32 = 258; unsafe { @@ -178,7 +183,7 @@ mod tests { #[test] fn test_config_dir() { let dir = config_dir(); - assert!(dir.ends_with(".mobilecli")); + assert!(dir.ends_with(".mobilecli") || dir.ends_with("mobilecli")); } #[test] diff --git a/cli/src/protocol.rs b/cli/src/protocol.rs index 94f762c..71e5235 100644 --- a/cli/src/protocol.rs +++ b/cli/src/protocol.rs @@ -48,6 +48,24 @@ pub enum TmuxViewportAction { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ClientMessage { + AuthStart { + auth_version: u8, + credential_id: String, + client_nonce: String, + mobile_installation_id: String, + #[serde(default)] + sender_id: Option, + client_version: String, + #[serde(default)] + client_capabilities: Option, + }, + AuthResponse { + credential_id: String, + client_nonce: String, + server_nonce: String, + mobile_installation_id: String, + proof: String, + }, Hello { client_version: String, #[serde(default)] @@ -238,12 +256,22 @@ pub enum ClientMessage { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ServerMessage { + AuthChallenge { + auth_version: u8, + server_id: String, + credential_id: String, + server_nonce: String, + }, Welcome { server_version: String, #[serde(skip_serializing_if = "Option::is_none")] device_id: Option, #[serde(skip_serializing_if = "Option::is_none")] device_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + server_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_version: Option, }, Error { code: String, @@ -616,11 +644,23 @@ pub struct ConnectionInfo { /// Device name/hostname (for display) #[serde(skip_serializing_if = "Option::is_none")] pub device_name: Option, + /// Auth protocol version for paired mobile clients. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_version: Option, + /// Stable desktop daemon auth identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub server_id: Option, + /// Credential id for this mobile pairing. + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_id: Option, + /// One-time displayed pairing token. Stored only on mobile. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_token: Option, } impl ConnectionInfo { /// Encode as compact string for QR code (smaller QR) - /// Format: mobilecli://host:port?device_id=UUID&device_name=HOSTNAME[&wss=1] + /// Format: mobilecli://host:port?device_id=UUID&device_name=HOSTNAME&auth=v2&server_id=...&credential_id=...&auth_token=...[&wss=1] /// /// Note: This format is for device-level pairing, not session-specific connections. /// The mobile app connects to the device and then fetches the session list via @@ -648,6 +688,21 @@ impl ConnectionInfo { if let Some(name) = &self.device_name { params.push(format!("device_name={}", urlencoding::encode(name))); } + if let Some(version) = self.auth_version { + params.push(format!("auth=v{}", version)); + } + if let Some(server_id) = &self.server_id { + params.push(format!("server_id={}", urlencoding::encode(server_id))); + } + if let Some(credential_id) = &self.credential_id { + params.push(format!( + "credential_id={}", + urlencoding::encode(credential_id) + )); + } + if let Some(auth_token) = &self.auth_token { + params.push(format!("auth_token={}", urlencoding::encode(auth_token))); + } if is_wss { params.push("wss=1".to_string()); } @@ -660,3 +715,55 @@ impl ConnectionInfo { url } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_qr_includes_auth_v2_pairing_fields() { + let info = ConnectionInfo { + ws_url: "ws://100.64.0.10:9847".to_string(), + session_id: String::new(), + session_name: None, + encryption_key: None, + version: "0.1.0".to_string(), + device_id: Some("desktop-device".to_string()), + device_name: Some("Desktop One".to_string()), + auth_version: Some(2), + server_id: Some("server-id".to_string()), + credential_id: Some("credential-id".to_string()), + auth_token: Some("secret-token".to_string()), + }; + + let qr = info.to_compact_qr(); + assert!(qr.starts_with("mobilecli://100.64.0.10:9847?")); + assert!(qr.contains("device_id=desktop-device")); + assert!(qr.contains("device_name=Desktop%20One")); + assert!(qr.contains("auth=v2")); + assert!(qr.contains("server_id=server-id")); + assert!(qr.contains("credential_id=credential-id")); + assert!(qr.contains("auth_token=secret-token")); + } + + #[test] + fn compact_qr_preserves_wss_flag_with_auth_fields() { + let info = ConnectionInfo { + ws_url: "wss://example.test/ws".to_string(), + session_id: String::new(), + session_name: None, + encryption_key: None, + version: "0.1.0".to_string(), + device_id: None, + device_name: None, + auth_version: Some(2), + server_id: Some("server-id".to_string()), + credential_id: Some("credential-id".to_string()), + auth_token: Some("secret-token".to_string()), + }; + + let qr = info.to_compact_qr(); + assert!(qr.contains("auth=v2")); + assert!(qr.contains("wss=1")); + } +} diff --git a/cli/src/pty_wrapper.rs b/cli/src/pty_wrapper.rs index d9bf674..af75151 100644 --- a/cli/src/pty_wrapper.rs +++ b/cli/src/pty_wrapper.rs @@ -7,8 +7,10 @@ //! 4. Relays input from daemon (mobile) to the PTY //! 5. Handles terminal resize events +use crate::auth; use crate::daemon::{get_port, DEFAULT_PORT}; use crate::protocol::PtyResizeReason; +use crate::setup; use crate::tmux::sanitize_tmux_token; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use colored::Colorize; @@ -268,6 +270,7 @@ fn parse_tmux_mouse_mode(raw: &str) -> Option { } } +#[cfg(any(unix, test))] fn parse_bool_env_flag(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "on" | "1" | "true" | "yes" => Some(true), @@ -276,6 +279,7 @@ fn parse_bool_env_flag(raw: &str) -> Option { } } +#[cfg(unix)] fn resolve_raw_mode() -> bool { let Ok(raw) = std::env::var("MOBILECLI_RAW_MODE") else { // Default to raw mode when stdin is a TTY. AI CLI tools (Claude Code, @@ -327,17 +331,19 @@ fn resolve_tmux_mouse_mode() -> TmuxMouseMode { default_mode } -fn setup_tmux_session( - socket_name: &str, - session_name: &str, - command_path: &str, - args: &[String], - cwd: &str, +struct TmuxSessionOptions<'a> { + socket_name: &'a str, + session_name: &'a str, + command_path: &'a str, + args: &'a [String], + cwd: &'a str, terminal_size: (u16, u16), tmux_mouse_mode: TmuxMouseMode, headless: bool, -) -> Result<(), WrapError> { - let (cols, rows) = terminal_size; +} + +fn setup_tmux_session(options: TmuxSessionOptions<'_>) -> Result<(), WrapError> { + let (cols, rows) = options.terminal_size; // Bootstrap a dedicated tmux server first so global options apply before // the wrapped CLI starts. We also disable alternate-screen globally here @@ -351,27 +357,27 @@ fn setup_tmux_session( // will have default history-limit (2000), but we set global options for // future windows. This is a tmux limitation - history-limit can only be // set globally and affects windows created after it's set. - let mut new_session = tmux_base_command(socket_name); + let mut new_session = tmux_base_command(options.socket_name); new_session .arg("new-session") .arg("-d") .arg("-s") - .arg(session_name) + .arg(options.session_name) .arg("-x") .arg(cols.to_string()) .arg("-y") .arg(rows.to_string()) .arg("--") - .arg(command_path) - .args(args) - .current_dir(cwd) + .arg(options.command_path) + .args(options.args) + .current_dir(options.cwd) .env("TERM", "xterm-256color") .env("MOBILECLI_SESSION", "1"); run_tmux_checked(&mut new_session, "new-session")?; // Set global options for future windows. The first window already // exists with default settings, but new windows will inherit these. - let mut set_history = tmux_base_command(socket_name); + let mut set_history = tmux_base_command(options.socket_name); set_history .arg("set-option") .arg("-g") @@ -382,7 +388,7 @@ fn setup_tmux_session( // Best-effort options for deterministic rendering behavior. // NOTE: window-size must remain dynamic so wrapper PTY resizes propagate // into tmux panes when mobile dimensions change. - let window_target = format!("{}:0", session_name); + let window_target = format!("{}:0", options.session_name); // Only disable alternate-screen in headless mode (phone-only sessions). // In headless mode, there is no desktop terminal, so we disable smcup/rmcup // to keep all output in the main buffer where capture-pane can reach it. @@ -393,8 +399,8 @@ fn setup_tmux_session( // garbling the output when the user scrolls on their desktop terminal. // The mobile app can still capture scrollback via capture-pane on the main // buffer — it just won't see TUI alt-screen content, which is acceptable. - if headless { - let mut disable_altscreen_client = tmux_base_command(socket_name); + if options.headless { + let mut disable_altscreen_client = tmux_base_command(options.socket_name); disable_altscreen_client .arg("set-option") .arg("-g") @@ -405,24 +411,24 @@ fn setup_tmux_session( // Mouse mode is configurable. Linux defaults to off so desktop emulators // (e.g. Konsole) preserve normal drag-select clipboard behavior. - let mut set_mouse_mode = tmux_base_command(socket_name); + let mut set_mouse_mode = tmux_base_command(options.socket_name); set_mouse_mode .arg("set-option") .arg("-g") .arg("mouse") - .arg(tmux_mouse_mode.as_tmux_value()); + .arg(options.tmux_mouse_mode.as_tmux_value()); let _ = run_tmux_checked(&mut set_mouse_mode, "mouse"); // history-limit is set globally before new-session so the window is // allocated with the full 200K-line buffer from the start. let mut option_sets: Vec<(&str, &str, &str, &str)> = vec![ - ("set-option", session_name, "status", "off"), - ("set-option", session_name, "allow-rename", "off"), + ("set-option", options.session_name, "status", "off"), + ("set-option", options.session_name, "allow-rename", "off"), ("set-window-option", &window_target, "window-size", "latest"), ]; // Only disable alternate-screen at the window level in headless mode. // See the comment above for the full rationale. - if headless { + if options.headless { option_sets.push(( "set-window-option", &window_target, @@ -431,7 +437,7 @@ fn setup_tmux_session( )); } for (command, target, key, value) in option_sets { - let mut option_cmd = tmux_base_command(socket_name); + let mut option_cmd = tmux_base_command(options.socket_name); option_cmd .arg(command) .arg("-t") @@ -440,8 +446,8 @@ fn setup_tmux_session( .arg(value); if let Err(err) = run_tmux_checked(&mut option_cmd, key) { tracing::debug!( - socket = socket_name, - session = session_name, + socket = options.socket_name, + session = options.session_name, option = key, error = %err, "Ignoring non-fatal tmux option failure" @@ -539,6 +545,15 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { .map(|p| p.display().to_string()) .unwrap_or_else(|_| ".".to_string()) }); + let desktop_link_token = setup::load_config() + .map(|cfg| cfg.desktop_link_token) + .filter(|token| !token.trim().is_empty()) + .ok_or_else(|| { + WrapError::DaemonConnection( + "Missing desktop link token; run `mobilecli setup` or `mobilecli pair` first" + .to_string(), + ) + })?; // Connect to daemon (use actual port from file, fallback to default) let port = get_port().unwrap_or(DEFAULT_PORT); @@ -554,8 +569,19 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { let (mut ws_tx, mut ws_rx) = ws_stream.split(); // Register with daemon as a PTY session + let transcript = auth::build_pty_registration_transcript( + &session_id, + &config.session_name, + &config.command, + &cwd, + runtime_mode.as_str(), + true, + ); + let pty_proof = auth::local_pty_proof_from_token(&desktop_link_token, &transcript); let register_msg = serde_json::json!({ "type": "register_pty", + "pty_auth_version": auth::LOCAL_PTY_AUTH_VERSION, + "pty_proof": pty_proof, "session_id": session_id, "name": config.session_name, "command": config.command, @@ -563,7 +589,11 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { "runtime": runtime_mode.as_str(), "desktop": true, }); - tracing::info!("Sending registration message: {}", register_msg); + tracing::info!( + session_id = %session_id, + runtime = runtime_mode.as_str(), + "Sending authenticated PTY registration" + ); ws_tx .send(Message::Text(register_msg.to_string())) @@ -651,16 +681,16 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { session_name: format!("mcli-{}", token), }; // attach mode: desktop terminal is present, keep alt-screen enabled - setup_tmux_session( - &ctx.socket_name, - &ctx.session_name, - &cmd_path, - &config.args, - &cwd, - (cols, rows), + setup_tmux_session(TmuxSessionOptions { + socket_name: &ctx.socket_name, + session_name: &ctx.session_name, + command_path: &cmd_path, + args: &config.args, + cwd: &cwd, + terminal_size: (cols, rows), tmux_mouse_mode, - false, // headless=false: preserve alt-screen for desktop terminal - )?; + headless: false, // Preserve alt-screen for desktop terminal. + })?; tmux_context = Some(ctx); } @@ -849,17 +879,21 @@ pub async fn run_wrapped(config: WrapConfig) -> Result { // dimensions to the child PTY. Without this, resizing the desktop terminal // window leaves the child PTY at stale dimensions, causing garbled output. #[cfg(unix)] - let mut sigwinch = tokio::signal::unix::signal( - tokio::signal::unix::SignalKind::window_change(), - ).expect("failed to register SIGWINCH handler"); + let mut sigwinch = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change()) + .expect("failed to register SIGWINCH handler"); loop { // Helper future that resolves on SIGWINCH (unix) or never (other platforms). let sigwinch_fut = async { #[cfg(unix)] - { sigwinch.recv().await; } + { + sigwinch.recv().await; + } #[cfg(not(unix))] - { std::future::pending::<()>().await; } + { + std::future::pending::<()>().await; + } }; tokio::select! { @@ -1178,11 +1212,6 @@ fn setup_raw_mode() -> Option { None } -#[cfg(not(unix))] -fn setup_raw_mode() -> Option<()> { - None -} - /// Restore terminal mode #[cfg(unix)] fn restore_terminal_mode(original: Option) { @@ -1203,7 +1232,7 @@ mod tests { use super::{ cleanup_tmux_session, parse_bool_env_flag, parse_tmux_mouse_mode, resolve_resize_reason, resolve_runtime_mode, sanitize_tmux_token, setup_tmux_session, tmux_base_command, - RuntimeMode, TmuxContext, TmuxMouseMode, + RuntimeMode, TmuxContext, TmuxMouseMode, TmuxSessionOptions, }; use crate::protocol::PtyResizeReason; @@ -1301,17 +1330,18 @@ mod tests { session_name: format!("mcli-test-{}", token), }; - setup_tmux_session( - &ctx.socket_name, - &ctx.session_name, - "/bin/sh", + let args = vec!["-lc".to_string(), "sleep 10 & wait".to_string()]; + setup_tmux_session(TmuxSessionOptions { + socket_name: &ctx.socket_name, + session_name: &ctx.session_name, + command_path: "/bin/sh", // Keep the session alive long enough for CI has-session checks. - &vec!["-lc".to_string(), "sleep 10 & wait".to_string()], - ".", - (80, 24), - TmuxMouseMode::default_for_platform(), - true, // headless: tests run without a desktop terminal - ) + args: &args, + cwd: ".", + terminal_size: (80, 24), + tmux_mouse_mode: TmuxMouseMode::default_for_platform(), + headless: true, // Tests run without a desktop terminal. + }) .expect("setup tmux session"); let mut has_session = tmux_base_command(&ctx.socket_name); @@ -1413,16 +1443,17 @@ mod tests { session_name: format!("mcli-test-{}", token), }; - setup_tmux_session( - &ctx.socket_name, - &ctx.session_name, - "/bin/sh", - &vec!["-lc".to_string(), "sleep 10 & wait".to_string()], - ".", - (80, 24), - TmuxMouseMode::On, - true, // headless: tests run without a desktop terminal - ) + let args = vec!["-lc".to_string(), "sleep 10 & wait".to_string()]; + setup_tmux_session(TmuxSessionOptions { + socket_name: &ctx.socket_name, + session_name: &ctx.session_name, + command_path: "/bin/sh", + args: &args, + cwd: ".", + terminal_size: (80, 24), + tmux_mouse_mode: TmuxMouseMode::On, + headless: true, // Tests run without a desktop terminal. + }) .expect("setup tmux session with mouse override"); let mut show_mouse_mode = tmux_base_command(&ctx.socket_name); diff --git a/cli/src/setup.rs b/cli/src/setup.rs index 2bb4acd..fe5763c 100644 --- a/cli/src/setup.rs +++ b/cli/src/setup.rs @@ -2,6 +2,7 @@ //! //! Handles first-time setup and connection configuration. +use crate::auth::{self, AuthCredential, PairingCredential}; use crate::platform; use crate::shell_hook; use colored::Colorize; @@ -22,25 +23,85 @@ pub enum ConnectionMode { /// Configuration stored for the CLI #[derive(Debug, Clone)] pub struct Config { + pub config_version: u32, + pub server_id: String, pub device_id: String, pub device_name: String, + pub desktop_link_token: String, pub connection_mode: ConnectionMode, pub tailscale_ip: Option, pub local_ip: Option, + pub auth_version: u8, + pub credentials: Vec, + pub filesystem: FileSystemAccessConfig, +} + +/// Configuration for mobile filesystem access. +#[derive(Debug, Clone)] +pub struct FileSystemAccessConfig { + /// Explicit roots approved for mobile browsing/editing. + pub allowed_roots: Vec, + /// Whole-home access is intentionally opt-in. + pub whole_home_enabled: bool, + /// Destructive operations such as delete/rename/copy-overwrite are allowed. + pub destructive_operations: bool, +} + +impl Default for FileSystemAccessConfig { + fn default() -> Self { + let cwd = std::env::current_dir().ok(); + let home = dirs_next::home_dir(); + let cwd_is_home = cwd.as_ref().is_some_and(|cwd| { + home.as_ref().is_some_and(|home| { + cwd.canonicalize().unwrap_or_else(|_| cwd.clone()) + == home.canonicalize().unwrap_or_else(|_| home.clone()) + }) + }); + let allowed_roots = if cwd_is_home { + Vec::new() + } else { + cwd.and_then(|p| p.to_str().map(|s| s.to_string())) + .into_iter() + .collect() + }; + Self { + allowed_roots, + whole_home_enabled: false, + destructive_operations: false, + } + } } impl Default for Config { fn default() -> Self { Self { + config_version: 2, + server_id: uuid::Uuid::new_v4().to_string(), device_id: uuid::Uuid::new_v4().to_string(), device_name: get_hostname(), + desktop_link_token: auth::generate_nonce(), connection_mode: ConnectionMode::Local, tailscale_ip: None, local_ip: None, + auth_version: auth::AUTH_VERSION, + credentials: Vec::new(), + filesystem: FileSystemAccessConfig::default(), } } } +impl Config { + pub fn auth_configured(&self) -> bool { + self.credentials.iter().any(AuthCredential::is_active) + } + + pub fn create_pairing_credential(&mut self, name: impl Into) -> PairingCredential { + let pairing = auth::generate_pairing_credential(name); + self.credentials.push(pairing.credential.clone()); + pairing + } +} + /// Get the system hostname for device identification pub fn get_hostname() -> String { hostname::get() @@ -95,9 +156,32 @@ pub fn load_config() -> Option { .map(|s| s.to_string()) .unwrap_or_else(get_hostname); + let credentials = json + .get("credentials") + .cloned() + .and_then(|v| serde_json::from_value::>(v).ok()) + .unwrap_or_default(); + + let filesystem = parse_filesystem_config(json.get("filesystem")); + let config = Config { + config_version: json + .get("config_version") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32, + server_id: json + .get("server_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), device_id, device_name, + desktop_link_token: json + .get("desktop_link_token") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_default(), connection_mode: mode, tailscale_ip: json .get("tailscale_ip") @@ -107,16 +191,51 @@ pub fn load_config() -> Option { .get("local_ip") .and_then(|v| v.as_str()) .map(|s| s.to_string()), + auth_version: json + .get("auth_version") + .and_then(|v| v.as_u64()) + .unwrap_or(auth::AUTH_VERSION as u64) as u8, + credentials, + filesystem, }; Some(config) } +fn parse_filesystem_config(value: Option<&serde_json::Value>) -> FileSystemAccessConfig { + let mut config = FileSystemAccessConfig::default(); + let mut allowed_roots_seen = false; + if let Some(value) = value { + if let Some(roots) = value.get("allowed_roots").and_then(|v| v.as_array()) { + allowed_roots_seen = true; + config.allowed_roots = roots + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .filter(|s| !s.trim().is_empty()) + .collect(); + } + if let Some(enabled) = value.get("whole_home_enabled").and_then(|v| v.as_bool()) { + config.whole_home_enabled = enabled; + } + if let Some(enabled) = value + .get("destructive_operations") + .and_then(|v| v.as_bool()) + { + config.destructive_operations = enabled; + } + } + if !allowed_roots_seen && config.allowed_roots.is_empty() { + config.allowed_roots = FileSystemAccessConfig::default().allowed_roots; + } + config +} + /// Save configuration pub fn save_config(config: &Config) -> io::Result<()> { let config_path = get_config_path(); if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent)?; + harden_windows_acl(parent)?; } let mode_str = match &config.connection_mode { @@ -126,14 +245,115 @@ pub fn save_config(config: &Config) -> io::Result<()> { }; let json = serde_json::json!({ - "device_id": config.device_id, - "device_name": config.device_name, + "config_version": 2, + "server_id": &config.server_id, + "device_id": &config.device_id, + "device_name": &config.device_name, + "desktop_link_token": &config.desktop_link_token, "connection_mode": mode_str, - "tailscale_ip": config.tailscale_ip, - "local_ip": config.local_ip, + "tailscale_ip": &config.tailscale_ip, + "local_ip": &config.local_ip, + "auth_version": config.auth_version, + "credentials": &config.credentials, + "filesystem": { + "allowed_roots": &config.filesystem.allowed_roots, + "whole_home_enabled": config.filesystem.whole_home_enabled, + "destructive_operations": config.filesystem.destructive_operations, + }, }); - std::fs::write(&config_path, serde_json::to_string_pretty(&json)?)?; + write_config_private( + &config_path, + serde_json::to_string_pretty(&json)?.as_bytes(), + )?; + Ok(()) +} + +pub fn ensure_desktop_link_token() -> io::Result { + let mut config = load_config().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "MobileCLI config is missing; run `mobilecli setup` first", + ) + })?; + + if config.desktop_link_token.trim().is_empty() { + config.desktop_link_token = auth::generate_nonce(); + save_config(&config)?; + } + + Ok(config.desktop_link_token) +} + +fn write_config_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> { + let tmp = path.with_extension("json.tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::fs::PermissionsExt; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&tmp, path)?; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + { + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&tmp, path)?; + harden_windows_acl(path)?; + } + Ok(()) +} + +#[cfg(windows)] +fn harden_windows_acl(path: &std::path::Path) -> io::Result<()> { + let system_root = std::env::var_os("SystemRoot") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")); + let icacls = system_root.join("System32").join("icacls.exe"); + let username = match std::env::var("USERNAME") { + Ok(value) if !value.trim().is_empty() => value, + _ => { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "USERNAME is not set", + )) + } + }; + let principal = match std::env::var("USERDOMAIN") { + Ok(domain) if !domain.trim().is_empty() => format!("{}\\{}", domain, username), + _ => username, + }; + let grant = format!("{}:F", principal); + let status = Command::new(icacls) + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .arg(grant) + .status()?; + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + "icacls failed to harden config ACL", + )) + } +} + +#[cfg(not(windows))] +fn harden_windows_acl(_path: &std::path::Path) -> io::Result<()> { Ok(()) } @@ -235,41 +455,18 @@ fn prompt_yn(message: &str, default: bool) -> bool { } /// Install Tailscale (Linux) -/// -/// Security note: Downloads and executes the official Tailscale install script. -/// User is prompted for confirmation before execution. For additional security, -/// users can manually install via their package manager or verify the script at -/// https://tailscale.com/install.sh before running. #[cfg(target_os = "linux")] fn install_tailscale_linux() -> io::Result { println!(); - println!("{}", "Installing Tailscale...".cyan()); - println!("This will download and run the official Tailscale installer."); - println!("Script URL: {}", "https://tailscale.com/install.sh".cyan()); + println!("{}", "Tailscale installation is manual on Linux.".cyan()); + println!("Install it with your distribution package manager or follow:"); + println!(" {}", "https://tailscale.com/download/linux".cyan()); println!(); println!( "{}", - "Alternatively, install manually: https://tailscale.com/download/linux".dimmed() + "MobileCLI does not run curl | sh installers from setup.".dimmed() ); - println!(); - - if !prompt_yn("Download and run installer?", true) { - return Ok(false); - } - - // Download and run install script (user has confirmed) - let status = Command::new("sh") - .arg("-c") - .arg("curl -fsSL https://tailscale.com/install.sh | sh") - .status()?; - - if status.success() { - println!("{}", "✓ Tailscale installed successfully!".green()); - Ok(true) - } else { - println!("{}", "✗ Tailscale installation failed".red()); - Ok(false) - } + Ok(false) } /// Install Tailscale (macOS) @@ -387,7 +584,7 @@ pub fn run_setup_wizard() -> io::Result { "1" => break 1, "2" => break 2, "3" => break 3, - "" => break 1, // Default to local + "" => break 2, // Default to Tailscale for remote-safe access _ => println!("{}", "Please enter 1, 2, or 3".yellow()), } }; @@ -428,7 +625,7 @@ pub fn run_setup_wizard() -> io::Result { println!(); println!("{}", "Tailscale is not installed.".yellow()); - if prompt_yn("Would you like to install Tailscale now?", true) { + if prompt_yn("Would you like to install Tailscale now?", false) { #[cfg(target_os = "macos")] let installed = install_tailscale_macos()?; @@ -477,9 +674,6 @@ pub fn run_setup_wizard() -> io::Result { println!(); println!("{}", "⚠ Tailscale not fully configured.".yellow()); println!(" Run 'tailscale up' to complete setup."); - - // Fall back to local - config.local_ip = get_local_ip(); } } 3 => { @@ -493,6 +687,19 @@ pub fn run_setup_wizard() -> io::Result { _ => unreachable!(), } + println!(); + println!( + "{}", + "── Mobile Filesystem Safety ─────────────────────────────────".dimmed() + ); + println!( + "{}", + "Mobile file browsing/editing is limited to configured roots. Delete and rename are off by default.".dimmed() + ); + if prompt_yn("Allow destructive mobile file operations?", false) { + config.filesystem.destructive_operations = true; + } + // Save configuration save_config(&config)?; @@ -517,7 +724,7 @@ pub fn run_setup_wizard() -> io::Result { ); println!(); - if prompt_yn("Enable auto-launch?", true) { + if prompt_yn("Enable auto-launch?", false) { if shell_hook::install_quiet() { println!( "{} Auto-launch enabled! New terminals will start mobilecli automatically.", @@ -566,3 +773,23 @@ pub fn run_setup_wizard() -> io::Result { Ok(config) } + +#[cfg(test)] +mod tests { + use super::parse_filesystem_config; + + #[test] + fn explicit_empty_allowed_roots_stays_deny_all() { + let value = serde_json::json!({ + "allowed_roots": [], + "whole_home_enabled": false, + "destructive_operations": false, + }); + + let config = parse_filesystem_config(Some(&value)); + + assert!(config.allowed_roots.is_empty()); + assert!(!config.whole_home_enabled); + assert!(!config.destructive_operations); + } +} diff --git a/docs/ARCHITECTURE_QUICK_REFERENCE.md b/docs/ARCHITECTURE_QUICK_REFERENCE.md index 73e3178..1ed353d 100644 --- a/docs/ARCHITECTURE_QUICK_REFERENCE.md +++ b/docs/ARCHITECTURE_QUICK_REFERENCE.md @@ -9,7 +9,7 @@ - **Process**: Manages PTY sessions, WebSocket server ### Mobile App (React Native) -- **Framework**: Expo SDK 52 +- **Framework**: Expo SDK 54 - **Terminal**: xterm.js - **Storage**: expo-secure-store (device links) - **Notifications**: expo-notifications (push) @@ -19,13 +19,15 @@ ``` 1. Desktop: mobilecli setup (or mobilecli --setup) ↓ -2. Generate QR: mobilecli://ip:9847?device_id=xxx +2. Generate auth-v2 QR: mobilecli://ip:9847?...credential_id=xxx&server_id=yyy&auth_token=zzz ↓ -3. Mobile: Scan QR → Store device +3. Mobile: Scan QR → Store device and pairing secret ↓ -4. WebSocket: Connect & authenticate +4. WebSocket: Connect over configured LAN/Tailscale/custom path ↓ -5. Stream: PTY bytes ↔ Terminal display +5. Auth: auth_start → auth_challenge → auth_response + ↓ +6. Stream: PTY bytes ↔ Terminal display ``` ## Key Protocol Messages @@ -33,7 +35,9 @@ ### Client → Server ```typescript { - type: "hello", // Initial handshake + type: "auth_start", // Initial mobile handshake + type: "auth_response", // Challenge-response proof + type: "hello", // Legacy/no-op after auth type: "get_sessions", // List active terminals type: "subscribe", // Subscribe to session type: "send_input", // Send terminal input @@ -46,6 +50,7 @@ ### Server → Client ```typescript { + type: "auth_challenge", // Auth-v2 server challenge type: "welcome", // Handshake response type: "sessions", // Session list type: "pty_bytes", // Terminal output (base64) @@ -81,13 +86,14 @@ mobile/ ## Security Model -- **Pairing Token (Optional)**: A per-device `auth_token` is generated during setup and embedded in the pairing QR code for convenience. +- **Auth-v2 Pairing**: Setup creates a mobile credential. The QR code contains the daemon URL, device metadata, server id, credential id, and one-time pairing token. The desktop stores only a derived verifier. - **Device IDs**: UUID per computer (for multi-device support / display) - **Network Options**: - Local WiFi (192.168.x.x) - Tailscale VPN (100.x.x.x) - Custom URL -- **Data**: Never leaves your network +- **Access Control**: Mobile clients must complete challenge-response auth before receiving sessions, terminal data, filesystem data, or push-token registration. Keep port 9847 on a trusted LAN, Tailnet, firewall allowlist, or protected custom endpoint. +- **Terminal Data**: Terminal streams are not sent through a MobileCLI relay. Push notifications use Expo's push service and include notification metadata. ## AI CLI Detection @@ -100,17 +106,13 @@ Automatically detects and adapts UI for: ## Testing Infrastructure -### Minimum Requirements -- **VPS**: 1 vCPU, 1GB RAM, 20GB storage -- **OS**: Ubuntu 22.04 LTS -- **Ports**: 22 (SSH), 9847 (WebSocket) -- **Cost**: ~$6/month - -### Recommended Stack -- **Server**: Hetzner CPX21 (€5.83/month) -- **Process Manager**: systemd -- **Monitoring**: journalctl logs -- **Sessions**: tmux for persistence +### Minimum Release Smoke +- **Desktop OS**: macOS Apple Silicon, macOS Intel, Linux x86_64, Linux ARM64, Windows x64 +- **Mobile OS**: iOS release candidate, Android release candidate if Android is distributed +- **Networks**: Same LAN and Tailscale +- **Auth**: QR pair, manual pair, bad token rejection, revoked credential rejection +- **Filesystem**: allowed project roots work; MobileCLI config and common secret paths are denied +- **Installer**: archive checksum is verified before extraction ## Quick Commands @@ -128,19 +130,11 @@ journalctl -u mobilecli-daemon -f ss -tlnp | grep 9847 # Debug +# Expected: legacy hello gets auth_required and no sensitive data websocat ws://localhost:9847 tcpdump -i any port 9847 ``` -## Budget Breakdown - -| Service | Monthly Cost | Purpose | -|---------|--------------|---------| -| Hetzner VPS | $6.30 | Test server | -| Domain (optional) | $1 | Custom URL | -| SSL (Let's Encrypt) | $0 | HTTPS | -| **Total** | **$7.30** | Complete setup | - ## Apple Review Tips 1. **Stable QR**: Use server IP, not dynamic diff --git a/docs/SYSTEM_COMPONENT_MAP_DESKTOP.md b/docs/SYSTEM_COMPONENT_MAP_DESKTOP.md index ac1a5a3..c6e1910 100644 --- a/docs/SYSTEM_COMPONENT_MAP_DESKTOP.md +++ b/docs/SYSTEM_COMPONENT_MAP_DESKTOP.md @@ -1,6 +1,6 @@ # Desktop System Component Map -Last updated: 2026-02-14 +Last updated: 2026-05-15 Scope: `cli/` Rust daemon + wrapper + filesystem + setup/link flows. ## 1. Runtime Topology @@ -9,9 +9,9 @@ Scope: `cli/` Rust daemon + wrapper + filesystem + setup/link flows. - Command router for: daemon start/stop/status, setup, QR display, session list, linked mode, wrapped PTY mode. - Delegates long-running websocket server to `daemon::run`. - `cli/src/daemon.rs` - - Single websocket server on `0.0.0.0:`. + - WebSocket daemon with loopback plus configured LAN/Tailscale/custom listener policy. - Accepts two client classes: - - Mobile clients (`hello`, `get_sessions`, FS ops, push token registration, spawn requests). + - Mobile clients (`auth_start`/`auth_response`, `get_sessions`, FS ops, push token registration, spawn requests). - PTY wrapper clients (`register_pty`, `pty_output`, terminal lifecycle). - Owns shared state: sessions, mobile subscribers, waiting states, push tokens, file watchers, rate limiters. - `cli/src/pty_wrapper.rs` @@ -27,9 +27,10 @@ Scope: `cli/` Rust daemon + wrapper + filesystem + setup/link flows. ## 2. Request/Response Flow Map ### 2.1 Mobile connect -1. Mobile opens WS -> sends `hello`. -2. Daemon accepts mobile client, replies `welcome` + `sessions` + waiting states. -3. Mobile subscribes/unsubscribes per active session. +1. Mobile opens WS -> sends `auth_start`. +2. Daemon replies `auth_challenge`; mobile sends `auth_response`. +3. Daemon accepts the client only after the challenge-response proof verifies, then replies `welcome`. +4. Mobile requests sessions and subscribes/unsubscribes per active session. ### 2.2 PTY session registration 1. Wrapper opens WS and first message is `register_pty`. diff --git a/docs/WINDOWS_SETUP.md b/docs/WINDOWS_SETUP.md index 380196e..0ecc26d 100644 --- a/docs/WINDOWS_SETUP.md +++ b/docs/WINDOWS_SETUP.md @@ -143,19 +143,11 @@ mobilecli.exe autolaunch install reg add "HKCU\Software\Microsoft\Command Processor" /v AutoRun /t REG_SZ /d "mobilecli" /f ``` -## Public Tunnel for Testing +## Remote Access for Testing -For Apple Review or remote access: +Do not expose the MobileCLI daemon through a public tunnel. Current builds require auth-v2 pairing before sending sessions or terminal data, but a public listener still increases attack surface and brute-force/DoS risk. -```powershell -# Install localtunnel -npm install -g localtunnel - -# Start tunnel (in separate window) -lt --port 9847 -``` - -This gives a public URL like `https://xxx.loca.lt` for mobile connection. +For remote testing, use Tailscale on both the Windows machine and the mobile device, or place the daemon behind a protected `wss://` endpoint you control. ## Commands Reference diff --git a/install.sh b/install.sh index 899083b..81480cf 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,7 @@ NC='\033[0m' # No Color REPO="MobileCLI/mobilecli" BINARY_NAME="mobilecli" +CHECKSUM_FILE="SHA256SUMS.txt" # Print styled messages info() { echo -e "${CYAN}$1${NC}"; } @@ -23,6 +24,46 @@ success() { echo -e "${GREEN}✓ $1${NC}"; } warn() { echo -e "${YELLOW}⚠ $1${NC}"; } error() { echo -e "${RED}✗ $1${NC}" >&2; exit 1; } +# Compute a SHA-256 digest using the platform's available tool. +sha256_digest() { + local file="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + else + error "No SHA-256 checksum tool found. Install sha256sum or shasum and try again." + fi +} + +# Verify the downloaded archive against the release checksum manifest. +verify_archive_checksum() { + local archive_path="$1" + local archive_name="$2" + local checksum_path="$3" + local expected actual + + if [ ! -s "$checksum_path" ]; then + error "Checksum manifest is missing or empty." + fi + + expected=$(awk -v file="$archive_name" '($2 == file || $2 == "*" file || $2 == "./" file) { print $1; exit }' "$checksum_path") + if [ -z "$expected" ]; then + error "No checksum entry found for ${archive_name}. Refusing to install." + fi + + expected=$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]') + if ! printf '%s\n' "$expected" | grep -Eq '^[0-9a-f]{64}$'; then + error "Invalid checksum entry for ${archive_name}. Refusing to install." + fi + + actual=$(sha256_digest "$archive_path" | tr '[:upper:]' '[:lower:]') + if [ "$actual" != "$expected" ]; then + error "Checksum verification failed for ${archive_name}. Refusing to install." + fi +} + # Map OS/arch to Rust target triple (matches release workflow archives) detect_target() { local os arch @@ -69,7 +110,7 @@ get_install_dir() { # Download and install install() { - local platform version install_dir archive_name download_url tmp_dir + local platform version install_dir archive_name download_url checksums_url tmp_dir info "╔══════════════════════════════════════════════════════════════╗" info "║ 📱 MobileCLI Installer ║" @@ -92,10 +133,18 @@ install() { # Construct download URL archive_name="${BINARY_NAME}-${version}-${platform}.tar.gz" download_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}" + checksums_url="https://github.com/${REPO}/releases/download/${version}/${CHECKSUM_FILE}" # Create temp directory tmp_dir=$(mktemp -d) - trap "rm -rf $tmp_dir" EXIT + trap "rm -rf '${tmp_dir}'" EXIT + + # Download checksum manifest + info "Downloading ${CHECKSUM_FILE}..." + if ! curl -fsSL "$checksums_url" -o "${tmp_dir}/${CHECKSUM_FILE}"; then + error "Failed to download checksums from $checksums_url" + fi + success "Downloaded checksums" # Download archive info "Downloading ${archive_name}..." @@ -104,6 +153,11 @@ install() { fi success "Downloaded successfully" + # Verify archive before extraction + info "Verifying checksum..." + verify_archive_checksum "${tmp_dir}/${archive_name}" "$archive_name" "${tmp_dir}/${CHECKSUM_FILE}" + success "Checksum verified" + # Extract archive info "Extracting..." tar -xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir" @@ -144,5 +198,8 @@ install() { fi } -# Run installation -install +# Run installation when executed directly. Tests can source this file to exercise +# checksum helpers without downloading or installing. +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + install +fi diff --git a/scripts/test-installer-checksum.sh b/scripts/test-installer-checksum.sh new file mode 100644 index 0000000..ceb87fe --- /dev/null +++ b/scripts/test-installer-checksum.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# shellcheck source=../install.sh +source "${ROOT_DIR}/install.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +archive_name="mobilecli-v0.0.0-x86_64-unknown-linux-gnu.tar.gz" +archive_path="${tmp_dir}/${archive_name}" +checksum_path="${tmp_dir}/SHA256SUMS.txt" + +printf 'mobilecli-test-archive' >"${archive_path}" +valid_digest="$(sha256_digest "${archive_path}")" + +expect_success() { + local label="$1" + if ! verify_archive_checksum "${archive_path}" "${archive_name}" "${checksum_path}" >/dev/null 2>&1; then + printf 'FAIL: expected success for %s\n' "${label}" >&2 + exit 1 + fi +} + +expect_failure() { + local label="$1" + if (verify_archive_checksum "${archive_path}" "${archive_name}" "${checksum_path}") >/dev/null 2>&1; then + printf 'FAIL: expected failure for %s\n' "${label}" >&2 + exit 1 + fi +} + +printf '%s %s\n' "${valid_digest}" "${archive_name}" >"${checksum_path}" +expect_success "valid checksum" + +printf '%s *%s\n' "${valid_digest}" "${archive_name}" >"${checksum_path}" +expect_success "coreutils star-prefixed checksum" + +printf '%s ./archive-other.tar.gz\n' "${valid_digest}" >"${checksum_path}" +expect_failure "missing archive entry" + +printf 'not-a-sha %s\n' "${archive_name}" >"${checksum_path}" +expect_failure "invalid digest shape" + +printf '%064d %s\n' 0 "${archive_name}" >"${checksum_path}" +expect_failure "wrong digest" + +: >"${checksum_path}" +expect_failure "empty checksum manifest" + +printf 'installer checksum tests: ok\n'