From 8f62f8972841227412eabfa2ebef834aa2fd889f Mon Sep 17 00:00:00 2001 From: TN Date: Mon, 14 Sep 2026 21:10:40 +0700 Subject: [PATCH] Detect rooted devices and send the signal in request headers --- docs/ip/README.md | 77 ++++++++------- .../mobile/sdk/ip/interceptor/IPHeaders.kt | 3 + .../ip/interceptor/SdkHeadersInterceptor.kt | 2 + .../mobile/sdk/ip/utils/DeviceUtils.kt | 1 + .../mobile/sdk/ip/utils/RootUtils.kt | 94 +++++++++++++++++++ 5 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/RootUtils.kt diff --git a/docs/ip/README.md b/docs/ip/README.md index bbf5f81..b52bd0e 100644 --- a/docs/ip/README.md +++ b/docs/ip/README.md @@ -962,28 +962,36 @@ IPificationServices.startAuthentication( #### 3.2.1 Custom parameters for your SMS backend -If your `/sms/auth` or `/sms/token` contract needs partner-specific values (for example a tenant or routing id), declare them on the SMS channel. `addAuthParam` values are added to the `/sms/auth` body; `addTokenParam` values and headers are captured in the `SMSAuthResponse` and reused automatically by `verifySMSOTP()`. +All SMS custom values are optional. Add them only when your `/sms/auth` or `/sms/token` contract requires partner-specific fields. + +| Parameter | Required | Description | +| --- | --- | --- | +| SMS auth params | Optional | Sent in the `/sms/auth` body. Add only when required by your backend auth contract. | +| SMS token params | Optional | Sent in the `/sms/token` body. Captured in the `SMSAuthResponse` and reused automatically by `verifySMSOTP()`. | +| SMS headers | Optional | Added to both SMS backend requests. | #### **Kotlin** ```kotlin +// Optional, only if required by your backend: authRequestBuilder.sms { - addAuthParam("server_id", serverId) // -> /sms/auth body - addTokenParam("server_id", serverId) // -> /sms/token body - addHeader("X-Tenant", tenantId) // -> both requests + addAuthParam("custom_auth_param", "value") // -> /sms/auth body + addTokenParam("custom_token_param", "value") // -> /sms/token body + addHeader("X-Custom-Header", "value") // -> both requests } ``` #### **Java** ```java +// Optional, only if required by your backend: authRequestBuilder.setSMSOptions( new SMSChannelOptions.Builder() - .addAuthParam("server_id", serverId) - .addTokenParam("server_id", serverId) - .addHeader("X-Tenant", tenantId) + .addAuthParam("custom_auth_param", "value") // -> /sms/auth body + .addTokenParam("custom_token_param", "value") // -> /sms/token body + .addHeader("X-Custom-Header", "value") // -> both requests .build() ); ``` @@ -1245,6 +1253,8 @@ Each channel talks to a different backend contract, so partner-specific values a | `ts43 { ... }` / `setTS43Options` | `/ts43/auth` body | `/ts43/token` body | both TS43 requests | | `sms { ... }` / `setSMSOptions` | `/sms/auth` body | `/sms/token` body (reused automatically by `verifySMSOTP`) | both SMS requests | +All channel options are optional. Configure a channel only when the backend behind that channel requires extra fields; a channel with no options behaves exactly as before. + Keys owned by the SDK (`client_id`, `login_hint`, `scope`, `code`, `auth_req_id`, `nonce`, `vp_token`, ...) are reserved: adding one throws `IllegalArgumentException` so the mistake is caught during development. Options configured for a channel that is not in `AUTH_CHANNELS` are ignored (a debug log line is written). @@ -1255,45 +1265,48 @@ Keys owned by the SDK (`client_id`, `login_hint`, `scope`, `code`, `auth_req_id` val authRequest = AuthRequest.Builder() .setScope("openid ip:phone_verify") .addQueryParam("login_hint", country_code + user_input_phone_number) + // Optional, only if required by your backend: .ts43 { - addAuthParam("server_id", serverId) - addTokenParam("server_id", serverId) - setScope("openid ip:phone") // optional TS43-only scope override + addAuthParam("custom_auth_param", "value") // -> /ts43/auth body + addTokenParam("custom_token_param", "value") // -> /ts43/token body + setScope("openid ip:phone") // optional TS43-only scope override } .ip { - addAuthParam("consent_id", consentId) // IP authorization request only - addTokenParam("server_id", serverId) // IP_TOKEN_URL body only + addAuthParam("custom_auth_param", "value") // -> IP authorization request query string + addTokenParam("custom_token_param", "value") // -> IP_TOKEN_URL body } .sms { - addAuthParam("server_id", serverId) - addAuthParam("locale", "vi") - addTokenParam("server_id", serverId) + addAuthParam("custom_auth_param", "value") // -> /sms/auth body + addTokenParam("custom_token_param", "value") // -> /sms/token body } .build() // Same value for every channel? Say so explicitly: -// .forAllChannels { addAuthParam("server_id", serverId) } +// .forAllChannels { addAuthParam("custom_auth_param", "value") } ``` #### **Java** ```java -AuthRequest authRequest = new AuthRequest.Builder() - .setScope("openid ip:phone_verify") - .setTS43Options(new TS43ChannelOptions.Builder() - .addAuthParam("server_id", serverId) - .addTokenParam("server_id", serverId) - .build()) - .setIPOptions(new IPChannelOptions.Builder() - .addAuthParam("consent_id", consentId) - .addTokenParam("server_id", serverId) - .build()) - .setSMSOptions(new SMSChannelOptions.Builder() - .addAuthParam("server_id", serverId) - .addTokenParam("server_id", serverId) - .build()) - .build(); -// Then call addQueryParam("login_hint", ...) on the builder before build(), as in the examples above. +AuthRequest.Builder authRequestBuilder = new AuthRequest.Builder(); +authRequestBuilder.setScope("openid ip:phone_verify"); +authRequestBuilder.addQueryParam("login_hint", country_code + user_input_phone_number); + +// Optional, only if required by your backend: +authRequestBuilder.setTS43Options(new TS43ChannelOptions.Builder() + .addAuthParam("custom_auth_param", "value") // -> /ts43/auth body + .addTokenParam("custom_token_param", "value") // -> /ts43/token body + .build()); +authRequestBuilder.setIPOptions(new IPChannelOptions.Builder() + .addAuthParam("custom_auth_param", "value") // -> IP authorization request query string + .addTokenParam("custom_token_param", "value") // -> IP_TOKEN_URL body + .build()); +authRequestBuilder.setSMSOptions(new SMSChannelOptions.Builder() + .addAuthParam("custom_auth_param", "value") // -> /sms/auth body + .addTokenParam("custom_token_param", "value") // -> /sms/token body + .build()); + +AuthRequest authRequest = authRequestBuilder.build(); ``` diff --git a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/IPHeaders.kt b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/IPHeaders.kt index 3745ef8..04f6041 100644 --- a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/IPHeaders.kt +++ b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/IPHeaders.kt @@ -23,6 +23,9 @@ internal object IPHeaders { /** Android API level. */ const val OS_API_LEVEL = "os-sdk" + /** Whether the device shows signs of being rooted. Value is `yes` or `no`. */ + const val ROOTED = "rooted" + // First SIM headers /** Mobile country code reported by SIM slot 1. */ diff --git a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/SdkHeadersInterceptor.kt b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/SdkHeadersInterceptor.kt index c6ef899..72aee85 100644 --- a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/SdkHeadersInterceptor.kt +++ b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/interceptor/SdkHeadersInterceptor.kt @@ -9,6 +9,7 @@ import com.ipification.mobile.sdk.ip.utils.DeviceUtils import com.ipification.mobile.sdk.ip.utils.IPLogs import com.ipification.mobile.sdk.ip.utils.LogUtils import com.ipification.mobile.sdk.ip.utils.NetworkUtils +import com.ipification.mobile.sdk.ip.utils.RootUtils import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -56,6 +57,7 @@ class SdkHeadersInterceptor( .addHeader(IPHeaders.DEVICE_NAME, "${Build.MANUFACTURER} - ${Build.MODEL}") .addHeader(IPHeaders.OS_VERSION, Build.VERSION.RELEASE) .addHeader(IPHeaders.OS_API_LEVEL, Build.VERSION.SDK_INT.toString()) + .addHeader(IPHeaders.ROOTED, RootUtils.rootedHeaderValue()) if (includeCarrierHeaders) { addCarrierHeaders(requestBuilder) diff --git a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/DeviceUtils.kt b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/DeviceUtils.kt index d9ca28a..c3958f6 100644 --- a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/DeviceUtils.kt +++ b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/DeviceUtils.kt @@ -230,6 +230,7 @@ class DeviceUtils private constructor(context: Context) { .appendLine("WIFI: ${enabledState(NetworkUtils.isWifiEnabled(context))}") .appendLine("VPN: ${enabledState(NetworkUtils.isVpnEnabled(context))}") .appendLine("ROAMING: ${enabledState(NetworkUtils.isRoaming(context))}") + .appendLine("ROOTED: ${RootUtils.rootedHeaderValue()}") .appendLine("-------------------------------------") .appendLine("DEVICE NAME: ${Build.MANUFACTURER} - ${Build.MODEL}") .appendLine("OS VERSION: ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}") diff --git a/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/RootUtils.kt b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/RootUtils.kt new file mode 100644 index 0000000..e498a5e --- /dev/null +++ b/ipification-auth/src/main/java/com/ipification/mobile/sdk/ip/utils/RootUtils.kt @@ -0,0 +1,94 @@ +package com.ipification.mobile.sdk.ip.utils + +import android.os.Build +import java.io.File + +/** + * Heuristic root detection used for SDK diagnostics and risk signals. + * + * The result is a best-effort indication, not a security guarantee: a determined user can hide root + * from every check below. It is intended to help IPification and partners spot risky sessions, and + * must never be used on its own to grant or deny authentication. + * + * Detection is intentionally cheap and side-effect free: it inspects build tags and well-known root + * artifacts on disk, and never spawns a process. The result is computed once per process and cached, + * so it can be read from request paths without measurable cost. + */ +internal object RootUtils { + + /** Build tag left by non-production (typically rooted or self-signed) system images. */ + private const val TEST_KEYS_TAG = "test-keys" + + /** Common locations of the `su` binary on rooted devices. */ + private val SU_BINARY_PATHS = arrayOf( + "/sbin/su", + "/system/bin/su", + "/system/xbin/su", + "/system/sbin/su", + "/vendor/bin/su", + "/su/bin/su", + "/data/local/su", + "/data/local/bin/su", + "/data/local/xbin/su" + ) + + /** Files installed by common root managers. */ + private val ROOT_MANAGER_PATHS = arrayOf( + "/system/app/Superuser.apk", + "/system/app/SuperSU.apk", + "/system/app/Magisk.apk", + "/sbin/magisk", + "/sbin/.magisk", + "/data/adb/magisk", + "/data/adb/ksu", + "/system/xbin/daemonsu", + "/system/etc/init.d/99SuperSUDaemon", + "/dev/com.koushikdutta.superuser.daemon" + ) + + @Volatile + private var cachedResult: Boolean? = null + + /** + * Returns whether the device shows signs of being rooted. + * + * The first call performs the checks and caches the outcome for the lifetime of the process. + */ + @JvmStatic + fun isDeviceRooted(): Boolean { + cachedResult?.let { return it } + synchronized(this) { + cachedResult?.let { return it } + val result = runCatching { detect() } + .onFailure { LogUtils.debug("Root detection failed: ${it.message}") } + .getOrDefault(false) + cachedResult = result + return result + } + } + + /** Returns `yes` or `no`, for request headers and diagnostic logs. */ + @JvmStatic + fun rootedHeaderValue(): String = if (isDeviceRooted()) "yes" else "no" + + /** Clears the cached result. Intended for tests. */ + internal fun reset() { + synchronized(this) { cachedResult = null } + } + + private fun detect(): Boolean { + return hasTestKeysBuild() || hasAnyFile(SU_BINARY_PATHS) || hasAnyFile(ROOT_MANAGER_PATHS) + } + + /** Checks whether the system image was signed with test keys. */ + private fun hasTestKeysBuild(): Boolean { + return Build.TAGS?.contains(TEST_KEYS_TAG) == true + } + + /** Checks whether any of the given paths exists, ignoring paths the app may not stat. */ + private fun hasAnyFile(paths: Array): Boolean { + return paths.any { path -> + runCatching { File(path).exists() }.getOrDefault(false) + } + } +}