Repo Created

This commit is contained in:
Fr4nz D13trich 2025-11-15 17:44:12 +01:00
parent eb305e2886
commit a8c22c65db
4784 changed files with 329907 additions and 2 deletions

View file

@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2021 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
apply plugin: 'com.android.library'
apply plugin: 'com.squareup.wire'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
apply plugin: 'signing'
dependencies {
api project(':play-services-safetynet')
implementation project(':play-services-base-core')
implementation project(':play-services-droidguard')
implementation project(':play-services-droidguard-core')
implementation project(':play-services-tasks-ktx')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "androidx.webkit:webkit:$webkitVersion"
implementation "com.android.volley:volley:$volleyVersion"
implementation "com.squareup.wire:wire-runtime:$wireVersion"
}
wire {
kotlin {
javaInterop = true
}
}
android {
namespace "org.microg.gms.safetynet.core"
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
defaultConfig {
versionName version
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
lintOptions {
disable 'MissingTranslation'
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = 1.8
}
}
apply from: '../../gradle/publish-android.gradle'
description = 'microG service implementation for play-services-safetynet'

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2021 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service android:name="org.microg.gms.safetynet.SafetyNetClientService">
<intent-filter>
<action android:name="com.google.android.gms.safetynet.service.START" />
</intent-filter>
</service>
<activity
android:name="org.microg.gms.safetynet.ReCaptchaActivity"
android:autoRemoveFromRecents="true"
android:icon="@drawable/ic_recaptcha"
android:process=":ui"
android:exported="false"
android:theme="@style/Theme.AppCompat.Light.Dialog.NoActionBar">
<intent-filter>
<action android:name="org.microg.gms.safetynet.RECAPTCHA_ACTIVITY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,206 @@
/*
* SPDX-FileCopyrightText: 2021 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.safetynet;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.util.Base64;
import android.util.Log;
import org.microg.gms.common.Constants;
import org.microg.gms.common.PackageUtils;
import org.microg.gms.common.Utils;
import org.microg.gms.profile.Build;
import org.microg.gms.profile.ProfileManager;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.zip.GZIPInputStream;
import okio.ByteString;
public class Attestation {
private static final String TAG = "GmsSafetyNetAttest";
private Context context;
private String packageName;
private byte[] payload;
private String droidGuardResult;
public Attestation(Context context, String packageName) {
this.context = context;
this.packageName = packageName;
}
public void setPayload(byte[] payload) {
this.payload = payload;
}
public SafetyNetData buildPayload(byte[] nonce) {
this.droidGuardResult = null;
SafetyNetData payload = new SafetyNetData.Builder()
.nonce(ByteString.of(nonce))
.currentTimeMs(System.currentTimeMillis())
.packageName(packageName)
.fileDigest(getPackageFileDigest())
.signatureDigest(getPackageSignatures())
.gmsVersionCode(Constants.GMS_VERSION_CODE)
//.googleCn(false)
.seLinuxState(new SELinuxState.Builder().enabled(true).supported(true).build())
.suCandidates(Collections.<FileState>emptyList())
.build();
Log.d(TAG, "Payload: "+payload.toString());
this.payload = payload.encode();
return payload;
}
public byte[] getPayload() {
return payload;
}
public String getPayloadHashBase64() {
try {
MessageDigest digest = getSha256Digest();
return Base64.encodeToString(digest.digest(payload), Base64.NO_WRAP);
} catch (NoSuchAlgorithmException e) {
Log.w(TAG, e);
return null;
}
}
private static MessageDigest getSha256Digest() throws NoSuchAlgorithmException {
return MessageDigest.getInstance("SHA-256");
}
public void setDroidGuardResult(String droidGuardResult) {
this.droidGuardResult = droidGuardResult;
}
private ByteString getPackageFileDigest() {
try {
return ByteString.of(getPackageFileDigest(context, packageName));
} catch (Exception e) {
Log.w(TAG, e);
return null;
}
}
public static byte[] getPackageFileDigest(Context context, String packageName) throws Exception {
FileInputStream is = new FileInputStream(new File(context.getPackageManager().getApplicationInfo(packageName, 0).sourceDir));
MessageDigest digest = getSha256Digest();
byte[] data = new byte[4096];
while (true) {
int read = is.read(data);
if (read < 0) break;
digest.update(data, 0, read);
}
is.close();
return digest.digest();
}
@SuppressLint("PackageManagerGetSignatures")
private List<ByteString> getPackageSignatures() {
try {
ArrayList<ByteString> res = new ArrayList<>();
for (byte[] bytes : getPackageSignatures(context, packageName)) {
res.add(ByteString.of(bytes));
}
return res;
} catch (Exception e) {
Log.w(TAG, e);
return null;
}
}
public static byte[][] getPackageSignatures(Context context, String packageName) throws Exception {
PackageInfo pi = context.getPackageManager().getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
ArrayList<byte[]> res = new ArrayList<>();
MessageDigest digest = getSha256Digest();
for (Signature signature : pi.signatures) {
res.add(digest.digest(signature.toByteArray()));
}
return res.toArray(new byte[][]{});
}
public String attest(String apiKey) throws IOException {
if (payload == null) {
throw new IllegalStateException("missing payload");
}
return attest(new AttestRequest.Builder().safetyNetData(ByteString.of(payload)).droidGuardResult(droidGuardResult).build(), apiKey).result;
}
private AttestResponse attest(AttestRequest request, String apiKey) throws IOException {
ProfileManager.ensureInitialized(context);
String requestUrl = "https://www.googleapis.com/androidcheck/v1/attestations/attest?alt=PROTO&key=" + apiKey;
HttpURLConnection connection = (HttpURLConnection) new URL(requestUrl).openConnection();
connection.setRequestMethod("POST");
connection.setDoInput(true);
connection.setDoOutput(true);
connection.setRequestProperty("content-type", "application/x-protobuf");
connection.setRequestProperty("Accept-Encoding", "gzip");
connection.setRequestProperty("X-Android-Package", packageName);
connection.setRequestProperty("X-Android-Cert", PackageUtils.firstSignatureDigest(context, packageName));
connection.setRequestProperty("User-Agent", "SafetyNet/" + Constants.GMS_VERSION_CODE + " (" + Build.DEVICE + " " + Build.ID + "); gzip");
OutputStream os = connection.getOutputStream();
os.write(request.encode());
os.close();
if (connection.getResponseCode() != 200) {
byte[] bytes = null;
String ex = null;
try {
bytes = Utils.readStreamToEnd(connection.getErrorStream());
ex = new String(Utils.readStreamToEnd(new GZIPInputStream(new ByteArrayInputStream(bytes))));
} catch (Exception e) {
if (bytes != null) {
throw new IOException(getBytesAsString(bytes), e);
}
throw new IOException(connection.getResponseMessage(), e);
}
throw new IOException(ex);
}
InputStream is = connection.getInputStream();
byte[] bytes = Utils.readStreamToEnd(new GZIPInputStream(is));
try {
return AttestResponse.ADAPTER.decode(bytes);
} catch (IOException e) {
Log.d(TAG, Base64.encodeToString(bytes, 0));
throw e;
}
}
private String getBytesAsString(byte[] bytes) {
if (bytes == null) return "null";
try {
CharsetDecoder d = Charset.forName("US-ASCII").newDecoder();
CharBuffer r = d.decode(ByteBuffer.wrap(bytes));
return r.toString();
} catch (Exception e) {
return Base64.encodeToString(bytes, Base64.NO_WRAP);
}
}
}

View file

@ -0,0 +1,240 @@
/*
* SPDX-FileCopyrightText: 2021 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.safetynet
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.net.http.SslCertificate
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.os.ResultReceiver
import android.util.Base64
import android.util.Log
import android.view.View
import android.view.Window
import android.webkit.*
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebViewClientCompat
import com.google.android.gms.droidguard.DroidGuardClient
import com.google.android.gms.safetynet.SafetyNetStatusCodes.*
import com.google.android.gms.tasks.await
import org.microg.gms.profile.Build
import org.microg.gms.profile.ProfileManager
import org.microg.gms.safetynet.core.R
import java.io.ByteArrayInputStream
import java.net.URLEncoder
import java.security.MessageDigest
import kotlin.math.min
private const val TAG = "GmsReCAPTCHA"
private fun StringBuilder.appendUrlEncodedParam(key: String, value: String?) = append("&")
.append(URLEncoder.encode(key, "UTF-8"))
.append("=")
.append(value?.let { URLEncoder.encode(it, "UTF-8") } ?: "")
class ReCaptchaActivity : AppCompatActivity() {
private val receiver: ResultReceiver?
get() = intent?.getParcelableExtra("result") as ResultReceiver?
private val params: String?
get() = intent?.getStringExtra("params")
private val webView: WebView?
get() = findViewById(R.id.recaptcha_webview)
private val loading: View?
get() = findViewById(R.id.recaptcha_loading)
private val density: Float
get() = resources.displayMetrics.density
private val widthPixels: Int
get() = resources.displayMetrics.widthPixels
private val heightPixels: Int
get() {
val base = resources.displayMetrics.heightPixels
val statusBarHeightId = resources.getIdentifier("status_bar_height", "dimen", "android")
val statusBarHeight = if (statusBarHeightId > 0) resources.getDimensionPixelSize(statusBarHeightId) else 0
return base - statusBarHeight - (density * 20.0).toInt()
}
private var resultSent: Boolean = false
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (receiver == null || params == null) {
finish()
return
}
requestWindowFeature(Window.FEATURE_NO_TITLE)
setContentView(R.layout.recaptcha_window)
webView?.apply {
webViewClient = object : WebViewClientCompat() {
fun String.isRecaptchaUrl() = startsWith("https://www.gstatic.com/recaptcha/") || startsWith("https://www.google.com/recaptcha/") || startsWith("https://www.google.com/js/bg/")
override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
if (url.isRecaptchaUrl()) {
return null
}
return WebResourceResponse("text/plain", "UTF-8", ByteArrayInputStream(byteArrayOf()))
}
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith("https://support.google.com/recaptcha")) {
startActivity(Intent("android.intent.action.VIEW", Uri.parse(url)))
finish()
return true
}
return !url.isRecaptchaUrl()
}
}
settings.apply {
javaScriptEnabled = true
useWideViewPort = true
displayZoomControls = false
setSupportZoom(false)
cacheMode = WebSettings.LOAD_NO_CACHE
ProfileManager.ensureInitialized(this@ReCaptchaActivity)
userAgentString = Build.generateWebViewUserAgentString(userAgentString)
}
addJavascriptInterface(ReCaptchaEmbedder(this@ReCaptchaActivity), "RecaptchaEmbedder")
}
lifecycleScope.launchWhenResumed {
open()
}
}
fun sendErrorResult(errorCode: Int, error: String) = sendResult(errorCode) { putString("error", error); putInt("errorCode", errorCode) }
fun sendResult(resultCode: Int, v: Bundle.() -> Unit) {
receiver?.send(resultCode, Bundle().also(v))
resultSent = true
}
override fun finish() {
lifecycleScope.launchWhenResumed {
webView?.loadUrl("javascript: RecaptchaMFrame.shown(0, 0, false);")
}
if (!resultSent) {
sendErrorResult(CANCELED, "Cancelled")
}
super.finish()
}
fun setWebViewSize(width: Int, height: Int, visible: Boolean) {
webView?.apply {
layoutParams.width = min(widthPixels, (width * density).toInt())
layoutParams.height = min(heightPixels, (height * density).toInt())
requestLayout()
loadUrl("javascript: RecaptchaMFrame.shown(${(layoutParams.width / density).toInt()}, ${(layoutParams.height / density).toInt()}, $visible);")
}
}
suspend fun updateToken(flow: String, params: String) {
val map = mapOf("contentBinding" to Base64.encodeToString(MessageDigest.getInstance("SHA-256").digest(params.toByteArray()), Base64.NO_WRAP))
val dg = try {
DroidGuardClient.getResults(this, flow, map).await()
} catch (e: Exception) {
Log.w(TAG, e)
Base64.encodeToString("ERROR : IOException".toByteArray(), Base64.NO_WRAP + Base64.URL_SAFE + Base64.NO_PADDING)
}
if (SDK_INT >= 19) {
webView?.evaluateJavascript("RecaptchaMFrame.token('${URLEncoder.encode(dg, "UTF-8")}', '$params');", null)
} else {
webView?.loadUrl("javascript: RecaptchaMFrame.token('${URLEncoder.encode(dg, "UTF-8")}', '$params');")
}
}
suspend fun open() {
val params = StringBuilder(params).appendUrlEncodedParam("mt", System.currentTimeMillis().toString()).toString()
val map = mapOf("contentBinding" to Base64.encodeToString(MessageDigest.getInstance("SHA-256").digest(params.toByteArray()), Base64.NO_WRAP))
val dg = try {
DroidGuardClient.getResults(this, "recaptcha-android-frame", map).await()
} catch (e: Exception) {
Log.w(TAG, e)
Base64.encodeToString("ERROR : IOException".toByteArray(), Base64.NO_WRAP + Base64.URL_SAFE + Base64.NO_PADDING)
}
webView?.postUrl(MFRAME_URL, "mav=1&dg=${URLEncoder.encode(dg, "UTF-8")}&mp=${URLEncoder.encode(params, "UTF-8")}".toByteArray())
}
companion object {
private const val MFRAME_URL = "https://www.google.com/recaptcha/api2/mframe"
class ReCaptchaEmbedder(val activity: ReCaptchaActivity) {
@JavascriptInterface
fun challengeReady() {
Log.d(TAG, "challengeReady()")
activity.runOnUiThread { activity.webView?.loadUrl("javascript: RecaptchaMFrame.show(${min(activity.widthPixels / activity.density, 400f)}, ${min(activity.heightPixels / activity.density, 400f)});") }
}
@JavascriptInterface
fun getClientAPIVersion() = 1
@JavascriptInterface
fun onChallengeExpired() {
Log.d(TAG, "onChallengeExpired()")
}
@JavascriptInterface
fun onError(errorCode: Int, finish: Boolean) {
Log.d(TAG, "onError($errorCode, $finish)")
when (errorCode) {
1 -> activity.sendErrorResult(ERROR, "Invalid Input Argument")
2 -> activity.sendErrorResult(TIMEOUT, "Session Timeout")
7 -> activity.sendErrorResult(RECAPTCHA_INVALID_SITEKEY, "Invalid Site Key")
8 -> activity.sendErrorResult(RECAPTCHA_INVALID_KEYTYPE, "Invalid Type of Site Key")
9 -> activity.sendErrorResult(RECAPTCHA_INVALID_PACKAGE_NAME, "Invalid Package Name for App")
else -> activity.sendErrorResult(ERROR, "error")
}
if (finish) activity.finish()
}
@JavascriptInterface
fun onResize(width: Int, height: Int) {
Log.d(TAG, "onResize($width, $height)")
if (activity.webView?.visibility == View.VISIBLE) {
activity.runOnUiThread { activity.setWebViewSize(width, height, true) }
} else {
activity.runOnUiThread { activity.webView?.loadUrl("javascript: RecaptchaMFrame.shown($width, $height, true);") }
}
}
@JavascriptInterface
fun onShow(visible: Boolean, width: Int, height: Int) {
Log.d(TAG, "onShow($visible, $width, $height)")
if (width <= 0 && height <= 0) {
activity.runOnUiThread { activity.webView?.loadUrl("javascript: RecaptchaMFrame.shown($width, $height, $visible);") }
} else {
activity.runOnUiThread {
activity.setWebViewSize(width, height, visible)
activity.loading?.visibility = if (visible) View.GONE else View.VISIBLE
activity.webView?.visibility = if (visible) View.VISIBLE else View.GONE
}
}
}
@JavascriptInterface
fun requestToken(s: String, b: Boolean) {
Log.d(TAG, "requestToken($s, $b)")
activity.runOnUiThread {
val cert = activity.webView?.certificate?.let { Base64.encodeToString(SslCertificate.saveState(it).getByteArray("x509-certificate"), Base64.URL_SAFE + Base64.NO_PADDING + Base64.NO_WRAP) }
?: ""
val params = StringBuilder(activity.params).appendUrlEncodedParam("c", s).appendUrlEncodedParam("sc", cert).appendUrlEncodedParam("mt", System.currentTimeMillis().toString()).toString()
val flow = "recaptcha-android-${if (b) "verify" else "reload"}"
activity.lifecycleScope.launchWhenResumed {
activity.updateToken(flow, params)
}
}
}
@JavascriptInterface
fun verifyCallback(token: String) {
Log.d(TAG, "verifyCallback($token)")
activity.sendResult(0) { putString("token", token) }
activity.resultSent = true
activity.finish()
}
}
}
}

View file

@ -0,0 +1,277 @@
/*
* SPDX-FileCopyrightText: 2021 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.safetynet
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.os.Parcel
import android.os.ResultReceiver
import android.util.Base64
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.api.Status
import com.google.android.gms.common.internal.GetServiceRequest
import com.google.android.gms.common.internal.IGmsCallbacks
import com.google.android.gms.droidguard.DroidGuardClient
import com.google.android.gms.safetynet.AttestationData
import com.google.android.gms.safetynet.HarmfulAppsInfo
import com.google.android.gms.safetynet.RecaptchaResultData
import com.google.android.gms.safetynet.SafeBrowsingData
import com.google.android.gms.safetynet.SafetyNetStatusCodes
import com.google.android.gms.safetynet.internal.ISafetyNetCallbacks
import com.google.android.gms.safetynet.internal.ISafetyNetService
import com.google.android.gms.tasks.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.BaseService
import org.microg.gms.common.GmsService
import org.microg.gms.common.GooglePackagePermission
import org.microg.gms.common.PackageUtils
import org.microg.gms.droidguard.core.DroidGuardPreferences
import org.microg.gms.settings.SettingsContract
import org.microg.gms.settings.SettingsContract.CheckIn.getContentUri
import org.microg.gms.settings.SettingsContract.getSettings
import org.microg.gms.utils.warnOnTransactionIssues
import java.io.IOException
import java.net.URLEncoder
private const val TAG = "GmsSafetyNet"
private const val DEFAULT_API_KEY = "AIzaSyDqVnJBjE5ymo--oBJt3On7HQx9xNm1RHA"
class SafetyNetClientService : BaseService(TAG, GmsService.SAFETY_NET_CLIENT) {
override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) {
callback.onPostInitComplete(0, SafetyNetClientServiceImpl(this, request.packageName, lifecycle), null)
}
}
private fun StringBuilder.appendUrlEncodedParam(key: String, value: String?) = append("&")
.append(URLEncoder.encode(key, "UTF-8"))
.append("=")
.append(value?.let { URLEncoder.encode(it, "UTF-8") } ?: "")
class SafetyNetClientServiceImpl(
private val context: Context,
private val packageName: String,
override val lifecycle: Lifecycle
) : ISafetyNetService.Stub(), LifecycleOwner {
override fun attest(callbacks: ISafetyNetCallbacks, nonce: ByteArray) {
attestWithApiKey(callbacks, nonce, DEFAULT_API_KEY)
}
override fun attestWithApiKey(callbacks: ISafetyNetCallbacks, nonce: ByteArray?, apiKey: String) {
if (nonce == null) {
callbacks.onAttestationResult(Status(SafetyNetStatusCodes.DEVELOPER_ERROR, "Nonce missing"), null)
return
}
if (!SafetyNetPreferences.isEnabled(context)) {
Log.d(TAG, "ignoring SafetyNet request, SafetyNet is disabled")
callbacks.onAttestationResult(Status(SafetyNetStatusCodes.ERROR, "Disabled"), null)
return
}
if (!DroidGuardPreferences.isAvailable(context)) {
Log.d(TAG, "ignoring SafetyNet request, DroidGuard is disabled")
callbacks.onAttestationResult(Status(SafetyNetStatusCodes.ERROR, "Unsupported"), null)
return
}
lifecycleScope.launchWhenStarted {
val db = SafetyNetDatabase(context)
var requestID: Long = -1
try {
val attestation = Attestation(context, packageName)
val safetyNetData = attestation.buildPayload(nonce)
requestID = db.insertRecentRequestStart(
SafetyNetRequestType.ATTESTATION,
safetyNetData.packageName,
safetyNetData.nonce?.toByteArray(),
safetyNetData.currentTimeMs ?: 0
)
val data = mapOf("contentBinding" to attestation.payloadHashBase64)
val dg = withContext(Dispatchers.IO) { DroidGuardClient.getResults(context, "attest", data).await() }
attestation.setDroidGuardResult(dg)
val jwsResult = withContext(Dispatchers.IO) { attestation.attest(apiKey) }
val jsonData = try {
requireNotNull(jwsResult)
jwsResult.split(".").let {
assert(it.size == 3)
return@let Base64.decode(it[1], Base64.URL_SAFE).decodeToString()
}
} catch (e: Exception) {
e.printStackTrace()
Log.w(TAG, "An exception occurred when parsing the JWS token.")
null
}
db.insertRecentRequestEnd(requestID, Status.SUCCESS, jsonData)
callbacks.onAttestationResult(Status.SUCCESS, AttestationData(jwsResult))
} catch (e: Exception) {
Log.w(TAG, "Exception during attest: ${e.javaClass.name}", e)
val code = when (e) {
is IOException -> SafetyNetStatusCodes.NETWORK_ERROR
else -> SafetyNetStatusCodes.ERROR
}
val status = Status(code, e.localizedMessage)
// This shouldn't happen, but do not update the database if it didn't insert the start of the request
if (requestID != -1L) db.insertRecentRequestEnd(requestID, status, null)
try {
callbacks.onAttestationResult(Status(code, e.localizedMessage), null)
} catch (e: Exception) {
Log.w(TAG, "Exception while sending error", e)
}
}
db.close()
}
}
override fun getSharedUuid(callbacks: ISafetyNetCallbacks) {
PackageUtils.checkPackageUid(context, packageName, getCallingUid())
PackageUtils.assertGooglePackagePermission(context, GooglePackagePermission.SAFETYNET)
// TODO
Log.d(TAG, "dummy Method: getSharedUuid")
callbacks.onSharedUuid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
}
override fun lookupUri(callbacks: ISafetyNetCallbacks, apiKey: String, threatTypes: IntArray, i: Int, s2: String) {
Log.d(TAG, "unimplemented Method: lookupUri")
callbacks.onSafeBrowsingData(Status.SUCCESS, SafeBrowsingData())
}
override fun enableVerifyApps(callbacks: ISafetyNetCallbacks) {
Log.d(TAG, "dummy Method: enableVerifyApps")
callbacks.onVerifyAppsUserResult(Status.SUCCESS, true)
}
override fun listHarmfulApps(callbacks: ISafetyNetCallbacks) {
Log.d(TAG, "dummy Method: listHarmfulApps")
callbacks.onHarmfulAppsInfo(Status.SUCCESS, HarmfulAppsInfo().apply {
lastScanTime = ((System.currentTimeMillis() - VERIFY_APPS_LAST_SCAN_DELAY) / VERIFY_APPS_LAST_SCAN_TIME_ROUNDING) * VERIFY_APPS_LAST_SCAN_TIME_ROUNDING + VERIFY_APPS_LAST_SCAN_OFFSET
})
}
override fun verifyWithRecaptcha(callbacks: ISafetyNetCallbacks, siteKey: String?) {
if (siteKey == null) {
callbacks.onRecaptchaResult(Status(SafetyNetStatusCodes.RECAPTCHA_INVALID_SITEKEY, "SiteKey missing"), null)
return
}
if (!SafetyNetPreferences.isEnabled(context)) {
Log.d(TAG, "ignoring SafetyNet request, SafetyNet is disabled")
callbacks.onRecaptchaResult(Status(SafetyNetStatusCodes.ERROR, "Disabled"), null)
return
}
val db = SafetyNetDatabase(context)
val requestID = db.insertRecentRequestStart(
SafetyNetRequestType.RECAPTCHA,
context.packageName,
null,
System.currentTimeMillis()
)
val intent = Intent("org.microg.gms.safetynet.RECAPTCHA_ACTIVITY")
intent.`package` = context.packageName
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
val androidId = getSettings(
context,
getContentUri(context),
arrayOf(SettingsContract.CheckIn.ANDROID_ID)
) { cursor: Cursor -> cursor.getLong(0) }
val params = StringBuilder()
val (packageFileDigest, packageSignatures) = try {
Pair(
Base64.encodeToString(
Attestation.getPackageFileDigest(context, packageName),
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
),
Attestation.getPackageSignatures(context, packageName)
.map { Base64.encodeToString(it, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) }
)
} catch (e: Exception) {
db.insertRecentRequestEnd(requestID, Status(SafetyNetStatusCodes.ERROR, e.localizedMessage), null)
db.close()
callbacks.onRecaptchaResult(Status(SafetyNetStatusCodes.ERROR, e.localizedMessage), null)
return
}
params.appendUrlEncodedParam("k", siteKey)
.appendUrlEncodedParam("di", androidId.toString())
.appendUrlEncodedParam("pk", packageName)
.appendUrlEncodedParam("sv", SDK_INT.toString())
.appendUrlEncodedParam("gv", "20.47.14 (040306-{{cl}})")
.appendUrlEncodedParam("gm", "260")
.appendUrlEncodedParam("as", packageFileDigest)
for (signature in packageSignatures) {
Log.d(TAG, "Sig: $signature")
params.appendUrlEncodedParam("ac", signature)
}
params.appendUrlEncodedParam("ip", "com.android.vending")
.appendUrlEncodedParam("av", false.toString())
.appendUrlEncodedParam("si", null)
intent.putExtra("params", params.toString())
intent.putExtra("result", object : ResultReceiver(null) {
override fun onReceiveResult(resultCode: Int, resultData: Bundle) {
if (resultCode != 0) {
db.insertRecentRequestEnd(
requestID,
Status(resultData.getInt("errorCode"), resultData.getString("error")),
null
)
db.close()
callbacks.onRecaptchaResult(
Status(resultData.getInt("errorCode"), resultData.getString("error")),
null
)
} else {
db.insertRecentRequestEnd(requestID, Status.SUCCESS, resultData.getString("token"))
db.close()
callbacks.onRecaptchaResult(
Status.SUCCESS,
RecaptchaResultData().apply { token = resultData.getString("token") })
}
}
})
context.startActivity(intent)
}
override fun initSafeBrowsing(callbacks: ISafetyNetCallbacks) {
Log.d(TAG, "dummy: initSafeBrowsing")
callbacks.onInitSafeBrowsingResult(Status.SUCCESS)
}
override fun shutdownSafeBrowsing() {
Log.d(TAG, "dummy: shutdownSafeBrowsing")
}
override fun isVerifyAppsEnabled(callbacks: ISafetyNetCallbacks) {
Log.d(TAG, "dummy: isVerifyAppsEnabled")
callbacks.onVerifyAppsUserResult(Status.SUCCESS, true)
}
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) }
companion object {
// We simulate one scan every day, which will happen at 03:12:02.121 and will be available 32 seconds later
const val VERIFY_APPS_LAST_SCAN_DELAY = 32 * 1000L
const val VERIFY_APPS_LAST_SCAN_OFFSET = ((3 * 60 + 12) * 60 + 2) * 1000L + 121
const val VERIFY_APPS_LAST_SCAN_TIME_ROUNDING = 24 * 60 * 60 * 1000L
}
}

View file

@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: 2016, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.safetynet
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import com.google.android.gms.common.api.Status
class SafetyNetDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
init {
if (SDK_INT >= 16) {
setWriteAheadLoggingEnabled(true)
}
}
override fun onOpen(db: SQLiteDatabase) {
if (!db.isReadOnly) clearOldRequests(db)
}
private fun createSafetyNetSummary(cursor: Cursor): SafetyNetSummary {
val summary = SafetyNetSummary(
SafetyNetRequestType.valueOf(
cursor.getString(cursor.getColumnIndexOrThrow(FIELD_REQUEST_TYPE))
),
cursor.getString(cursor.getColumnIndexOrThrow(FIELD_PACKAGE_NAME)),
cursor.getBlob(cursor.getColumnIndexOrThrow(FIELD_NONCE)),
cursor.getLong(cursor.getColumnIndexOrThrow(FIELD_TIMESTAMP))
)
summary.id = cursor.getInt(cursor.getColumnIndexOrThrow(FIELD_ID))
if (cursor.isNull(cursor.getColumnIndexOrThrow(FIELD_RESULT_STATUS_CODE))) return summary
summary.responseStatus = Status(
cursor.getInt(cursor.getColumnIndexOrThrow(FIELD_RESULT_STATUS_CODE)),
cursor.getString(cursor.getColumnIndexOrThrow(FIELD_RESULT_STATUS_MSG))
)
summary.responseData = cursor.getString(cursor.getColumnIndexOrThrow(FIELD_RESULT_DATA))
return summary
}
val recentApps: List<Pair<String, Long>>
get() {
val db = readableDatabase
val cursor = db.query(TABLE_RECENTS, arrayOf(FIELD_PACKAGE_NAME, "MAX($FIELD_TIMESTAMP)"), null, null, FIELD_PACKAGE_NAME, null, "MAX($FIELD_TIMESTAMP) DESC")
if (cursor != null) {
val result = ArrayList<Pair<String, Long>>()
while (cursor.moveToNext()) {
result.add(cursor.getString(0) to cursor.getLong(1))
}
cursor.close()
return result
}
return emptyList()
}
fun getRecentRequests(packageName: String): List<SafetyNetSummary> {
val db = readableDatabase
val cursor = db.query(TABLE_RECENTS, null, "$FIELD_PACKAGE_NAME = ?", arrayOf(packageName), null, null, "$FIELD_TIMESTAMP DESC")
if (cursor != null) {
val result: MutableList<SafetyNetSummary> = ArrayList()
while (cursor.moveToNext()) {
result.add(createSafetyNetSummary(cursor))
}
cursor.close()
return result
}
return emptyList()
}
fun insertRecentRequestStart(
requestType: SafetyNetRequestType,
packageName: String?,
nonce: ByteArray?,
timestamp: Long
): Long {
val db = writableDatabase
val cv = ContentValues()
cv.put(FIELD_REQUEST_TYPE, requestType.name)
cv.put(FIELD_PACKAGE_NAME, packageName)
cv.put(FIELD_NONCE, nonce)
cv.put(FIELD_TIMESTAMP, timestamp)
return db.insert(TABLE_RECENTS, null, cv)
}
fun insertRecentRequestEnd(id: Long, status: Status, resultData: String?) {
val db = writableDatabase
val cv = ContentValues()
cv.put(FIELD_RESULT_STATUS_CODE, status.statusCode)
cv.put(FIELD_RESULT_STATUS_MSG, status.statusMessage)
cv.put(FIELD_RESULT_DATA, resultData)
db.update(TABLE_RECENTS, cv, "$FIELD_ID = ?", arrayOf(id.toString()))
}
fun clearOldRequests(db: SQLiteDatabase) {
val timeout = 1000 * 60 * 60 * 24 * 14 // 14 days
val maxRequests = 150
var rows = 0
rows += db.compileStatement(
"DELETE FROM $TABLE_RECENTS WHERE $FIELD_ID NOT IN " +
"(SELECT $FIELD_ID FROM $TABLE_RECENTS ORDER BY $FIELD_TIMESTAMP LIMIT $maxRequests)"
).executeUpdateDelete()
val sqLiteStatement = db.compileStatement("DELETE FROM $TABLE_RECENTS WHERE $FIELD_TIMESTAMP + ? < ?")
sqLiteStatement.bindLong(1, timeout.toLong())
sqLiteStatement.bindLong(2, System.currentTimeMillis())
rows += sqLiteStatement.executeUpdateDelete()
if (rows != 0) Log.d(TAG, "Cleared $rows old request(s)")
}
fun clearAllRequests() {
val db = writableDatabase
db.execSQL("DELETE FROM $TABLE_RECENTS")
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_TABLE_RECENTS)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
throw IllegalStateException("Upgrades not supported")
}
companion object {
private val TAG = SafetyNetDatabase::class.java.simpleName
private const val DB_NAME = "snet.db"
private const val DB_VERSION = 1
private const val CREATE_TABLE_RECENTS = "CREATE TABLE recents (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT ," +
"request_type TEXT," +
"package_name TEXT," +
"nonce TEXT," +
"timestamp INTEGER," +
"result_status_code INTEGER DEFAULT NULL," +
"result_status_msg TEXT DEFAULT NULL," +
"result_data TEXT DEFAULT NULL)"
private const val TABLE_RECENTS = "recents"
private const val FIELD_ID = "id"
private const val FIELD_REQUEST_TYPE = "request_type"
private const val FIELD_PACKAGE_NAME = "package_name"
private const val FIELD_NONCE = "nonce"
private const val FIELD_TIMESTAMP = "timestamp"
private const val FIELD_RESULT_STATUS_CODE = "result_status_code"
private const val FIELD_RESULT_STATUS_MSG = "result_status_msg"
private const val FIELD_RESULT_DATA = "result_data"
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2021 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.safetynet
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.microg.gms.settings.SettingsContract
import org.microg.gms.settings.SettingsContract.SafetyNet.ENABLED
object SafetyNetPreferences {
private fun <T> getSettings(context: Context, projection: String, def: T, f: (Cursor) -> T): T {
return try {
SettingsContract.getSettings(context, SettingsContract.SafetyNet.getContentUri(context), arrayOf(projection), f)
} catch (e: Exception) {
def
}
}
private fun setSettings(context: Context, f: ContentValues.() -> Unit) =
SettingsContract.setSettings(context, SettingsContract.SafetyNet.getContentUri(context), f)
@JvmStatic
fun isEnabled(context: Context): Boolean = getSettings(context, ENABLED, false) { it.getInt(0) != 0 }
@JvmStatic
fun setEnabled(context: Context, enabled: Boolean) = setSettings(context) { put(ENABLED, enabled) }
}

View file

@ -0,0 +1,8 @@
package org.microg.gms.safetynet
enum class SafetyNetRequestType {
ATTESTATION,
RECAPTCHA,
RECAPTCHA_ENTERPRISE,
;
}

View file

@ -0,0 +1,87 @@
package org.microg.gms.safetynet
import android.os.Parcel
import android.os.Parcelable
import com.google.android.gms.common.api.Status
import kotlin.properties.Delegates
data class SafetyNetSummary(
val requestType: SafetyNetRequestType,
// request data
val packageName: String,
val nonce: ByteArray?, // null with SafetyNetRequestType::RECAPTCHA
val timestamp: Long,
) : Parcelable {
var id by Delegates.notNull<Int>()
// response data
// note : responseStatus do not represent the actual status in case of an attestation, it will be in resultData
var responseStatus: Status? = null
var responseData: String? = null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SafetyNetSummary
if (requestType != other.requestType) return false
if (packageName != other.packageName) return false
if (!nonce.contentEquals(other.nonce)) return false
if (responseStatus != other.responseStatus) return false
if (responseData != other.responseData) return false
return true
}
override fun hashCode(): Int {
var result = requestType.hashCode()
result = 31 * result + packageName.hashCode()
result = 31 * result + nonce.hashCode()
result = 31 * result + (responseStatus?.hashCode() ?: 0)
result = 31 * result + (responseData?.hashCode() ?: 0)
return result
}
// Parcelable implementation
constructor(parcel: Parcel) : this(
SafetyNetRequestType.valueOf(parcel.readString()!!),
parcel.readString()!!,
parcel.createByteArray(),
parcel.readLong()
) {
responseStatus = parcel.readParcelable(Status::class.java.classLoader)
responseData = parcel.readString()
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(requestType.name)
parcel.writeString(packageName)
parcel.writeByteArray(nonce)
parcel.writeLong(timestamp)
parcel.writeParcelable(responseStatus, flags)
parcel.writeString(responseData)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<SafetyNetSummary> {
override fun createFromParcel(parcel: Parcel): SafetyNetSummary {
return SafetyNetSummary(parcel)
}
override fun newArray(size: Int): Array<SafetyNetSummary?> {
return arrayOfNulls(size)
}
}
}

View file

@ -0,0 +1,34 @@
option java_package = "org.microg.gms.safetynet";
option java_outer_classname = "SafetyNetProto";
message SELinuxState {
optional bool supported = 1;
optional bool enabled = 2;
}
message FileState {
optional string fileName = 1;
optional bytes digest = 2;
}
message SafetyNetData {
optional bytes nonce = 1;
optional string packageName = 2;
repeated bytes signatureDigest = 3;
optional bytes fileDigest = 4;
optional int32 gmsVersionCode = 5;
repeated FileState suCandidates = 6;
optional SELinuxState seLinuxState = 7;
optional int64 currentTimeMs = 8;
optional bool googleCn = 9;
}
message AttestRequest {
optional bytes safetyNetData = 1;
optional string droidGuardResult = 2;
}
message AttestResponse {
optional string result = 2;
}

View file

@ -0,0 +1,64 @@
<!--
~ SPDX-FileCopyrightText: 2021, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:pathData="M2,24L2,40.5L5.9387,36.5616a22,22 45,0 0,9.6423 7.7639,22 22,0 0,0 23.9753,-4.7692L31.071,31.071A10,10 0,0 1,24 34,10 10,135 0,1 14.7361,27.7642L18.5,24L14,24Z"
android:strokeWidth="0"
android:fillColor="#bdbdbd"
android:strokeColor="#000000"/>
<path
android:pathData="M7.5,2 L11.4386,5.9387A22,22 0,0 0,2 24L14,24A10,10 135,0 1,20.236 14.7361L24,18.5L24,14 24,2l-0.0682,0z"
android:strokeWidth="0"
android:strokeColor="#000000">
<aapt:attr name="android:fillColor">
<gradient
android:startY="24"
android:startX="14"
android:endY="20.75"
android:endX="14.000"
android:type="linear">
<item android:offset="0" android:color="#FF1E88E5"/>
<item android:offset="1" android:color="#FF2196F3"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M46,7.5 L42.0615,11.4386A22,22 0,0 0,24 2V14a10,10 0,0 1,9.264 6.2358l-3.7641,3.7641h4.5,12v-0.0682z"
android:strokeWidth="0"
android:strokeColor="#000000">
<aapt:attr name="android:fillColor">
<gradient
android:startY="14"
android:startX="24"
android:endY="14"
android:endX="27.25"
android:type="linear">
<item android:offset="0" android:color="#FF3949AB"/>
<item android:offset="1" android:color="#FF3F51B5"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M46,7.5 L42.0615,11.4386C37.9491,5.5255 31.2026,2 24,2L46,24v-0.0682z"
android:strokeWidth="0">
<aapt:attr name="android:fillColor">
<gradient
android:startY="2"
android:startX="24"
android:endY="24"
android:endX="46"
android:type="linear">
<item android:offset="0" android:color="#18FFFFFF"/>
<item android:offset="1" android:color="#00FFFFFF"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2021, microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/recaptcha_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@android:color/white"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginLeft="20dip"
android:layout_marginTop="20dip"
android:layout_marginRight="20dip"
android:layout_marginBottom="10sp"
android:gravity="center"
android:orientation="horizontal">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_recaptcha" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="10sp"
android:layout_marginLeft="10sp"
android:text="reCAPTCHA"
android:textColor="#FF3949AB"
android:textStyle="bold" />
</LinearLayout>
<ProgressBar
style="?android:progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginLeft="20dip"
android:layout_marginTop="10sp"
android:layout_marginRight="20dip"
android:layout_marginBottom="20dip"
android:indeterminate="true"
android:indeterminateTint="#FF3949AB" />
</LinearLayout>
<WebView
android:id="@+id/recaptcha_webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:visibility="gone" />
</FrameLayout>