Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md
Original file line number Diff line number Diff line change
@@ -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://<device-ip>: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://<server-ip>: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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any?, Any?>?) {
Expand All @@ -19,8 +20,19 @@ class DroidGuardServiceImpl(private val service: DroidGuardChimeraService, priva
}

override fun guardWithRequest(callbacks: IDroidGuardCallbacks?, flow: String?, map: MutableMap<Any?, Any?>?, 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any?, Any?>?): 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<Any?, Any?>?): 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<Any?, Any?>?): String {
return map.orEmpty().map { (key, value) ->
Uri.encode(key.toString()) + "=" + Uri.encode(value.toString())
}.joinToString("&")
}

private fun postToServer(paramsMap: Map<String, String>, 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<String, String> {
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)
}
}
Loading