diff --git a/play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md b/play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md new file mode 100644 index 0000000000..337da1d3f5 --- /dev/null +++ b/play-services-droidguard/REMOTE_DROIDGUARD_SETUP.md @@ -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. diff --git a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardHandleImpl.kt b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardHandleImpl.kt index 9dfa0ab9a4..6cf1dfe8a6 100644 --- a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardHandleImpl.kt +++ b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/DroidGuardHandleImpl.kt @@ -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() + private val sessionIdSequence = AtomicLong(System.currentTimeMillis()) + + data class MultiStepSession( + val flow: String?, + val request: DroidGuardResultsRequest?, + var currentStep: Int = 0, + var initialData: MutableMap = mutableMapOf(), + var pendingStepData: MutableMap> = mutableMapOf() + ) override fun init(flow: String?) { Log.d(TAG, "init($flow)") @@ -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) { @@ -84,6 +97,10 @@ class DroidGuardHandleImpl(private val context: Context, private val packageName override fun snapshot(map: MutableMap): ByteArray { Log.d(TAG, "snapshot($map)") + return snapshotWithFlow(map, flow) + } + + private fun snapshotWithFlow(map: MutableMap, 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()) @@ -98,6 +115,68 @@ class DroidGuardHandleImpl(private val context: Context, private val packageName } } + override fun begin(flow: String?, request: DroidGuardResultsRequest?, initialData: Map?): 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?): 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): 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() + 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() @@ -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 } 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..5e47fe8afd 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,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() + private val sessionIdSequence = AtomicLong(System.currentTimeMillis()) + + data class MultiStepSession( + val flow: String?, + val request: DroidGuardResultsRequest, + var currentStep: Int = 0, + val initialData: MutableMap = mutableMapOf(), + val pendingStepData: MutableMap> = mutableMapOf() + ) + private val url: String get() = DroidGuardPreferences.getNetworkServerUrl(context) ?: throw IllegalStateException("Network URL required") @@ -30,13 +42,70 @@ class RemoteHandleImpl(private val context: Context, private val packageName: St override fun snapshot(map: Map?): ByteArray { Log.d(TAG, "snapshot($map)") + return doSnapshot(flow, request, map.orEmpty()) + } + + override fun begin(flow: String?, request: DroidGuardResultsRequest?, initialData: Map?): 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?): 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): ByteArray { + Log.d(TAG, "snapshotWithSession($sessionId, $map)") + val session = sessions.remove(sessionId) ?: return byteArrayOf() + val request = session.request + val combinedMap = mutableMapOf() + 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): 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" @@ -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 } @@ -59,4 +129,4 @@ class RemoteHandleImpl(private val context: Context, private val packageName: St this.request = request return null } -} \ No newline at end of file +} diff --git a/play-services-droidguard/src/main/aidl/com/google/android/gms/droidguard/internal/IDroidGuardHandle.aidl b/play-services-droidguard/src/main/aidl/com/google/android/gms/droidguard/internal/IDroidGuardHandle.aidl index b47260ca93..86ef890b8e 100644 --- a/play-services-droidguard/src/main/aidl/com/google/android/gms/droidguard/internal/IDroidGuardHandle.aidl +++ b/play-services-droidguard/src/main/aidl/com/google/android/gms/droidguard/internal/IDroidGuardHandle.aidl @@ -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; } diff --git a/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/DroidGuardHandle.java b/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/DroidGuardHandle.java index d9e6d69fe7..7bf62bd902 100644 --- a/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/DroidGuardHandle.java +++ b/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/DroidGuardHandle.java @@ -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 data); + long begin(String flow, DroidGuardResultsRequest request, Map initialData); + + DroidGuardInitReply nextStep(long sessionId, Map stepData); + + String snapshotWithSession(long sessionId, Map data); + + void closeSession(long sessionId); + boolean isOpened(); void close(); diff --git a/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardResultsRequest.java b/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardResultsRequest.java index d2f0948b5e..0693ef26e3 100644 --- a/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardResultsRequest.java +++ b/play-services-droidguard/src/main/java/com/google/android/gms/droidguard/internal/DroidGuardResultsRequest.java @@ -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; @@ -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); diff --git a/play-services-droidguard/src/main/java/org/microg/gms/droidguard/DroidGuardHandleImpl.java b/play-services-droidguard/src/main/java/org/microg/gms/droidguard/DroidGuardHandleImpl.java index 1a80794f87..97530f4989 100644 --- a/play-services-droidguard/src/main/java/org/microg/gms/droidguard/DroidGuardHandleImpl.java +++ b/play-services-droidguard/src/main/java/org/microg/gms/droidguard/DroidGuardHandleImpl.java @@ -8,6 +8,7 @@ import android.util.Log; import com.google.android.gms.droidguard.DroidGuardHandle; +import com.google.android.gms.droidguard.internal.DroidGuardInitReply; import com.google.android.gms.droidguard.internal.DroidGuardResultsRequest; import com.google.android.gms.droidguard.internal.IDroidGuardHandle; @@ -21,6 +22,7 @@ public class DroidGuardHandleImpl implements DroidGuardHandle { private final DroidGuardResultsRequest request; private IDroidGuardHandle handle; private byte[] error; + private long currentSessionId = -1; public DroidGuardHandleImpl(DroidGuardApiClient apiClient, DroidGuardResultsRequest request, IDroidGuardHandle handle) { this.apiClient = apiClient; @@ -67,6 +69,110 @@ public String snapshot(Map data) { return Utils.toBase64(result); } + @Override + public long begin(String flow, DroidGuardResultsRequest request, Map initialData) { + if (error != null || handle == null) { + return -1; + } + ArrayBlockingQueue resultQueue = new ArrayBlockingQueue<>(1); + apiClient.runOnHandler(() -> { + long sessionId; + try { + sessionId = handle.begin(flow, request != null ? request : this.request, initialData); + if (sessionId > 0) { + currentSessionId = sessionId; + } + resultQueue.offer(sessionId); + } catch (Exception e) { + error = Utils.getErrorBytes("Begin multi-step failed: " + e); + resultQueue.offer(-1L); + } + }); + try { + Long sessionId = resultQueue.poll(this.request.getTimeoutMillis(), TimeUnit.MILLISECONDS); + return sessionId != null ? sessionId : -1L; + } catch (InterruptedException e) { + error = Utils.getErrorBytes("Begin multi-step timeout: " + this.request.getTimeoutMillis() + " ms"); + return -1; + } + } + + @Override + public DroidGuardInitReply nextStep(long sessionId, Map stepData) { + if (error != null || handle == null || sessionId != currentSessionId) { + return null; + } + ArrayBlockingQueue resultQueue = new ArrayBlockingQueue<>(1); + apiClient.runOnHandler(() -> { + try { + resultQueue.offer(handle.nextStep(sessionId, stepData)); + } catch (Exception e) { + error = Utils.getErrorBytes("Next step failed: " + e); + resultQueue.offer(null); + } + }); + try { + return resultQueue.poll(this.request.getTimeoutMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + error = Utils.getErrorBytes("Next step timeout: " + this.request.getTimeoutMillis() + " ms"); + return null; + } + } + + @Override + public String snapshotWithSession(long sessionId, Map data) { + byte[] result; + if (error != null || handle == null || sessionId != currentSessionId) { + result = Utils.getErrorBytes("Invalid session"); + } else { + ArrayBlockingQueue resultQueue = new ArrayBlockingQueue<>(1); + apiClient.runOnHandler(() -> { + byte[] innerResult; + try { + innerResult = handle.snapshotWithSession(sessionId, data); + if (innerResult == null) { + error = Utils.getErrorBytes("Received null"); + innerResult = error; + } + } catch (Exception e) { + error = Utils.getErrorBytes("Snapshot with session failed: " + e); + innerResult = error; + } finally { + if (sessionId == currentSessionId) { + currentSessionId = -1; + } + } + resultQueue.offer(innerResult); + }); + try { + result = resultQueue.poll(this.request.getTimeoutMillis(), TimeUnit.MILLISECONDS); + if (result == null) { + result = Utils.getErrorBytes("Snapshot with session timeout: " + this.request.getTimeoutMillis() + " ms"); + } + } catch (InterruptedException e) { + result = Utils.getErrorBytes("Results transfer failed: " + e); + } + } + return Utils.toBase64(result); + } + + @Override + public void closeSession(long sessionId) { + if (handle == null || sessionId != currentSessionId) { + return; + } + apiClient.runOnHandler(() -> { + try { + handle.closeSession(sessionId); + if (sessionId == currentSessionId) { + currentSessionId = -1; + } + } catch (Exception e) { + Log.w(TAG, "Error while closing session: " + e); + } + }); + } + @Override public boolean isOpened() { return handle != null && error == null && handle.asBinder().pingBinder(); @@ -81,6 +187,9 @@ public void close() { } catch (Exception e) { Log.w(TAG, "Error while closing handle."); } + if (currentSessionId > 0) { + closeSession(currentSessionId); + } apiClient.markHandleClosed(); handle = null; }