diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c066d6ccf..fc9aafbc0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -207,7 +207,7 @@ dependencies { implementation("io.noties.markwon:image:${vMarkwon}") implementation("com.caverock:androidsvg:1.4") - val vLibsu = "3.1.1" + val vLibsu = "3.1.2" implementation("com.github.topjohnwu.libsu:core:${vLibsu}") implementation("com.github.topjohnwu.libsu:io:${vLibsu}") diff --git a/app/src/main/java/com/topjohnwu/magisk/core/Const.kt b/app/src/main/java/com/topjohnwu/magisk/core/Const.kt index d8557e849..97d8b484f 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/Const.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/Const.kt @@ -29,7 +29,7 @@ object Const { const val MAGISK_LOG = "/cache/magisk.log" // Versions - const val SNET_EXT_VER = 15 + const val SNET_EXT_VER = 16 const val SNET_REVISION = "22.0" const val BOOTCTL_REVISION = "22.0" diff --git a/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt b/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt index 391e11e70..45c57cdbc 100644 --- a/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt +++ b/app/src/main/java/com/topjohnwu/magisk/data/repository/NetworkService.kt @@ -1,6 +1,5 @@ package com.topjohnwu.magisk.data.repository -import android.os.Build import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Config.Value.BETA_CHANNEL import com.topjohnwu.magisk.core.Config.Value.CANARY_CHANNEL @@ -66,7 +65,7 @@ class NetworkService( } // Fetch files - suspend fun fetchSafetynet() = wrap { jsd.fetchSafetynet() } + // suspend fun fetchSafetynet() = wrap { jsd.fetchSafetynet() } suspend fun fetchBootctl() = wrap { jsd.fetchBootctl() } suspend fun fetchInstaller() = wrap { val sha = fetchMainVersion() @@ -76,4 +75,7 @@ class NetworkService( suspend fun fetchString(url: String) = wrap { raw.fetchString(url) } private suspend fun fetchMainVersion() = api.fetchBranch(MAGISK_MAIN, "master").commit.sha + + // Temporary for canary builds, switch to release link at next public release + suspend fun fetchSafetynet() = fetchFile("https://github.com/topjohnwu/Magisk/releases/download/v22.1/snet.jar") } diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt index c712f6780..2250c5072 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/CheckSafetyNetEvent.kt @@ -1,6 +1,12 @@ +@file:Suppress("DEPRECATION") + package com.topjohnwu.magisk.ui.safetynet import android.content.Context +import android.util.Base64 +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi import com.topjohnwu.magisk.R import com.topjohnwu.magisk.arch.ContextExecutor import com.topjohnwu.magisk.arch.ViewEventWithScope @@ -9,21 +15,29 @@ import com.topjohnwu.magisk.data.repository.NetworkService import com.topjohnwu.magisk.ktx.DynamicClassLoader import com.topjohnwu.magisk.ktx.writeTo import com.topjohnwu.magisk.view.MagiskDialog +import com.topjohnwu.signing.CryptoUtils import com.topjohnwu.superuser.Shell +import dalvik.system.BaseDexClassLoader import dalvik.system.DexFile -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject +import org.bouncycastle.asn1.ASN1Encoding +import org.bouncycastle.asn1.ASN1Primitive +import org.bouncycastle.est.jcajce.JsseDefaultHostnameAuthorizer import org.koin.core.KoinComponent import org.koin.core.inject import timber.log.Timber +import java.io.ByteArrayInputStream import java.io.File import java.io.IOException +import java.lang.reflect.Field import java.lang.reflect.InvocationHandler +import java.security.GeneralSecurityException +import java.security.SecureRandom +import java.security.Signature +import java.security.cert.X509Certificate -@Suppress("DEPRECATION") class CheckSafetyNetEvent( private val callback: (SafetyNetResult) -> Unit = {} ) : ViewEventWithScope(), ContextExecutor, KoinComponent, SafetyNetHelper.Callback { @@ -32,92 +46,94 @@ class CheckSafetyNetEvent( private lateinit var apk: File private lateinit var dex: File + private lateinit var nonce: ByteArray override fun invoke(context: Context) { apk = File("${context.filesDir.parent}/snet", "snet.jar") dex = File(apk.parent, "snet.dex") - scope.launch { + scope.launch(Dispatchers.IO) { attest(context) { // Download and retry - withContext(Dispatchers.IO) { - Shell.sh("rm -rf " + apk.parent).exec() - apk.parentFile?.mkdir() + Shell.sh("rm -rf " + apk.parent).exec() + apk.parentFile?.mkdir() + withContext(Dispatchers.Main) { + showDialog(context) } - download(context, true) } } } private suspend fun attest(context: Context, onError: suspend (Exception) -> Unit) { + val helper: SafetyNetHelper try { - val helper = withContext(Dispatchers.IO) { - val loader = DynamicClassLoader(apk) - val dex = DexFile.loadDex(apk.path, dex.path, 0) + val loader = DynamicClassLoader(apk) - // Scan through the dex and find our helper class - var helperClass: Class<*>? = null - for (className in dex.entries()) { - if (className.startsWith("x.")) { - val cls = loader.loadClass(className) + // Scan through the dex and find our helper class + var clazz: Class<*>? = null + loop@for (dex in loader.getDexFiles()) { + for (name in dex.entries()) { + if (name.startsWith("x.")) { + val cls = loader.loadClass(name) if (InvocationHandler::class.java.isAssignableFrom(cls)) { - helperClass = cls - break + clazz = cls + break@loop } } } - helperClass ?: throw Exception() - - val helper = helperClass - .getMethod("get", Class::class.java, Context::class.java, Any::class.java) - .invoke( - null, SafetyNetHelper::class.java, - context, this@CheckSafetyNetEvent - ) as SafetyNetHelper - - if (helper.version < Const.SNET_EXT_VER) - throw Exception() - helper } - helper.attest() + clazz ?: throw Exception("Cannot find SafetyNetHelper class") + + helper = clazz.getMethod("get", Class::class.java, Context::class.java, Any::class.java) + .invoke(null, SafetyNetHelper::class.java, context, this) as SafetyNetHelper + + if (helper.version != Const.SNET_EXT_VER) + throw Exception("snet extension version mismatch") } catch (e: Exception) { - if (e is CancellationException) - throw e onError(e) - } - } - - @Suppress("SameParameterValue") - private fun download(context: Context, askUser: Boolean) { - fun downloadInternal() = scope.launch { - val abort: suspend (Exception) -> Unit = { - Timber.e(it) - callback(SafetyNetResult()) - } - try { - withContext(Dispatchers.IO) { - svc.fetchSafetynet().byteStream().writeTo(apk) - } - attest(context, abort) - } catch (e: IOException) { - if (e is CancellationException) - throw e - abort(e) - } - } - - if (!askUser) { - downloadInternal() return } + val random = SecureRandom() + nonce = ByteArray(24) + random.nextBytes(nonce) + helper.attest(nonce) + } + + private fun Class<*>.field(name: String): Field = + getDeclaredField(name).apply { isAccessible = true } + + // All of these fields are whitelisted + private fun BaseDexClassLoader.getDexFiles(): List { + val pathList = BaseDexClassLoader::class.java.field("pathList").get(this) + val dexElements = pathList.javaClass.field("dexElements").get(pathList) as Array<*> + val fileField = dexElements.javaClass.componentType.field("dexFile") + return dexElements.map { fileField.get(it) as DexFile } + } + + private fun download(context: Context) = scope.launch(Dispatchers.IO) { + val abort: suspend (Exception) -> Unit = { + Timber.e(it) + withContext(Dispatchers.Main) { + callback(SafetyNetResult()) + } + } + try { + svc.fetchSafetynet().byteStream().writeTo(apk) + attest(context, abort) + } catch (e: IOException) { + abort(e) + } + } + + private fun showDialog(context: Context) { MagiskDialog(context) .applyTitle(R.string.proprietary_title) .applyMessage(R.string.proprietary_notice) .cancellable(false) .applyButton(MagiskDialog.ButtonType.POSITIVE) { titleRes = android.R.string.ok - onClick { downloadInternal() } + onClick { download(context) } } .applyButton(MagiskDialog.ButtonType.NEGATIVE) { titleRes = android.R.string.cancel @@ -129,7 +145,94 @@ class CheckSafetyNetEvent( .reveal() } - override fun onResponse(response: JSONObject?) { - callback(SafetyNetResult(response)) + private fun String.decode(): ByteArray { + return if (contains("\\+|/".toRegex())) + Base64.decode(this, Base64.DEFAULT) + else + Base64.decode(this, Base64.URL_SAFE) + } + + private fun String.parseJws(): SafetyNetResponse? { + val jws = split('.') + val secondDot = lastIndexOf('.') + val rawHeader = String(jws[0].decode()) + val payload = String(jws[1].decode()) + var signature = jws[2].decode() + val signedBytes = substring(0, secondDot).toByteArray() + + val moshi = Moshi.Builder().build() + val header = moshi.adapter(JwsHeader::class.java).fromJson(rawHeader) ?: return null + + val alg = when (header.algorithm) { + "RS256" -> "SHA256withRSA" + "ES256" -> { + // Convert to DER encoding + signature = ASN1Primitive.fromByteArray(signature).getEncoded(ASN1Encoding.DER) + "SHA256withECDSA" + } + else -> return null + } + + // Verify signature + val certB64 = header.certificates?.first() ?: return null + val certDer = certB64.decode() + val bis = ByteArrayInputStream(certDer) + val cert: X509Certificate + try { + cert = CryptoUtils.readCertificate(bis) + val verifier = Signature.getInstance(alg) + verifier.initVerify(cert.publicKey) + verifier.update(signedBytes) + if (!verifier.verify(signature)) + return null + } catch (e: GeneralSecurityException) { + Timber.e(e) + return null + } + + // Verify hostname + val hostNameVerifier = JsseDefaultHostnameAuthorizer(setOf()) + try { + if (!hostNameVerifier.verify("attest.android.com", cert)) + return null + } catch (e: IOException) { + Timber.e(e) + return null + } + + val response = moshi.adapter(SafetyNetResponse::class.java).fromJson(payload) ?: return null + + // Verify results + if (!response.nonce.decode().contentEquals(nonce)) + return null + + return response + } + + override fun onResponse(response: String?) { + if (response != null) { + scope.launch(Dispatchers.Default) { + val res = response.parseJws() + withContext(Dispatchers.Main) { + callback(SafetyNetResult(res)) + } + } + } else { + callback(SafetyNetResult()) + } } } + +@JsonClass(generateAdapter = true) +data class JwsHeader( + @Json(name = "alg") val algorithm: String, + @Json(name = "x5c") val certificates: List? +) + +@JsonClass(generateAdapter = true) +data class SafetyNetResponse( + val nonce: String, + val ctsProfileMatch: Boolean, + val basicIntegrity: Boolean, + val evaluationType: String = "" +) diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt index 50bed6032..013f45dd5 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetyNetHelper.kt @@ -1,14 +1,12 @@ package com.topjohnwu.magisk.ui.safetynet -import org.json.JSONObject - interface SafetyNetHelper { val version: Int - fun attest() + fun attest(nonce: ByteArray) interface Callback { - fun onResponse(response: JSONObject?) + fun onResponse(response: String?) } } diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt index b2a505460..59131a661 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/safetynet/SafetynetViewModel.kt @@ -5,10 +5,9 @@ import com.topjohnwu.magisk.BR import com.topjohnwu.magisk.R import com.topjohnwu.magisk.arch.BaseViewModel import com.topjohnwu.magisk.utils.set -import org.json.JSONObject data class SafetyNetResult( - val response: JSONObject? = null, + val response: SafetyNetResponse? = null, val dismiss: Boolean = false ) @@ -43,20 +42,20 @@ class SafetynetViewModel : BaseViewModel() { init { cachedResult?.also { - resolveResponse(SafetyNetResult(it)) + handleResponse(SafetyNetResult(it)) } ?: attest() } private fun attest() { isChecking = true CheckSafetyNetEvent { - resolveResponse(it) + handleResponse(it) }.publish() } fun reset() = attest() - private fun resolveResponse(response: SafetyNetResult) { + private fun handleResponse(response: SafetyNetResult) { isChecking = false if (response.dismiss) { @@ -65,26 +64,15 @@ class SafetynetViewModel : BaseViewModel() { } response.response?.apply { - runCatching { - val cts = getBoolean("ctsProfileMatch") - val basic = getBoolean("basicIntegrity") - val eval = optString("evaluationType") - val result = cts && basic - cachedResult = this - ctsState = cts - basicIntegrityState = basic - evalType = if (eval.contains("HARDWARE")) "HARDWARE" else "BASIC" - isSuccess = result - safetyNetTitle = - if (result) R.string.safetynet_attest_success - else R.string.safetynet_attest_failure - }.onFailure { - isSuccess = false - ctsState = false - basicIntegrityState = false - evalType = "N/A" - safetyNetTitle = R.string.safetynet_res_invalid - } + val result = ctsProfileMatch && basicIntegrity + cachedResult = this + ctsState = ctsProfileMatch + basicIntegrityState = basicIntegrity + evalType = if (evaluationType.contains("HARDWARE")) "HARDWARE" else "BASIC" + isSuccess = result + safetyNetTitle = + if (result) R.string.safetynet_attest_success + else R.string.safetynet_attest_failure } ?: { isSuccess = false ctsState = false @@ -95,7 +83,7 @@ class SafetynetViewModel : BaseViewModel() { } companion object { - private var cachedResult: JSONObject? = null + private var cachedResult: SafetyNetResponse? = null } } diff --git a/build.py b/build.py index dea5d8e19..01dbea51b 100755 --- a/build.py +++ b/build.py @@ -363,7 +363,7 @@ def build_stub(args): def build_snet(args): - if not op.exists(op.join('snet', 'src', 'main', 'java', 'com', 'topjohnwu', 'snet')): + if not op.exists(op.join('stub', 'src', 'main', 'java', 'com', 'topjohnwu', 'snet')): error('snet sources have to be bind mounted on top of the stub folder') header('* Building snet extension') proc = execv([gradlew, 'stub:assembleRelease']) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 442d9132e..336a74c2a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Apr 14 22:32:11 PDT 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-all.zip