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
62 changes: 62 additions & 0 deletions play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Remote DroidGuard Setup for Play Integrity Multi-Step Flows

This document covers only the client-side configuration that microG already provides and a minimal
self-hosted server pattern for testing remote Play Integrity in issue [#2851](https://github.com/microg/GmsCore/issues/2851).

## 1) What changed for Play Integrity compatibility

Play Integrity can use a multi-step DroidGuard flow. Before this fix, microG accepted only single
snapshot calls over remote DroidGuard. The implementation now supports:

- `begin(...)` to start a multi-step session
- `nextStep(...)` to submit one intermediate step
- `snapshotWithSession(...)` to submit the final payload
- `closeSession(...)` to clean up server-side state if needed

The remote request packet now carries the multi-step metadata in `DroidGuardResultsRequest`:

- `sessionId`
- `stepNumber` (0-based)
- `totalSteps` (if known by caller)
- `isMultiStep = true`

## 2) Client setup in microG

1. Open microG Settings → DroidGuard → Remote
2. Set the Remote URL to your reachable endpoint
3. Save and keep microG in Remote mode for the target app profile

The remote client sends:

- query params:
- `flow`
- `source` (package name)
- `x-request-*` values from `DroidGuardResultsRequest`
- `POST` body with the step payload as URL-encoded key/value pairs

## 3) Minimal server endpoint expectation

MicroG remote mode expects a DroidGuard-compatible endpoint that receives:

- `POST` at the configured URL
- query parameters above
- `application/x-www-form-urlencoded` body
- returns raw DroidGuard token bytes (`byte[]`)

The request sequence is:

1. `begin(...)` initializes a server-side session context
2. each `nextStep(...)` appends state
3. `snapshotWithSession(...)` finalizes and returns response bytes

If you are hosting your own server, return a non-empty byte array and ensure CORS/network
and TLS are configured for your environment.

## 4) Suggested minimum validation flow

1. Start a fresh Play Integrity flow from a test app
2. Confirm the first request includes `x-request-is-multi-step=true`
3. Confirm subsequent `nextStep` calls arrive without `sessionId` mismatch
4. Confirm the final `snapshotWithSession` returns non-empty bytes

Keep payload logs scrubbed of PII before sharing them in bug reports.
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,25 @@ import org.microg.gms.droidguard.BytesException
import org.microg.gms.droidguard.GuardCallback
import org.microg.gms.droidguard.HandleProxy
import java.io.FileNotFoundException
import java.util.concurrent.atomic.AtomicLong

class DroidGuardHandleImpl(private val context: Context, private val packageName: String, private val factory: NetworkHandleProxyFactory, private val callback: GuardCallback) : IDroidGuardHandle.Stub() {
private val condition = ConditionVariable()

private var flow: String? = null
private var request: DroidGuardResultsRequest? = null
private var handleProxy: HandleProxy? = null
private var handleInitError: Throwable? = null
private val sessions = mutableMapOf<Long, MultiStepSession>()
private val sessionIdSequence = AtomicLong(System.currentTimeMillis())

data class MultiStepSession(
val flow: String?,
val request: DroidGuardResultsRequest?,
var currentStep: Int = 0,
var initialData: MutableMap<Any?, Any?> = mutableMapOf(),
var pendingStepData: MutableMap<Int, Map<Any?, Any?>> = mutableMapOf()
)

override fun init(flow: String?) {
Log.d(TAG, "init($flow)")
Expand All @@ -35,6 +47,7 @@ class DroidGuardHandleImpl(private val context: Context, private val packageName
override fun initWithRequest(flow: String?, request: DroidGuardResultsRequest?): DroidGuardInitReply {
Log.d(TAG, "initWithRequest($flow, $request)")
this.flow = flow
this.request = request
var handleProxy: HandleProxy? = null
try {
if (!LOW_LATENCY_ENABLED || flow in NOT_LOW_LATENCY_FLOWS) {
Expand Down Expand Up @@ -84,6 +97,10 @@ class DroidGuardHandleImpl(private val context: Context, private val packageName

override fun snapshot(map: MutableMap<Any?, Any?>): ByteArray {
Log.d(TAG, "snapshot($map)")
return snapshotWithFlow(map, flow)
}

private fun snapshotWithFlow(map: MutableMap<Any?, Any?>, flow: String?): ByteArray {
condition.block()
handleInitError?.let { return FallbackCreator.create(flow, context, map, it) }
val handleProxy = this.handleProxy ?: return FallbackCreator.create(flow, context, map, IllegalStateException())
Expand All @@ -98,6 +115,68 @@ class DroidGuardHandleImpl(private val context: Context, private val packageName
}
}

override fun begin(flow: String?, request: DroidGuardResultsRequest?, initialData: Map<Any?, Any?>?): Long {
Log.d(TAG, "begin($flow, $request, $initialData)")
condition.block()
if (handleProxy == null) return -1

val sessionId = sessionIdSequence.incrementAndGet()
val normalizedRequest = request?.copy() ?: this.request?.copy() ?: DroidGuardResultsRequest()
val resolvedFlow = flow ?: this.flow
val session = MultiStepSession(
flow = resolvedFlow,
request = normalizedRequest,
initialData = initialData?.toMutableMap() ?: mutableMapOf()
)
sessions[sessionId] = session
normalizedRequest.setSessionId(sessionId)
normalizedRequest.setMultiStep(true)
normalizedRequest.setStepNumber(0)
return sessionId
}

override fun nextStep(sessionId: Long, stepData: Map<Any?, Any?>?): DroidGuardInitReply {
Log.d(TAG, "nextStep($sessionId, $stepData)")
condition.block()
val session = sessions[sessionId] ?: return DroidGuardInitReply(null, null)
session.currentStep++
session.pendingStepData[session.currentStep] = stepData.orEmpty()
return DroidGuardInitReply(null, null)
}

override fun snapshotWithSession(sessionId: Long, map: MutableMap<Any?, Any?>): ByteArray {
Log.d(TAG, "snapshotWithSession($sessionId, $map)")
condition.block()
val session = sessions.remove(sessionId) ?: return byteArrayOf()
val request = session.request
?: this.request
?: return byteArrayOf()

val combinedMap = mutableMapOf<Any?, Any?>()
combinedMap.putAll(session.initialData)
for (step in session.pendingStepData.toSortedMap().values) {
combinedMap.putAll(step)
}
combinedMap.putAll(map)
request.setSessionId(sessionId)
request.setStepNumber(session.currentStep)
request.setMultiStep(true)
request.setTotalSteps(request.getTotalSteps())

return snapshotWithFlow(combinedMap, session.flow)
}

private fun DroidGuardResultsRequest.copy(): DroidGuardResultsRequest {
return DroidGuardResultsRequest().also {
it.bundle.putAll(bundle)
}
}

override fun closeSession(sessionId: Long) {
Log.d(TAG, "closeSession($sessionId)")
sessions.remove(sessionId)
}

override fun close() {
Log.d(TAG, "close()")
condition.block()
Expand All @@ -106,6 +185,9 @@ class DroidGuardHandleImpl(private val context: Context, private val packageName
} catch (e: Exception) {
Log.w(TAG, "Error during handle close", e)
}
sessions.clear()
request = null
flow = null
handleProxy = null
handleInitError = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,30 @@ 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.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicLong

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 val sessions = mutableMapOf<Long, MultiStepSession>()
private val sessionIdSequence = AtomicLong(System.currentTimeMillis())

data class MultiStepSession(
val flow: String?,
val request: DroidGuardResultsRequest,
var currentStep: Int = 0,
val initialData: MutableMap<Any?, Any?> = mutableMapOf(),
val pendingStepData: MutableMap<Int, Map<Any?, Any?>> = mutableMapOf()
)

private val url: String
get() = DroidGuardPreferences.getNetworkServerUrl(context) ?: throw IllegalStateException("Network URL required")

Expand All @@ -30,13 +42,70 @@ class RemoteHandleImpl(private val context: Context, private val packageName: St

override fun snapshot(map: Map<Any?, Any?>?): ByteArray {
Log.d(TAG, "snapshot($map)")
return doSnapshot(flow, request, map.orEmpty())
}

override fun begin(flow: String?, request: DroidGuardResultsRequest?, initialData: Map<Any?, Any?>?): Long {
Log.d(TAG, "begin($flow, $request, $initialData)")
val resolvedFlow = flow ?: this.flow
val resolvedRequest = request?.copy() ?: this.request?.copy() ?: return -1
val sessionId = sessionIdSequence.incrementAndGet()
val session = MultiStepSession(
flow = resolvedFlow,
request = resolvedRequest,
initialData = initialData?.toMutableMap() ?: mutableMapOf()
)
session.request.setSessionId(sessionId)
session.request.setMultiStep(true)
session.request.setStepNumber(0)
sessions[sessionId] = session
return sessionId
}

override fun nextStep(sessionId: Long, stepData: Map<Any?, Any?>?): DroidGuardInitReply {
Log.d(TAG, "nextStep($sessionId, $stepData)")
val session = sessions[sessionId] ?: return DroidGuardInitReply(null, null)
session.currentStep++
session.pendingStepData[session.currentStep] = stepData.orEmpty()
return DroidGuardInitReply(null, null)
}

override fun snapshotWithSession(sessionId: Long, map: MutableMap<Any?, Any?>): ByteArray {
Log.d(TAG, "snapshotWithSession($sessionId, $map)")
val session = sessions.remove(sessionId) ?: return byteArrayOf()
val request = session.request
val combinedMap = mutableMapOf<Any?, Any?>()
combinedMap.putAll(session.initialData)
for (step in session.pendingStepData.toSortedMap().values) {
combinedMap.putAll(step)
}
combinedMap.putAll(map)
request.setSessionId(sessionId)
request.setStepNumber(session.currentStep)
request.setMultiStep(true)
request.setTotalSteps(request.getTotalSteps())
return doSnapshot(session.flow, request, combinedMap)
}

private fun DroidGuardResultsRequest.copy(): DroidGuardResultsRequest {
return DroidGuardResultsRequest().also {
it.bundle.putAll(bundle)
}
}

override fun closeSession(sessionId: Long) {
Log.d(TAG, "closeSession($sessionId)")
sessions.remove(sessionId)
}

private fun doSnapshot(flow: String?, request: DroidGuardResultsRequest?, map: Map<Any?, Any?>): ByteArray {
val paramsMap = mutableMapOf("flow" to flow, "source" to packageName)
for (key in request?.bundle?.keySet().orEmpty()) {
request?.bundle?.getString(key)?.let { paramsMap["x-request-$key"] = it }
request?.bundle?.get(key)?.let { paramsMap["x-request-$key"] = it.toString() }
}
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("&")
val payload = map.map { Uri.encode(it.key?.toString()) + "=" + Uri.encode(it.value?.toString()) }.joinToString("&")
Log.d(TAG, "POST ${connection.url}: $payload")
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
connection.requestMethod = "POST"
Expand All @@ -49,6 +118,7 @@ class RemoteHandleImpl(private val context: Context, private val packageName: St

override fun close() {
Log.d(TAG, "close()")
sessions.clear()
this.request = null
this.flow = null
}
Expand All @@ -59,4 +129,4 @@ class RemoteHandleImpl(private val context: Context, private val packageName: St
this.request = request
return null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ interface IDroidGuardHandle {
byte[] snapshot(in Map map) = 1;
oneway void close() = 2;
DroidGuardInitReply initWithRequest(String flow, in DroidGuardResultsRequest request) = 4;

long begin(String flow, in DroidGuardResultsRequest request, in Map initialData) = 5;
DroidGuardInitReply nextStep(long sessionId, in Map stepData) = 6;
byte[] snapshotWithSession(long sessionId, in Map map) = 7;
oneway void closeSession(long sessionId) = 8;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@

package com.google.android.gms.droidguard;

import com.google.android.gms.droidguard.internal.DroidGuardInitReply;
import com.google.android.gms.droidguard.internal.DroidGuardResultsRequest;

import java.util.Map;

public interface DroidGuardHandle {
String snapshot(Map<String, String> data);

long begin(String flow, DroidGuardResultsRequest request, Map<String, String> initialData);

DroidGuardInitReply nextStep(long sessionId, Map<String, String> stepData);

String snapshotWithSession(long sessionId, Map<String, String> data);

void closeSession(long sessionId);

boolean isOpened();

void close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public class DroidGuardResultsRequest extends AutoSafeParcelable {
private static final String KEY_NETWORK_TO_USE = "networkToUse";
private static final String KEY_TIMEOUT_MS = "timeoutMs";
public static final String KEY_OPEN_HANDLES = "openHandles";
private static final String KEY_SESSION_ID = "sessionId";
private static final String KEY_STEP_NUMBER = "stepNumber";
private static final String KEY_TOTAL_STEPS = "totalSteps";
private static final String KEY_IS_MULTI_STEP = "isMultiStep";

@Field(2)
public Bundle bundle;
Expand Down Expand Up @@ -79,6 +83,42 @@ public DroidGuardResultsRequest setOpenHandles(int openHandles) {
return this;
}

public long getSessionId() {
return bundle.getLong(KEY_SESSION_ID, -1L);
}

public DroidGuardResultsRequest setSessionId(long sessionId) {
bundle.putLong(KEY_SESSION_ID, sessionId);
return this;
}

public int getStepNumber() {
return bundle.getInt(KEY_STEP_NUMBER, 0);
}

public DroidGuardResultsRequest setStepNumber(int stepNumber) {
bundle.putInt(KEY_STEP_NUMBER, stepNumber);
return this;
}

public int getTotalSteps() {
return bundle.getInt(KEY_TOTAL_STEPS, 0);
}

public DroidGuardResultsRequest setTotalSteps(int totalSteps) {
bundle.putInt(KEY_TOTAL_STEPS, totalSteps);
return this;
}

public boolean isMultiStep() {
return bundle.getBoolean(KEY_IS_MULTI_STEP, false);
}

public DroidGuardResultsRequest setMultiStep(boolean isMultiStep) {
bundle.putBoolean(KEY_IS_MULTI_STEP, isMultiStep);
return this;
}

@RequiresApi(api = 21)
public Network getNetworkToUse() {
return bundle.getParcelable(KEY_NETWORK_TO_USE);
Expand Down
Loading