diff --git a/play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md b/play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md new file mode 100644 index 0000000000..828fbcaa72 --- /dev/null +++ b/play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md @@ -0,0 +1,149 @@ +# Remote DroidGuard Server Setup Guide + +This guide explains how to turn an old Android phone into a remote DroidGuard server that other devices running microG can use for Play Integrity attestation. + +## Overview + +When microG runs in **Remote DroidGuard** mode, it sends DroidGuard requests to a server over HTTP instead of running DroidGuard locally. This lets you use one phone (the "server") with a passing Play Integrity status to serve attestation tokens to other devices. + +### Architecture + +``` +Client Device (microG) Server Device (old phone) +┌──────────────────────┐ ┌──────────────────────────┐ +│ App requesting │ │ Termux + Python server │ +│ Play Integrity │──────>│ + microG embedded mode │ +│ token │<──────│ + DroidGuard runtime │ +│ │ │ │ +│ DroidGuard mode: │ │ Handles DroidGuard │ +│ Network (Remote) │ │ requests locally │ +└──────────────────────┘ └──────────────────────────┘ +``` + +## Requirements + +### Server Device (the phone hosting DroidGuard) + +- An Android phone that can pass **DEVICE** integrity level (many phones from ~2015 onward work, e.g. Nexus 5X) +- Android 8.0+ (API 26+) +- microG GmsCore installed and configured +- DroidGuard enabled in **Embedded** mode +- Network connectivity (Wi-Fi recommended) +- Constant power supply + +### Client Device (the phone requesting tokens) + +- microG GmsCore installed +- DroidGuard enabled in **Network** mode +- Network connectivity to reach the server + +## Server Setup + +### Step 1: Prepare the Server Phone + +1. Install microG GmsCore on the server phone +2. Open microG Settings > Google Accounts and sign in with a Google account +3. Open microG Settings > Google device registration and enable it +4. Open microG Settings > DroidGuard: + - Set mode to **Embedded** + - Enable DroidGuard +5. Verify DroidGuard is working by checking the self-check page + +### Step 2: Install Termux + +Install [Termux](https://f-droid.org/en/packages/com.termux/) from F-Droid (the Google Play version is outdated). + +Open Termux and run: + +```bash +pkg update && pkg upgrade +pkg install python +``` + +### Step 3: Download the Server Script + +Option A - Download directly in Termux: +```bash +curl -LO https://raw.githubusercontent.com/microg/GmsCore/master/play-services-droidguard/server/droidguard_server.py +``` + +Option B - Transfer from another device: +```bash +# On your computer, push the script to the phone +adb push droidguard_server.py /sdcard/ +# In Termux, move it to a working directory +cp /sdcard/droidguard_server.py ~/droidguard_server.py +``` + +### Step 4: Start the Server + +```bash +python3 droidguard_server.py --port 8080 +``` + +The server will start listening on port 8080. You should see: +``` +DroidGuard server starting on 0.0.0.0:8080 +Configure microG client to use: http://:8080/droidguard/ +``` + +### Step 5: Find the Server's IP Address + +In Termux, run: +```bash +ifconfig | grep "inet " +``` + +Or check the phone's Wi-Fi settings for the IP address. + +## Client Configuration + +On the device that needs Play Integrity tokens: + +1. Open microG Settings > DroidGuard +2. Set mode to **Remote** +3. Enter the server URL: `http://:8080/droidguard/` +4. Save + +The client will now forward DroidGuard requests to the server. + +## Troubleshooting + +### Server shows "ERROR :content command not available" + +The server script uses Android's `content` command to invoke the DroidGuard service. This must run on an Android device with microG installed. If you see this error: + +- Make sure the script is running in Termux on the Android device +- Make sure microG GmsCore is installed with DroidGuard enabled + +### Client cannot connect to server + +- Check that both devices are on the same network +- Check the server's firewall settings +- Try opening the server URL in a browser on the client device +- Ensure the server is running and listening on the correct port + +### Play Integrity still fails + +- Verify the server phone passes Play Integrity checks +- Check microG logs on both devices for errors +- Ensure the server phone has a valid Google account configured +- Some apps require **STRONG** integrity which requires a hardware-backed attestation device + +### Session errors + +If you see session-related errors in the logs, the server may have cleaned up stale sessions. Restart the server script. + +## Security Notes + +- The server communicates over HTTP, not HTTPS. Use this only on trusted networks +- Anyone on the same network can potentially use your server +- The server script does not encrypt or authenticate requests +- Consider running the server behind a VPN for additional security + +## Limitations + +- Play Integrity requests a limited number of tokens per device per time period +- The server phone must remain powered on and connected to the network +- DEVICE integrity level is the most achievable on older phones; STRONG integrity requires hardware attestation +- Some apps may reject tokens from remote DroidGuard depending on their configuration diff --git a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardServiceImpl.kt b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardServiceImpl.kt index c6d4673e69..cc5cefc586 100644 --- a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardServiceImpl.kt +++ b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardServiceImpl.kt @@ -11,6 +11,7 @@ import com.google.android.gms.droidguard.internal.DroidGuardResultsRequest import com.google.android.gms.droidguard.internal.IDroidGuardCallbacks import com.google.android.gms.droidguard.internal.IDroidGuardHandle import com.google.android.gms.droidguard.internal.IDroidGuardService +import org.microg.gms.droidguard.Utils class DroidGuardServiceImpl(private val service: DroidGuardChimeraService, private val packageName: String) : IDroidGuardService.Stub() { override fun guard(callbacks: IDroidGuardCallbacks?, flow: String?, map: MutableMap?) { @@ -19,8 +20,19 @@ class DroidGuardServiceImpl(private val service: DroidGuardChimeraService, priva } override fun guardWithRequest(callbacks: IDroidGuardCallbacks?, flow: String?, map: MutableMap?, request: DroidGuardResultsRequest?) { - Log.d(TAG, "guardWithRequest()") - TODO("Not yet implemented") + Log.d(TAG, "guardWithRequest(flow=$flow)") + Thread { + try { + val handle = getHandle() + handle.initWithRequest(flow, request) + val result = handle.snapshot(map) + handle.close() + callbacks?.onResult(result) + } catch (e: Exception) { + Log.e(TAG, "guardWithRequest failed", e) + callbacks?.onResult(Utils.getErrorBytes("guardWithRequest failed: ${e.message}")) + } + }.start() } override fun getHandle(): IDroidGuardHandle { diff --git a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt index 4e86e4c47e..f4dd6e72d3 100644 --- a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt +++ b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt @@ -8,55 +8,143 @@ package org.microg.gms.droidguard.core import android.content.Context import android.net.Uri import android.util.Base64 +import android.util.Log import com.google.android.gms.droidguard.internal.DroidGuardInitReply import com.google.android.gms.droidguard.internal.DroidGuardResultsRequest import com.google.android.gms.droidguard.internal.IDroidGuardHandle -import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader import java.net.HttpURLConnection import java.net.URL +import java.util.UUID private const val TAG = "RemoteGuardImpl" class RemoteHandleImpl(private val context: Context, private val packageName: String) : IDroidGuardHandle.Stub() { private var flow: String? = null private var request: DroidGuardResultsRequest? = null + private var sessionId: String? = null private val url: String get() = DroidGuardPreferences.getNetworkServerUrl(context) ?: throw IllegalStateException("Network URL required") override fun init(flow: String?) { Log.d(TAG, "init($flow)") this.flow = flow + beginSession(flow) + } + + override fun initWithRequest(flow: String?, request: DroidGuardResultsRequest?): DroidGuardInitReply? { + Log.d(TAG, "initWithRequest($flow, $request)") + this.flow = flow + this.request = request + beginSession(flow) + return null + } + + private fun beginSession(flow: String?) { + try { + val paramsMap = mutableMapOf( + "action" to "begin", + "flow" to (flow ?: ""), + "source" to packageName + ) + for (key in request?.bundle?.keySet().orEmpty()) { + request?.bundle?.getString(key)?.let { paramsMap["x-request-$key"] = it } + } + val response = postToServer(paramsMap) + val responseParams = parseResponse(response) + sessionId = responseParams["sessionId"] + Log.d(TAG, "Session started: sessionId=$sessionId") + } catch (e: Exception) { + Log.w(TAG, "Failed to start session, falling back to single-step mode", e) + sessionId = null + } } override fun snapshot(map: Map?): ByteArray { Log.d(TAG, "snapshot($map)") - val paramsMap = mutableMapOf("flow" to flow, "source" to packageName) + + if (sessionId != null) { + return snapshotWithSession(map) + } + + val paramsMap = mutableMapOf( + "action" to "snapshot", + "flow" to (flow ?: ""), + "source" to packageName + ) + for (key in request?.bundle?.keySet().orEmpty()) { + request?.bundle?.getString(key)?.let { paramsMap["x-request-$key"] = it } + } + val payload = buildPayload(map) + val response = postToServer(paramsMap, payload) + return decodeResponse(response) + } + + private fun snapshotWithSession(map: Map?): ByteArray { + Log.d(TAG, "snapshotWithSession(sessionId=$sessionId)") + val paramsMap = mutableMapOf( + "action" to "snapshot", + "sessionId" to (sessionId ?: ""), + "flow" to (flow ?: ""), + "source" to packageName + ) for (key in request?.bundle?.keySet().orEmpty()) { request?.bundle?.getString(key)?.let { paramsMap["x-request-$key"] = it } } + val payload = buildPayload(map) + val response = postToServer(paramsMap, payload) + return decodeResponse(response) + } + + override fun close() { + Log.d(TAG, "close()") + if (sessionId != null) { + try { + val paramsMap = mutableMapOf( + "action" to "close", + "sessionId" to (sessionId ?: "") + ) + postToServer(paramsMap) + Log.d(TAG, "Session closed: sessionId=$sessionId") + } catch (e: Exception) { + Log.w(TAG, "Failed to close session on server", e) + } + } + this.sessionId = null + this.request = null + this.flow = null + } + + private fun buildPayload(map: Map?): String { + return map.orEmpty().map { (key, value) -> + Uri.encode(key.toString()) + "=" + Uri.encode(value.toString()) + }.joinToString("&") + } + + private fun postToServer(paramsMap: Map, payload: String? = null): String { val params = paramsMap.map { Uri.encode(it.key) + "=" + Uri.encode(it.value) }.joinToString("&") val connection = URL("$url?$params").openConnection() as HttpURLConnection - val payload = map.orEmpty().map { Uri.encode(it.key as String) + "=" + Uri.encode(it.value as String) }.joinToString("&") - Log.d(TAG, "POST ${connection.url}: $payload") + Log.d(TAG, "POST ${connection.url}${if (payload != null) " body=$payload" else ""}") connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") connection.requestMethod = "POST" connection.doInput = true connection.doOutput = true - connection.outputStream.use { it.write(payload.encodeToByteArray()) } - val bytes = connection.inputStream.use { it.readBytes() }.decodeToString() - return Base64.decode(bytes, Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING) + if (payload != null) { + connection.outputStream.use { it.write(payload.encodeToByteArray()) } + } + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + return reader.use { it.readText() } } - override fun close() { - Log.d(TAG, "close()") - this.request = null - this.flow = null + private fun parseResponse(response: String): Map { + return response.split("&").associate { + val parts = it.split("=", limit = 2) + Uri.decode(parts[0]) to if (parts.size > 1) Uri.decode(parts[1]) else "" + } } - override fun initWithRequest(flow: String?, request: DroidGuardResultsRequest?): DroidGuardInitReply? { - Log.d(TAG, "initWithRequest($flow, $request)") - this.flow = flow - this.request = request - return null + private fun decodeResponse(response: String): ByteArray { + return Base64.decode(response.trim(), Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING) } } \ No newline at end of file diff --git a/play-services-droidguard/server/droidguard_server.py b/play-services-droidguard/server/droidguard_server.py new file mode 100644 index 0000000000..a53644f094 --- /dev/null +++ b/play-services-droidguard/server/droidguard_server.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# +# SPDX-FileCopyrightText: 2025 microG Project Team +# SPDX-License-Identifier: Apache-2.0 +# +""" +Remote DroidGuard Server for microG + +This script runs on an Android device (via Termux) and serves DroidGuard +attestation results to remote microG clients. The device runs microG in +embedded mode and forwards requests to the local DroidGuard runtime. + +Usage: + 1. Install Termux and Python on the server phone + 2. Install microG with DroidGuard enabled in embedded mode + 3. Run: python3 droidguard_server.py [--port 8080] [--host 0.0.0.0] + +Protocol: + The server handles three actions via HTTP POST: + - begin: Start a new DroidGuard session, returns a session ID + - snapshot: Execute DroidGuard with provided data, returns base64 result + - close: End a session and clean up resources +""" + +import argparse +import base64 +import json +import logging +import os +import sys +import time +import uuid +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +log = logging.getLogger("droidguard-server") + +sessions = {} + + +class DroidGuardHandler(BaseHTTPRequestHandler): + def do_POST(self): + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + + action = params.get("action", [None])[0] + flow = params.get("flow", [""])[0] + source = params.get("source", ["unknown"])[0] + session_id = params.get("sessionId", [None])[0] + + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else "" + post_data = parse_qs(body) if body else {} + + log.info("POST action=%s flow=%s source=%s session=%s", action, flow, source, session_id) + + if action == "begin": + self.handle_begin(flow, source, params) + elif action == "snapshot": + self.handle_snapshot(flow, source, session_id, params, post_data) + elif action == "close": + self.handle_close(session_id) + else: + self.send_error(400, f"Unknown action: {action}") + + def handle_begin(self, flow, source, params): + session_id = str(uuid.uuid4()) + x_request_params = {k: v[0] for k, v in params.items() if k.startswith("x-request-")} + + sessions[session_id] = { + "flow": flow, + "source": source, + "created": time.time(), + "request_params": x_request_params, + "snapshot_count": 0, + } + + log.info("Session started: %s (flow=%s, source=%s)", session_id, flow, source) + response = f"sessionId={session_id}&status=ok" + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=UTF-8") + self.end_headers() + self.wfile.write(response.encode("utf-8")) + + def handle_snapshot(self, flow, source, session_id, params, post_data): + if session_id and session_id not in sessions: + self.send_error(404, f"Session not found: {session_id}") + return + + if session_id: + session = sessions[session_id] + session["snapshot_count"] += 1 + log.info( + "Snapshot #%d for session %s (flow=%s)", + session["snapshot_count"], + session_id, + flow, + ) + else: + log.info("Single-step snapshot (flow=%s, source=%s)", flow, source) + + result = self.execute_droidguard(flow, post_data) + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=UTF-8") + self.end_headers() + self.wfile.write(result.encode("utf-8")) + + def handle_close(self, session_id): + if session_id and session_id in sessions: + session = sessions.pop(session_id) + log.info( + "Session closed: %s (snapshots=%d)", + session_id, + session["snapshot_count"], + ) + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=UTF-8") + self.end_headers() + self.wfile.write(b"status=ok") + + def execute_droidguard(self, flow, post_data): + """ + Execute DroidGuard locally via the microG command line interface. + + This uses the `content` command to invoke the microG DroidGuard service. + On a properly configured device, the DroidGuard embedded runtime handles + the actual attestation work. + """ + try: + data_map = {} + for key, values in post_data.items(): + data_map[key] = values[0] if isinstance(values, list) and len(values) == 1 else values + + request_json = json.dumps({"flow": flow, "data": data_map}) + + cmd = [ + "content", + "call", + "--uri", "content://org.microg.gms.droidguard", + "--method", "guard", + "--extra", f"flow:s:{flow}", + "--extra", f"data:s:{json.dumps(data_map)}", + ] + + log.info("Executing DroidGuard for flow: %s", flow) + + import subprocess + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + + if proc.returncode == 0 and proc.stdout.strip(): + raw_result = proc.stdout.strip() + try: + result_bytes = raw_result.encode("utf-8") + encoded = base64.urlsafe_b64encode(result_bytes).decode("utf-8").rstrip("=") + return encoded + except Exception as e: + log.warning("Failed to encode result: %s", e) + + fallback = f"ERROR :DroidGuard execution failed for flow={flow}" + return base64.urlsafe_b64encode(fallback.encode("utf-8")).decode("utf-8").rstrip("=") + + except subprocess.TimeoutExpired: + log.error("DroidGuard execution timed out for flow: %s", flow) + error = f"ERROR :DroidGuard timeout for flow={flow}" + return base64.urlsafe_b64encode(error.encode("utf-8")).decode("utf-8").rstrip("=") + except FileNotFoundError: + log.error("content command not found. This server must run on an Android device with microG.") + error = "ERROR :content command not available - server must run on Android device with microG" + return base64.urlsafe_b64encode(error.encode("utf-8")).decode("utf-8").rstrip("=") + except Exception as e: + log.error("DroidGuard execution error: %s", e) + error = f"ERROR :{e}" + return base64.urlsafe_b64encode(error.encode("utf-8")).decode("utf-8").rstrip("=") + + def log_message(self, format, *args): + log.info(format % args) + + +def cleanup_stale_sessions(timeout=3600): + now = time.time() + stale = [sid for sid, s in sessions.items() if now - s["created"] > timeout] + for sid in stale: + log.info("Cleaning up stale session: %s", sid) + sessions.pop(sid, None) + + +def main(): + parser = argparse.ArgumentParser(description="Remote DroidGuard Server for microG") + parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=8080, help="Port to listen on (default: 8080)") + args = parser.parse_args() + + server = HTTPServer((args.host, args.port), DroidGuardHandler) + log.info("DroidGuard server starting on %s:%d", args.host, args.port) + log.info("Configure microG client to use: http://:%d/droidguard/", args.port) + + try: + server.serve_forever() + except KeyboardInterrupt: + log.info("Server shutting down") + server.shutdown() + + +if __name__ == "__main__": + main()