diff --git a/.pubignore b/.pubignore index f6ab228..925a7f6 100644 --- a/.pubignore +++ b/.pubignore @@ -7,6 +7,5 @@ example/* example/lib/* !example/lib/main.dart test/ -pigeons/ !**/TalsecRuntime.xcframework/ !**/TalsecRuntime.xcframework/** diff --git a/CHANGELOG.md b/CHANGELOG.md index b33bfdb..63e25ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,60 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [9.0.0] - 2026-11-11 + +- Android SDK version: 20.0.1 +- iOS SDK version: 8.0.1 + +### Flutter + +#### Breaking + +- `RaspExecutionStateCallback.onAllChecksFinished` renamed to `onFastChecksFinished` and `RaspExecutionState.allChecksFinished` renamed to `fastChecksFinished`. On Android, it is triggered when the fast checks are completed, while the postponed checks may still run; on iOS, when all checks are completed +- `AndroidConfig.signingCertHashes` renamed to `certificateHashes` and `AndroidConfig.supportedStores` renamed to `supportedAlternativeStores`, matching the Android SDK and the other freeRASP plugins +- `IOSConfig.bundleIds` renamed to `appBundleIds` and `IOSConfig.teamId` renamed to `appTeamId`, matching the iOS SDK and the other freeRASP plugins. The JSON keys used by `toJson`/`fromJson` change accordingly +- `ThreatCallback.onADBEnabled` renamed to `onAdbEnabled` and `ThreatCallback.onUnsecureWiFi` renamed to `onUnsecureWifi`; `Threat.deviceId` renamed to `deviceID` and `Threat.unsecureWiFi` renamed to `unsecureWifi`, matching the other freeRASP plugins +- All `Talsec` methods now throw only `TalsecException` or its subclasses: Android-only methods throw the new `UnsupportedPlatformException` (code `unsupported-platform`) instead of `UnimplementedError`, `blockScreenCapture`/`isScreenCaptureBlocked` no longer throw a generic `Exception`, `start` wraps platform errors in `TalsecException`, and `addToWhitelist`/`getAppIcon` throw `MalwareFailureException` +- `MalwareCallback` now receives a `List` without nullable items, and its parameter is renamed from `packageInfo` to `suspiciousApps` +- The `Talsec.private` constructor and the `methodChannel`, `eventChannel` and `executionStateChannel` fields are no longer public; use `Talsec.instance` +- `ThreatX.fromInt` throws an `ArgumentError` for an unknown code instead of terminating the app. The app is still terminated when the native side sends an unknown threat code, and now also for an unknown execution state code, like in the other freeRASP plugins +- `ThreatCallback` no longer extends the generated `TalsecPigeonApi`; its `onMalwareDetected` and `onOngoingCallDetected` methods are removed, use the `onMalware` and `onOngoingCall` callbacks instead +- `PackageInfo` and `SuspiciousAppInfo` are now immutable classes with value equality; their fields are final and the `encode`/`decode` methods are removed + +#### Added + +- `ThreatCallback.onOngoingCall` callback with the new `CallState` enum (`started`, `ongoing`, `stopped`), driven by `Talsec.startOngoingCallDetection()` and `Talsec.stopOngoingCallDetection()` (Android only) +- `Talsec.blockScreenControl()` and `Talsec.isScreenControlBlocked()` to prevent accessibility services from reading or controlling the app screen (Android only) +- `Talsec.blockOverlayWindows()` and `Talsec.isOverlayWindowsBlocked()` to hide overlay windows of other apps (Android 12+ only, requires the `HIDE_OVERLAY_WINDOWS` permission) +- `RaspExecutionStateCallback.onCriticalChecksFinished` and `RaspExecutionStateCallback.onPostponedChecksFinished`, with matching `RaspExecutionState` values (Android only) +- `ExternalIdFailureException` and `MalwareFailureException` are now exported, so they can be caught by type + +#### Changed + +- Android package (namespace) of the plugin renamed from `com.aheaditec.freerasp` to `app.talsec.rasp.flutter`, following the Android SDK's `app.talsec.rasp` +- On Android, `Talsec.isScreenCaptureBlocked()` reports the protection state of the current activity +- On Android, freeRASP now starts in background mode (`TalsecMode.BACKGROUND`), so the initialization and synchronous checks no longer run on the main thread +- Malware and ongoing call detections are delivered over the same channel as the other threats, so `ThreatCallback` receives all detections in the order in which they occur. The plugin no longer uses Pigeon + +### Android + +#### Fixed + +- Improved hook detection to prevent collisions with running VPN services and to better work with the device resources (threads/RAM) +- Fixed a release crash during root detection when a checked package is not installed +- Fixed a crash in `ScreenProtector.unregisterScreenCallbacks` on Android 15+ when several activities registered screen callbacks +- Fixed intermittent build failures of apps using the SDK (`NullPointerException: Cannot read field "d"`, shown by Gradle as `[CIRCULAR REFERENCE]`) caused by invalid bytecode in some SDK builds +- Fixed `killOnBypass` closing the app when the same threat was reported twice in quick succession + +#### Changed + +- Deprecated the freeRASP Maven coordinates (`com.aheaditec.talsec.security:TalsecSecurity-{flavor}`) +- Improved Bootloader detection +- Extended emulator detection with additional device-property and ABI checks +- Improved logging structure and data enrichment for better Portal insights/reports +- Refactoring of the time spoofing detection which improves the performance of the freeRASP check execution +- Improved the performance of the App integrity check +- Updated CA bundle ### iOS diff --git a/README.md b/README.md index 53dd665..a461611 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Key features are the detection and prevention of * Unlocked or compromised bootloader * Hooking framework (e.g., Frida, LSPosed, Shadow) * UI automation and driven-input attacks (e.g., Appium) +* Ongoing cellular and VoIP calls during sensitive flows (Android) +* Screen control by accessibility services and overlay windows of other apps (Android) * Untrusted installation method * App Data Migration protection diff --git a/android/build.gradle b/android/build.gradle index dea2813..7352feb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,9 +1,9 @@ -group 'com.aheaditec.freerasp' +group 'app.talsec.rasp.flutter' version '1.0-SNAPSHOT' buildscript { ext.kotlin_version = '2.1.0' - ext.talsec_version = '19.3.1' + ext.talsec_version = '20.0.1' repositories { google() mavenCentral() @@ -32,7 +32,7 @@ apply plugin: 'app.talsec.plugin' android { if (project.android.hasProperty("namespace")) { - namespace("com.aheaditec.freerasp") + namespace("app.talsec.rasp.flutter") } compileSdk 36 diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index b299bf1..bc71b75 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,3 @@ + package="app.talsec.rasp.flutter"> diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/Extensions.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt similarity index 73% rename from android/src/main/kotlin/com/aheaditec/freerasp/Extensions.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt index 0529694..efb6332 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/Extensions.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/Extensions.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp +package app.talsec.rasp.flutter import android.content.Context import android.content.pm.PackageInfo @@ -12,8 +12,7 @@ import app.talsec.rasp.security.api.SuspiciousAppInfo import io.flutter.plugin.common.MethodChannel import org.json.JSONArray import org.json.JSONObject -import com.aheaditec.freerasp.generated.PackageInfo as FlutterPackageInfo -import com.aheaditec.freerasp.generated.SuspiciousAppInfo as FlutterSuspiciousAppInfo +import app.talsec.rasp.security.api.ThreatListener.CallState /** * Executes the provided block of code and catches any exceptions thrown by it, returning the @@ -32,31 +31,27 @@ internal inline fun runResultCatching(result: MethodChannel.Result, block: () -> } /** - * Converts a [SuspiciousAppInfo] instance to a [com.aheaditec.freerasp.generated.SuspiciousAppInfo] - * instance used by Pigeon package for Flutter. - * - * @return A new [com.aheaditec.freerasp.generated.SuspiciousAppInfo] object with information from - * this [SuspiciousAppInfo]. + * Converts a [SuspiciousAppInfo] to the map that the Dart `SuspiciousAppInfo.fromMap` reads. */ -internal fun SuspiciousAppInfo.toPigeon(context: Context): FlutterSuspiciousAppInfo { - return FlutterSuspiciousAppInfo(this.packageInfo.toPigeon(context), this.reasons.toList(), this.permissions?.toList()) +internal fun SuspiciousAppInfo.toFlutterMap(context: Context): Map { + return mapOf( + "packageInfo" to packageInfo.toFlutterMap(context), + "reasons" to reasons.toList(), + "permissions" to permissions?.toList(), + ) } /** - * Converts a [PackageInfo] instance to a [com.aheaditec.freerasp.generated.PackageInfo] instance - * used by Pigeon package for Flutter. - * - * @return A new [com.aheaditec.freerasp.generated.PackageInfo] object with information from - * this [PackageInfo]. + * Converts a [PackageInfo] to the map that the Dart `PackageInfo.fromMap` reads. */ -private fun PackageInfo.toPigeon(context: Context): FlutterPackageInfo { - return FlutterPackageInfo( - packageName = packageName, - appName = applicationInfo?.let { - context.packageManager.getApplicationLabel(it) as String +private fun PackageInfo.toFlutterMap(context: Context): Map { + return mapOf( + "packageName" to packageName, + "appName" to applicationInfo?.let { + context.packageManager.getApplicationLabel(it).toString() }, - version = getVersionString(), - installerStore = Utils.getInstallerPackageName(context, packageName), + "version" to getVersionString(), + "installerStore" to Utils.getInstallerPackageName(context, packageName), ) } @@ -117,3 +112,14 @@ internal fun JSONObject.toSuspiciousAppDetectionConfig(): SuspiciousAppDetection reasonMode, ) } + +/** + * Converts a [CallState] to the name of the Dart `CallState` value. + */ +internal fun CallState.toFlutterName(): String { + return when (this) { + CallState.STARTED -> "started" + CallState.ONGOING -> "ongoing" + CallState.STOPPED -> "stopped" + } +} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/FreeraspPlugin.kt similarity index 92% rename from android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/FreeraspPlugin.kt index cbbf80f..d0f473a 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/FreeraspPlugin.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp +package app.talsec.rasp.flutter import android.app.Activity import android.content.Context @@ -6,10 +6,10 @@ import android.os.Build import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import com.aheaditec.freerasp.handlers.ExecutionStateStreamHandler -import com.aheaditec.freerasp.handlers.MethodCallHandler -import com.aheaditec.freerasp.handlers.StreamHandler -import com.aheaditec.freerasp.handlers.TalsecThreatHandler +import app.talsec.rasp.flutter.handlers.ExecutionStateStreamHandler +import app.talsec.rasp.flutter.handlers.MethodCallHandler +import app.talsec.rasp.flutter.handlers.StreamHandler +import app.talsec.rasp.flutter.handlers.TalsecThreatHandler import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/RaspExecutionStateEvent.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/RaspExecutionStateEvent.kt new file mode 100644 index 0000000..15ab836 --- /dev/null +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/RaspExecutionStateEvent.kt @@ -0,0 +1,9 @@ +package app.talsec.rasp.flutter + +internal sealed class RaspExecutionStateEvent(val value: Int) { + object FastChecksFinished : RaspExecutionStateEvent(187429) + + object CriticalChecksFinished : RaspExecutionStateEvent(614702) + + object PostponedChecksFinished : RaspExecutionStateEvent(952318) +} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/ScreenProtector.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/ScreenProtector.kt similarity index 99% rename from android/src/main/kotlin/com/aheaditec/freerasp/ScreenProtector.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/ScreenProtector.kt index d63ddb6..8dfb672 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/ScreenProtector.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/ScreenProtector.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp +package app.talsec.rasp.flutter import android.annotation.SuppressLint import android.app.Activity diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/Threat.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/Threat.kt similarity index 97% rename from android/src/main/kotlin/com/aheaditec/freerasp/Threat.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/Threat.kt index 6e733a4..a118369 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/Threat.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/Threat.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp +package app.talsec.rasp.flutter /** * Sealed class to represent the error codes. diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/Utils.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/Utils.kt similarity index 98% rename from android/src/main/kotlin/com/aheaditec/freerasp/Utils.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/Utils.kt index bf0ebba..8060721 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/Utils.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/Utils.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp +package app.talsec.rasp.flutter import android.content.Context import android.graphics.Bitmap @@ -26,8 +26,8 @@ internal object Utils { val killOnBypass = json.optBoolean("killOnBypass") val androidConfig = json.getJSONObject("androidConfig") val packageName = androidConfig.getString("packageName") - val certificateHashes = androidConfig.extractArray("signingCertHashes") - val alternativeStores = androidConfig.extractArray("supportedStores") + val certificateHashes = androidConfig.extractArray("certificateHashes") + val alternativeStores = androidConfig.extractArray("supportedAlternativeStores") val builder = TalsecConfig.Builder(packageName, certificateHashes) .watcherMail(watcherMail) diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ExecutionStateDispatcher.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ExecutionStateDispatcher.kt similarity index 93% rename from android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ExecutionStateDispatcher.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ExecutionStateDispatcher.kt index 3449855..245c81a 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ExecutionStateDispatcher.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ExecutionStateDispatcher.kt @@ -1,6 +1,6 @@ -package com.aheaditec.freerasp.dispatchers +package app.talsec.rasp.flutter.dispatchers -import com.aheaditec.freerasp.RaspExecutionStateEvent +import app.talsec.rasp.flutter.RaspExecutionStateEvent import io.flutter.plugin.common.EventChannel.EventSink internal object ExecutionStateDispatcher { diff --git a/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt new file mode 100644 index 0000000..94231ec --- /dev/null +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/dispatchers/ThreatDispatcher.kt @@ -0,0 +1,115 @@ +package app.talsec.rasp.flutter.dispatchers + +import app.talsec.rasp.flutter.Threat +import app.talsec.rasp.flutter.toFlutterName +import app.talsec.rasp.security.api.ThreatListener.CallState +import io.flutter.plugin.common.EventChannel.EventSink + +/** + * Sends detections to Flutter over the threat [EventSink], in the order in which they occur. + * + * A plain threat is sent as its [Int] code. A detection with a payload is sent as a map with a + * `type` key: `malware` (with `suspiciousApps`) or `ongoingCall` (with `state`). Detections that + * occur while the app is in background or no listener is attached are cached and sent later. + */ +internal object ThreatDispatcher { + private val threatCache = mutableSetOf() + private val malwareCache = mutableListOf>() + private val callStateCache = mutableListOf() + private var isAppInForeground = false + + var eventSink: EventSink? = null + set(value) { + field = value + if (value != null) { + isAppInForeground = true + flushCache() + } + } + + fun onResume() { + isAppInForeground = true + if (eventSink != null) { + flushCache() + } + } + + fun onPause() { + isAppInForeground = false + } + + fun dispatchThreat(threat: Threat) { + if (isAppInForeground && eventSink != null) { + eventSink?.success(threat.value) + } else { + synchronized(threatCache) { + threatCache.add(threat) + } + } + } + + /** + * @param apps The suspicious apps, already converted with `toFlutterMap`. + */ + fun dispatchMalware(apps: List>) { + if (isAppInForeground && eventSink != null) { + eventSink?.success(malwareEvent(apps)) + } else { + synchronized(malwareCache) { + malwareCache.addAll(apps) + } + } + } + + fun dispatchOngoingCall(state: CallState) { + if (isAppInForeground && eventSink != null) { + eventSink?.success(ongoingCallEvent(state)) + } else { + // Keep the order: a call that started and stopped in background is reported as both. + synchronized(callStateCache) { + callStateCache.add(state) + } + } + } + + private fun flushCache() { + flushThreatCache() + flushMalwareCache() + flushCallStateCache() + } + + private fun flushThreatCache() { + val threats = synchronized(threatCache) { + val snapshot = threatCache.toSet() + threatCache.clear() + snapshot + } + threats.forEach { eventSink?.success(it.value) } + } + + private fun flushMalwareCache() { + val malware = synchronized(malwareCache) { + val snapshot = malwareCache.toList() + malwareCache.clear() + snapshot + } + if (malware.isNotEmpty()) { + eventSink?.success(malwareEvent(malware)) + } + } + + private fun flushCallStateCache() { + val states = synchronized(callStateCache) { + val snapshot = callStateCache.toList() + callStateCache.clear() + snapshot + } + states.forEach { eventSink?.success(ongoingCallEvent(it)) } + } + + private fun malwareEvent(apps: List>): Map = + mapOf("type" to "malware", "suspiciousApps" to apps) + + private fun ongoingCallEvent(state: CallState): Map = + mapOf("type" to "ongoingCall", "state" to state.toFlutterName()) +} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/ExecutionStateStreamHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/ExecutionStateStreamHandler.kt similarity index 96% rename from android/src/main/kotlin/com/aheaditec/freerasp/handlers/ExecutionStateStreamHandler.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/handlers/ExecutionStateStreamHandler.kt index 215f7fd..fee7dfc 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/ExecutionStateStreamHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/ExecutionStateStreamHandler.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp.handlers +package app.talsec.rasp.flutter.handlers import io.flutter.Log import io.flutter.plugin.common.BinaryMessenger diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt similarity index 59% rename from android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt index 9c98c11..0bae62a 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/MethodCallHandler.kt @@ -1,19 +1,18 @@ -package com.aheaditec.freerasp.handlers +package app.talsec.rasp.flutter.handlers +import android.annotation.SuppressLint import android.app.Activity import android.content.Context +import android.os.Build import android.os.Handler import android.os.HandlerThread import android.os.Looper import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import com.aheaditec.freerasp.Utils -import com.aheaditec.freerasp.generated.TalsecPigeonApi -import com.aheaditec.freerasp.resolve -import com.aheaditec.freerasp.runResultCatching -import com.aheaditec.freerasp.toPigeon -import app.talsec.rasp.security.api.SuspiciousAppInfo +import app.talsec.rasp.flutter.Utils +import app.talsec.rasp.flutter.resolve +import app.talsec.rasp.flutter.runResultCatching import app.talsec.rasp.security.api.Talsec import io.flutter.Log import io.flutter.plugin.common.BinaryMessenger @@ -27,35 +26,29 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { private var context: Context? = null private var methodChannel: MethodChannel? = null - private var talsecPigeon: TalsecPigeonApi? = null private val backgroundHandlerThread = HandlerThread("BackgroundThread").apply { start() } private val backgroundHandler = Handler(backgroundHandlerThread.looper) private val mainHandler = Handler(Looper.getMainLooper()) - internal var activity: Activity? = null - - companion object { - private const val CHANNEL_NAME: String = "talsec.app/freerasp/methods" - } + private var isOngoingCallDetectionEnabled = false - private val sink = object : MethodSink { - override fun onMalwareDetected(packageInfo: List) { - context?.let { context -> - val pigeonPackageInfo = packageInfo.map { it.toPigeon(context) } - talsecPigeon?.onMalwareDetected(pigeonPackageInfo) { result -> - // Parse the result (which is Unit so we can ignore it) or throw an exception - // Exceptions are translated to Flutter errors automatically - result.getOrElse { - Log.e("MethodCallHandlerSink", "Result ended with failure") - throw it - } - } + /** + * The current activity. Ongoing call detection is registered per activity, so it is moved + * along when the activity is replaced (e.g. on configuration changes) and stopped when the + * activity is detached. + */ + internal var activity: Activity? = null + set(value) { + if (field === value) return + if (isOngoingCallDetectionEnabled) { + field?.let { Talsec.stopOngoingCallDetection(it) } + value?.let { Talsec.startOngoingCallDetection(it) } } + field = value } - } - internal interface MethodSink { - fun onMalwareDetected(packageInfo: List) + companion object { + private const val CHANNEL_NAME: String = "talsec.app/freerasp/methods" } /** @@ -77,9 +70,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { } this.context = context - this.talsecPigeon = TalsecPigeonApi(messenger) - - TalsecThreatHandler.attachMethodSink(sink) } /** @@ -90,9 +80,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { methodChannel = null this.context = null - this.talsecPigeon = null - - TalsecThreatHandler.detachMethodSink() } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { @@ -122,6 +109,12 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { "getAppIcon" -> getAppIcon(call, result) "blockScreenCapture" -> blockScreenCapture(call, result) "isScreenCaptureBlocked" -> isScreenCaptureBlocked(result) + "blockScreenControl" -> blockScreenControl(call, result) + "isScreenControlBlocked" -> isScreenControlBlocked(result) + "blockOverlayWindows" -> blockOverlayWindows(call, result) + "isOverlayWindowsBlocked" -> isOverlayWindowsBlocked(result) + "startOngoingCallDetection" -> startOngoingCallDetection(result) + "stopOngoingCallDetection" -> stopOngoingCallDetection(result) "storeExternalId" -> storeExternalId(call, result) "removeExternalId" -> removeExternalId(result) else -> result.notImplemented() @@ -203,10 +196,103 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { */ private fun isScreenCaptureBlocked(result: MethodChannel.Result) { runResultCatching(result) { - result.success(Talsec.isScreenCaptureBlocked()) + result.success(Talsec.isScreenCaptureBlocked(requireActivity("check screen capture state"))) + } + } + + /** + * Blocks or unblocks accessibility services from reading or controlling the screen. + * + * @param call The method call containing the enable flag. + * @param result The result handler of the method call. + */ + private fun blockScreenControl(call: MethodCall, result: MethodChannel.Result) { + runResultCatching(result) { + val enable = call.argument("enable") ?: throw NullPointerException("Enable flag cannot be null.") + Talsec.blockScreenControl(requireActivity("block screen control"), enable) + result.success(null) } } + /** + * Checks if screen control is blocked. + * + * @param result The result handler of the method call. + */ + private fun isScreenControlBlocked(result: MethodChannel.Result) { + runResultCatching(result) { + result.success(Talsec.isScreenControlBlocked(requireActivity("check screen control state"))) + } + } + + /** + * Hides or shows overlay windows of other apps over the activity. Requires Android 12+ and the + * HIDE_OVERLAY_WINDOWS permission, otherwise `false` is returned. + * + * @param call The method call containing the enable flag. + * @param result The result handler of the method call. + */ + // Missing permission is suppressed because the decision to use the overlay API is made + // by developer, and not enforced by the library. + @SuppressLint("MissingPermission") + private fun blockOverlayWindows(call: MethodCall, result: MethodChannel.Result) { + runResultCatching(result) { + val enable = call.argument("enable") ?: throw NullPointerException("Enable flag cannot be null.") + val activity = requireActivity("block overlay windows") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + result.success(false) + return@runResultCatching + } + result.success(Talsec.blockOverlayWindows(activity, enable)) + } + } + + /** + * Checks if overlay windows of other apps are hidden over the activity. + * + * @param result The result handler of the method call. + */ + private fun isOverlayWindowsBlocked(result: MethodChannel.Result) { + runResultCatching(result) { + result.success(Talsec.isOverlayWindowsBlocked(requireActivity("check overlay windows state"))) + } + } + + /** + * Starts the ongoing call detection for the current activity. + * + * @param result The result handler of the method call. + */ + private fun startOngoingCallDetection(result: MethodChannel.Result) { + runResultCatching(result) { + val activity = requireActivity("start ongoing call detection") + if (!isOngoingCallDetectionEnabled) { + Talsec.startOngoingCallDetection(activity) + isOngoingCallDetectionEnabled = true + } + result.success(null) + } + } + + /** + * Stops the ongoing call detection for the current activity. + * + * @param result The result handler of the method call. + */ + private fun stopOngoingCallDetection(result: MethodChannel.Result) { + runResultCatching(result) { + if (isOngoingCallDetectionEnabled) { + activity?.let { Talsec.stopOngoingCallDetection(it) } + isOngoingCallDetectionEnabled = false + } + result.success(null) + } + } + + private fun requireActivity(action: String): Activity { + return activity ?: throw IllegalStateException("Unable to $action - activity is null") + } + /** * Stores an external ID. * diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt similarity index 75% rename from android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt index 8b00478..cda31c7 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/PluginThreatHandler.kt @@ -1,12 +1,14 @@ -package com.aheaditec.freerasp.handlers +package app.talsec.rasp.flutter.handlers import android.content.Context -import com.aheaditec.freerasp.RaspExecutionStateEvent -import com.aheaditec.freerasp.Threat -import com.aheaditec.freerasp.dispatchers.ExecutionStateDispatcher -import com.aheaditec.freerasp.dispatchers.ThreatDispatcher +import app.talsec.rasp.flutter.RaspExecutionStateEvent +import app.talsec.rasp.flutter.Threat +import app.talsec.rasp.flutter.dispatchers.ExecutionStateDispatcher +import app.talsec.rasp.flutter.dispatchers.ThreatDispatcher +import app.talsec.rasp.flutter.toFlutterMap import app.talsec.rasp.security.api.SuspiciousAppInfo import app.talsec.rasp.security.api.ThreatListener +import app.talsec.rasp.security.api.ThreatListener.CallState import app.talsec.rasp.security.api.ThreatListener.DeviceState import app.talsec.rasp.security.api.ThreatListener.RaspExecutionState import app.talsec.rasp.security.api.ThreatListener.ThreatDetected @@ -18,6 +20,9 @@ import app.talsec.rasp.security.api.ThreatListener.ThreatDetected */ internal object PluginThreatHandler { + /** Application context, used to resolve the details of apps reported as malware. */ + private var appContext: Context? = null + private val threatDetected = object : ThreatDetected() { override fun onPrivilegedAccess() { ThreatDispatcher.dispatchThreat(Threat.PrivilegedAccess) @@ -52,7 +57,8 @@ internal object PluginThreatHandler { } override fun onMalware(suspiciousApps: List) { - ThreatDispatcher.dispatchMalware(suspiciousApps) + val context = appContext ?: return + ThreatDispatcher.dispatchMalware(suspiciousApps.map { it.toFlutterMap(context) }) } override fun onScreenshot() { @@ -86,6 +92,10 @@ internal object PluginThreatHandler { override fun onBootloader() { ThreatDispatcher.dispatchThreat(Threat.Bootloader) } + + override fun onOngoingCall(state: CallState) { + ThreatDispatcher.dispatchOngoingCall(state) + } } private val deviceState = object : DeviceState() { @@ -111,14 +121,23 @@ internal object PluginThreatHandler { } private val raspExecutionState = object : RaspExecutionState() { - override fun onAllChecksFinished() { - ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.AllChecksFinished) + override fun onCriticalChecksFinished() { + ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.CriticalChecksFinished) + } + + override fun onFastChecksFinished() { + ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.FastChecksFinished) + } + + override fun onPostponedChecksFinished() { + ExecutionStateDispatcher.dispatch(RaspExecutionStateEvent.PostponedChecksFinished) } } private val internalListener = ThreatListener(threatDetected, deviceState, raspExecutionState) internal fun registerListener(context: Context) { + appContext = context.applicationContext internalListener.registerListener(context) } diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/StreamHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/StreamHandler.kt similarity index 98% rename from android/src/main/kotlin/com/aheaditec/freerasp/handlers/StreamHandler.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/handlers/StreamHandler.kt index 0156c1c..7b9e1f7 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/StreamHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/StreamHandler.kt @@ -1,4 +1,4 @@ -package com.aheaditec.freerasp.handlers +package app.talsec.rasp.flutter.handlers import io.flutter.Log import io.flutter.plugin.common.BinaryMessenger diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt similarity index 89% rename from android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt rename to android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt index 93a3958..fd20acd 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt +++ b/android/src/main/kotlin/app/talsec/rasp/flutter/handlers/TalsecThreatHandler.kt @@ -1,13 +1,14 @@ -package com.aheaditec.freerasp.handlers +package app.talsec.rasp.flutter.handlers import android.content.Context import android.os.Build -import com.aheaditec.freerasp.RaspExecutionStateEvent -import com.aheaditec.freerasp.ScreenProtector -import com.aheaditec.freerasp.dispatchers.ExecutionStateDispatcher -import com.aheaditec.freerasp.dispatchers.ThreatDispatcher +import app.talsec.rasp.flutter.RaspExecutionStateEvent +import app.talsec.rasp.flutter.ScreenProtector +import app.talsec.rasp.flutter.dispatchers.ExecutionStateDispatcher +import app.talsec.rasp.flutter.dispatchers.ThreatDispatcher import app.talsec.rasp.security.api.Talsec import app.talsec.rasp.security.api.TalsecConfig +import app.talsec.rasp.security.api.TalsecMode import io.flutter.plugin.common.EventChannel.EventSink /** @@ -24,7 +25,7 @@ internal object TalsecThreatHandler { */ internal fun start(context: Context, config: TalsecConfig) { attachListener(context) - Talsec.start(context, config) + Talsec.start(context, config, TalsecMode.BACKGROUND) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { ScreenProtector.enable() } @@ -132,14 +133,6 @@ internal object TalsecThreatHandler { internal fun detachExecutionStateSink() { ExecutionStateDispatcher.eventSink = null } - - internal fun attachMethodSink(sink: MethodCallHandler.MethodSink) { - ThreatDispatcher.methodSink = sink - } - - internal fun detachMethodSink() { - ThreatDispatcher.methodSink = null - } } diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/RaspExecutionStateEvent.kt b/android/src/main/kotlin/com/aheaditec/freerasp/RaspExecutionStateEvent.kt deleted file mode 100644 index 998e75c..0000000 --- a/android/src/main/kotlin/com/aheaditec/freerasp/RaspExecutionStateEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.aheaditec.freerasp - -internal sealed class RaspExecutionStateEvent(val value: Int) { - object AllChecksFinished : RaspExecutionStateEvent(187429) -} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ThreatDispatcher.kt b/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ThreatDispatcher.kt deleted file mode 100644 index 9afd875..0000000 --- a/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ThreatDispatcher.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.aheaditec.freerasp.dispatchers - -import com.aheaditec.freerasp.Threat -import com.aheaditec.freerasp.handlers.MethodCallHandler -import app.talsec.rasp.security.api.SuspiciousAppInfo -import io.flutter.plugin.common.EventChannel.EventSink - -internal object ThreatDispatcher { - private val threatCache = mutableSetOf() - private val malwareCache = mutableListOf() - private var isAppInForeground = false - - var eventSink: EventSink? = null - set(value) { - field = value - if (value != null) { - isAppInForeground = true - flushThreatCache() - } - } - - var methodSink: MethodCallHandler.MethodSink? = null - set(value) { - field = value - if (value != null) { - isAppInForeground = true - flushMalwareCache() - } - } - - fun onResume() { - isAppInForeground = true - if (eventSink != null) { - flushThreatCache() - } - if (methodSink != null) { - flushMalwareCache() - } - } - - fun onPause() { - isAppInForeground = false - } - - fun dispatchThreat(threat: Threat) { - if (isAppInForeground && eventSink != null) { - eventSink?.success(threat.value) - } else { - synchronized(threatCache) { - threatCache.add(threat) - } - } - } - - fun dispatchMalware(apps: List) { - if (isAppInForeground && methodSink != null) { - methodSink?.onMalwareDetected(apps) - } else { - synchronized(malwareCache) { - malwareCache.addAll(apps) - } - } - } - - private fun flushThreatCache() { - val threats = synchronized(threatCache) { - val snapshot = threatCache.toSet() - threatCache.clear() - snapshot - } - threats.forEach { eventSink?.success(it.value) } - } - - private fun flushMalwareCache() { - val malware = synchronized(malwareCache) { - val snapshot = malwareCache.toMutableList() - malwareCache.clear() - snapshot - } - if (malware.isNotEmpty()) { - methodSink?.onMalwareDetected(malware) - } - } -} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/generated/TalsecPigeonApi.kt b/android/src/main/kotlin/com/aheaditec/freerasp/generated/TalsecPigeonApi.kt deleted file mode 100644 index 97e8a59..0000000 --- a/android/src/main/kotlin/com/aheaditec/freerasp/generated/TalsecPigeonApi.kt +++ /dev/null @@ -1,142 +0,0 @@ -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon -@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") - -package com.aheaditec.freerasp.generated - -import android.util.Log -import io.flutter.plugin.common.BasicMessageChannel -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MessageCodec -import io.flutter.plugin.common.StandardMethodCodec -import io.flutter.plugin.common.StandardMessageCodec -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer - -private fun createConnectionError(channelName: String): FlutterError { - return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "")} - -/** - * Error class for passing custom error details to Flutter via a thrown PlatformException. - * @property code The error code. - * @property message The error message. - * @property details The error details. Must be a datatype supported by the api codec. - */ -class FlutterError ( - val code: String, - override val message: String? = null, - val details: Any? = null -) : Throwable() - -/** Generated class from Pigeon that represents data sent in messages. */ -data class PackageInfo ( - val packageName: String, - val appIcon: String? = null, - val appName: String? = null, - val version: String? = null, - val installerStore: String? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): PackageInfo { - val packageName = pigeonVar_list[0] as String - val appIcon = pigeonVar_list[1] as String? - val appName = pigeonVar_list[2] as String? - val version = pigeonVar_list[3] as String? - val installerStore = pigeonVar_list[4] as String? - return PackageInfo(packageName, appIcon, appName, version, installerStore) - } - } - fun toList(): List { - return listOf( - packageName, - appIcon, - appName, - version, - installerStore, - ) - } -} - -/** Generated class from Pigeon that represents data sent in messages. */ -data class SuspiciousAppInfo ( - val packageInfo: PackageInfo, - val reasons: List, - val permissions: List? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): SuspiciousAppInfo { - val packageInfo = pigeonVar_list[0] as PackageInfo - val reasons = pigeonVar_list[1] as List - @Suppress("UNCHECKED_CAST") - val permissions = pigeonVar_list[2] as List? - return SuspiciousAppInfo(packageInfo, reasons, permissions) - } - } - fun toList(): List { - return listOf( - packageInfo, - reasons, - permissions, - ) - } -} -private open class TalsecPigeonApiPigeonCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return when (type) { - 129.toByte() -> { - return (readValue(buffer) as? List)?.let { - PackageInfo.fromList(it) - } - } - 130.toByte() -> { - return (readValue(buffer) as? List)?.let { - SuspiciousAppInfo.fromList(it) - } - } - else -> super.readValueOfType(type, buffer) - } - } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - when (value) { - is PackageInfo -> { - stream.write(129) - writeValue(stream, value.toList()) - } - is SuspiciousAppInfo -> { - stream.write(130) - writeValue(stream, value.toList()) - } - else -> super.writeValue(stream, value) - } - } -} - -/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ -class TalsecPigeonApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { - companion object { - /** The codec used by TalsecPigeonApi. */ - val codec: MessageCodec by lazy { - TalsecPigeonApiPigeonCodec() - } - } - fun onMalwareDetected(packageInfoArg: List, callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(packageInfoArg)) { - if (it is List<*>) { - if (it.size > 1) { - callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) - } else { - callback(Result.success(Unit)) - } - } else { - callback(Result.failure(createConnectionError(channelName))) - } - } - } -} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index f700a23..2a935ab 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 78a684d..adf6d5f 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -14,7 +14,7 @@ class RunnerTests: XCTestCase { private let validConfig = """ {"watcherMail":"security@example.com","isProd":false,\ - "iosConfig":{"bundleIds":["app.talsec.freeraspExample"],"teamId":"ABCDE12345"}} + "iosConfig":{"appBundleIds":["app.talsec.freeraspExample"],"appTeamId":"ABCDE12345"}} """ override func setUp() { diff --git a/example/lib/main.dart b/example/lib/main.dart index 2e80cf9..88ca173 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,7 @@ // ignore_for_file: public_member_api_docs, avoid_redundant_argument_values +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freerasp/freerasp.dart'; @@ -36,8 +38,8 @@ Future _initializeTalsec() async { final config = TalsecConfig( androidConfig: AndroidConfig( packageName: 'com.aheaditec.freeraspExample', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], - supportedStores: ['com.sec.android.app.samsungapps'], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + supportedAlternativeStores: ['com.sec.android.app.samsungapps'], suspiciousAppDetectionConfig: const SuspiciousAppDetectionConfig( packageNames: ['com.aheaditec.freeraspExample'], requestedPermissions: [ @@ -51,8 +53,8 @@ Future _initializeTalsec() async { ), ), iosConfig: IOSConfig( - bundleIds: ['com.aheaditec.freeraspExample'], - teamId: 'M8AK35...', + appBundleIds: ['com.aheaditec.freeraspExample'], + appTeamId: 'M8AK35...', ), watcherMail: 'your_mail@example.com', isProd: true, @@ -60,6 +62,11 @@ Future _initializeTalsec() async { ); await Talsec.instance.start(config); + + if (Platform.isAndroid) { + // Report cellular and VoIP calls through ThreatCallback.onOngoingCall + await Talsec.instance.startOngoingCallDetection(); + } } /// The root widget of the application @@ -205,12 +212,37 @@ class _HomePageState extends ConsumerState { ], ), ), + if (Platform.isAndroid) ...[ + ProtectionToggle( + title: 'Screen Control', + isBlocked: Talsec.instance.isScreenControlBlocked, + block: Talsec.instance.blockScreenControl, + ), + ProtectionToggle( + title: 'Overlay Windows', + isBlocked: Talsec.instance.isOverlayWindowsBlocked, + block: ({required enabled}) => + Talsec.instance.blockOverlayWindows(enabled: enabled), + ), + ], ListTile( title: const Text('Check Rounds Completed'), trailing: SafetyIcon(isDetected: !threatState.allChecksPassed), ), + if (Platform.isAndroid) ...[ + ListTile( + title: const Text('Postponed Checks Completed'), + trailing: SafetyIcon( + isDetected: !threatState.postponedChecksPassed, + ), + ), + ], Expanded( - child: ThreatListView(threats: threatState.detectedThreats), + child: ThreatListView( + threats: threatState.detectedThreats, + showOngoingCall: Platform.isAndroid, + callState: threatState.callState, + ), ), ], ), diff --git a/example/lib/threat_notifier.dart b/example/lib/threat_notifier.dart index 5cebb66..acbdaa4 100644 --- a/example/lib/threat_notifier.dart +++ b/example/lib/threat_notifier.dart @@ -16,7 +16,7 @@ class ThreatNotifier extends AutoDisposeNotifier { onHooks: () => _updateThreat(Threat.hooks), onDebug: () => _updateThreat(Threat.debug), onPasscode: () => _updateThreat(Threat.passcode), - onDeviceID: () => _updateThreat(Threat.deviceId), + onDeviceID: () => _updateThreat(Threat.deviceID), onSimulator: () => _updateThreat(Threat.simulator), onAppIntegrity: () => _updateThreat(Threat.appIntegrity), onObfuscationIssues: () => _updateThreat(Threat.obfuscationIssues), @@ -27,19 +27,22 @@ class ThreatNotifier extends AutoDisposeNotifier { _updateThreat(Threat.secureHardwareNotAvailable), onSystemVPN: () => _updateThreat(Threat.systemVPN), onDevMode: () => _updateThreat(Threat.devMode), - onADBEnabled: () => _updateThreat(Threat.adbEnabled), + onAdbEnabled: () => _updateThreat(Threat.adbEnabled), onScreenshot: () => _updateThreat(Threat.screenshot), onScreenRecording: () => _updateThreat(Threat.screenRecording), onMultiInstance: () => _updateThreat(Threat.multiInstance), - onUnsecureWiFi: () => _updateThreat(Threat.unsecureWiFi), + onUnsecureWifi: () => _updateThreat(Threat.unsecureWifi), onTimeSpoofing: () => _updateThreat(Threat.timeSpoofing), onLocationSpoofing: () => _updateThreat(Threat.locationSpoofing), onAutomation: () => _updateThreat(Threat.automation), onBootloader: () => _updateThreat(Threat.bootloader), + onOngoingCall: _updateCallState, ); - final raspExecutionStateCallback = - RaspExecutionStateCallback(onAllChecksFinished: _updateChecksStatus); + final raspExecutionStateCallback = RaspExecutionStateCallback( + onFastChecksFinished: _updateChecksStatus, + onPostponedChecksFinished: _updatePostponedChecksStatus, + ); Talsec.instance.attachListener(threatCallback); Talsec.instance.attachExecutionStateListener(raspExecutionStateCallback); @@ -49,11 +52,19 @@ class ThreatNotifier extends AutoDisposeNotifier { state = state.copyWith(detectedThreats: {...state.detectedThreats, threat}); } - void _updateMalware(List malware) { - state = state.copyWith(detectedMalware: malware.nonNulls.toList()); + void _updateMalware(List malware) { + state = state.copyWith(detectedMalware: malware); } void _updateChecksStatus() { state = state.copyWith(allChecksPassed: true); } + + void _updatePostponedChecksStatus() { + state = state.copyWith(postponedChecksPassed: true); + } + + void _updateCallState(CallState callState) { + state = state.copyWith(callState: callState); + } } diff --git a/example/lib/threat_state.dart b/example/lib/threat_state.dart index 85efba0..d427a8a 100644 --- a/example/lib/threat_state.dart +++ b/example/lib/threat_state.dart @@ -7,28 +7,39 @@ class ThreatState { detectedThreats: {}, detectedMalware: [], allChecksPassed: false, + postponedChecksPassed: false, + callState: null, ); const ThreatState._({ required this.detectedThreats, required this.detectedMalware, required this.allChecksPassed, + required this.postponedChecksPassed, + required this.callState, }); final Set detectedThreats; final List detectedMalware; final bool allChecksPassed; + final bool postponedChecksPassed; + final CallState? callState; ThreatState copyWith({ Set? detectedThreats, List? detectedMalware, bool? allChecksPassed, + bool? postponedChecksPassed, + CallState? callState, }) { return ThreatState._( detectedThreats: detectedThreats ?? this.detectedThreats, detectedMalware: detectedMalware?.nonNulls.toList() ?? this.detectedMalware, allChecksPassed: allChecksPassed ?? this.allChecksPassed, + postponedChecksPassed: + postponedChecksPassed ?? this.postponedChecksPassed, + callState: callState ?? this.callState, ); } } diff --git a/example/lib/widgets/protection_toggle.dart b/example/lib/widgets/protection_toggle.dart new file mode 100644 index 0000000..ea9d223 --- /dev/null +++ b/example/lib/widgets/protection_toggle.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// A row with a button that toggles an Android screen protection. +class ProtectionToggle extends StatefulWidget { + /// Creates a [ProtectionToggle]. + const ProtectionToggle({ + required this.title, + required this.isBlocked, + required this.block, + super.key, + }); + + /// Name of the protection. + final String title; + + /// Reads whether the protection is active. + final Future Function() isBlocked; + + /// Turns the protection on or off. + final Future Function({required bool enabled}) block; + + @override + State createState() => _ProtectionToggleState(); +} + +class _ProtectionToggleState extends State { + bool _isBlocked = false; + + Future _toggle() async { + await widget.block(enabled: !_isBlocked); + final isBlocked = await widget.isBlocked(); + if (mounted) setState(() => _isBlocked = isBlocked); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleMedium), + ElevatedButton( + onPressed: _toggle, + style: ElevatedButton.styleFrom( + backgroundColor: _isBlocked ? Colors.green : Colors.red, + ), + child: Text( + _isBlocked ? 'Protected' : 'Unprotected', + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/widgets/threat_listview.dart b/example/lib/widgets/threat_listview.dart index 2342436..06c7ece 100644 --- a/example/lib/widgets/threat_listview.dart +++ b/example/lib/widgets/threat_listview.dart @@ -8,28 +8,73 @@ class ThreatListView extends StatelessWidget { /// Represents a list of detected threats const ThreatListView({ required this.threats, + this.showOngoingCall = false, + this.callState, super.key, }); /// Set of detected threats final Set threats; + /// Whether to show the ongoing call row (Android only) + final bool showOngoingCall; + + /// Last reported state of an ongoing call + final CallState? callState; + @override Widget build(BuildContext context) { + // Ongoing call carries a state instead of being a Threat value, so it is + // appended to the list as an extra row. + final itemCount = Threat.values.length + (showOngoingCall ? 1 : 0); + return ListView.separated( padding: const EdgeInsets.all(8), - itemCount: Threat.values.length, + itemCount: itemCount, itemBuilder: (context, index) { - final currentThreat = Threat.values[index]; - final isDetected = threats.contains(currentThreat); + if (index == Threat.values.length) { + final isDetected = + callState == CallState.started || callState == CallState.ongoing; + return _ThreatTile( + name: 'OngoingCall', + isDetected: isDetected, + subtitle: switch (callState) { + null => 'Not detected', + CallState.started => 'Started', + CallState.ongoing => 'Ongoing', + CallState.stopped => 'Stopped', + }, + ); + } - return ListTile( - title: Text(currentThreat.name.capitalize()), - subtitle: Text(isDetected ? 'Danger' : 'Safe'), - trailing: SafetyIcon(isDetected: isDetected), + final currentThreat = Threat.values[index]; + return _ThreatTile( + name: currentThreat.name.capitalize(), + isDetected: threats.contains(currentThreat), ); }, separatorBuilder: (_, __) => const Divider(height: 1), ); } } + +class _ThreatTile extends StatelessWidget { + const _ThreatTile({ + required this.name, + required this.isDetected, + this.subtitle, + }); + + final String name; + final bool isDetected; + final String? subtitle; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(name), + subtitle: Text(subtitle ?? (isDetected ? 'Danger' : 'Safe')), + trailing: SafetyIcon(isDetected: isDetected), + ); + } +} diff --git a/example/lib/widgets/widgets.dart b/example/lib/widgets/widgets.dart index 2342ae2..78092a2 100644 --- a/example/lib/widgets/widgets.dart +++ b/example/lib/widgets/widgets.dart @@ -1,3 +1,4 @@ export 'malware_bottom_sheet.dart'; +export 'protection_toggle.dart'; export 'safety_icon.dart'; export 'threat_listview.dart'; diff --git a/ios/freerasp/Sources/FlutterTalsecConfig.swift b/ios/freerasp/Sources/FlutterTalsecConfig.swift index 6d261b0..20e525e 100644 --- a/ios/freerasp/Sources/FlutterTalsecConfig.swift +++ b/ios/freerasp/Sources/FlutterTalsecConfig.swift @@ -7,11 +7,11 @@ struct FlutterTalsecConfig : Decodable { let isProd: Bool func toNativeConfig() -> TalsecConfig { - return TalsecConfig(appBundleIds: iosConfig.bundleIds, appTeamId: iosConfig.teamId, watcherMailAddress: watcherMail, isProd: isProd) + return TalsecConfig(appBundleIds: iosConfig.appBundleIds, appTeamId: iosConfig.appTeamId, watcherMailAddress: watcherMail, isProd: isProd) } } struct IOSConfig : Decodable { - let bundleIds: [String] - let teamId: String + let appBundleIds: [String] + let appTeamId: String } diff --git a/ios/freerasp/Sources/Models/RaspExecutionStates.swift b/ios/freerasp/Sources/Models/RaspExecutionStates.swift index 4ea3577..4d42af3 100644 --- a/ios/freerasp/Sources/Models/RaspExecutionStates.swift +++ b/ios/freerasp/Sources/Models/RaspExecutionStates.swift @@ -1,3 +1,3 @@ enum RaspExecutionStates: Int { - case allChecksFinished = 187429 + case fastChecksFinished = 187429 } diff --git a/ios/freerasp/Sources/TalsecHandlers.swift b/ios/freerasp/Sources/TalsecHandlers.swift index 51e2a68..d1ca42b 100644 --- a/ios/freerasp/Sources/TalsecHandlers.swift +++ b/ios/freerasp/Sources/TalsecHandlers.swift @@ -26,8 +26,9 @@ extension SecurityThreatCenter: SecurityThreatHandler, TalsecRuntime.RaspExecuti ThreatDispatcher.shared.dispatch(threat: securityThreat) } + // The iOS SDK reports its standard checks as a single "all checks" event, which maps to fastChecksFinished. public func onAllChecksFinished() { - ExecutionStateDispatcher.shared.dispatch(event: .allChecksFinished) + ExecutionStateDispatcher.shared.dispatch(event: .fastChecksFinished) } } diff --git a/lib/freerasp.dart b/lib/freerasp.dart index f504da3..cd681e3 100644 --- a/lib/freerasp.dart +++ b/lib/freerasp.dart @@ -2,9 +2,7 @@ export 'src/callbacks/rasp_execution_state_callback.dart'; export 'src/callbacks/threat_callback.dart'; export 'src/enums/enums.dart'; export 'src/errors/errors.dart'; -export 'src/generated/talsec_pigeon_api.g.dart' - show PackageInfo, SuspiciousAppInfo; export 'src/models/models.dart'; -export 'src/talsec.dart'; +export 'src/talsec.dart' hide createTalsecForTesting; export 'src/typedefs.dart'; export 'src/utils/utils.dart' hide ConfigVerifier; diff --git a/lib/src/callbacks/rasp_execution_state_callback.dart b/lib/src/callbacks/rasp_execution_state_callback.dart index 6bb24e3..b5d8060 100644 --- a/lib/src/callbacks/rasp_execution_state_callback.dart +++ b/lib/src/callbacks/rasp_execution_state_callback.dart @@ -6,8 +6,8 @@ import 'package:freerasp/src/typedefs.dart'; /// Example usage: /// ```dart /// final callback = RaspExecutionStateCallback( -/// onAllChecksFinished: () { -/// print('All security checks have been completed'); +/// onFastChecksFinished: () { +/// print('Fast security checks have been completed'); /// // Update UI or perform additional actions /// }, /// ); @@ -17,12 +17,30 @@ import 'package:freerasp/src/typedefs.dart'; class RaspExecutionStateCallback { /// Creates a new [RaspExecutionStateCallback] instance. /// - /// The [onAllChecksFinished] callback will be invoked when all security - /// checks have been completed by the native security engine. + /// The [onFastChecksFinished] callback will be invoked when the fast + /// security checks have been completed by the native security engine. RaspExecutionStateCallback({ - this.onAllChecksFinished, + this.onFastChecksFinished, + this.onCriticalChecksFinished, + this.onPostponedChecksFinished, }); - /// Callback invoked when all security checks are completed. - final VoidCallback? onAllChecksFinished; + /// Callback invoked when the fast (standard) security checks are completed. + /// + /// On Android, the postponed checks may still run afterwards; use + /// [onPostponedChecksFinished] to know when the initial evaluation has fully + /// ended. On iOS, this is invoked when all security checks are completed. + final VoidCallback? onFastChecksFinished; + + /// Callback invoked when the critical security checks are completed, + /// before the non-critical checks. + /// + /// Android only + final VoidCallback? onCriticalChecksFinished; + + /// Callback invoked when the postponed security checks are completed, + /// which ends the initial evaluation. + /// + /// Android only + final VoidCallback? onPostponedChecksFinished; } diff --git a/lib/src/callbacks/threat_callback.dart b/lib/src/callbacks/threat_callback.dart index 9e48f91..e612b0e 100644 --- a/lib/src/callbacks/threat_callback.dart +++ b/lib/src/callbacks/threat_callback.dart @@ -1,4 +1,3 @@ -import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart'; import 'package:freerasp/src/typedefs.dart'; /// A class which represents a set of callbacks that are used to notify the @@ -18,7 +17,7 @@ import 'package:freerasp/src/typedefs.dart'; /// // Attaching callback to Talsec /// Talsec.instance.attachListener(callback); /// ``` -class ThreatCallback extends TalsecPigeonApi { +class ThreatCallback { /// Constructs a [ThreatCallback] instance. ThreatCallback({ this.onHooks, @@ -34,16 +33,17 @@ class ThreatCallback extends TalsecPigeonApi { this.onSecureHardwareNotAvailable, this.onSystemVPN, this.onDevMode, - this.onADBEnabled, + this.onAdbEnabled, this.onMalware, this.onScreenshot, this.onScreenRecording, this.onMultiInstance, - this.onUnsecureWiFi, + this.onUnsecureWifi, this.onTimeSpoofing, this.onLocationSpoofing, this.onAutomation, this.onBootloader, + this.onOngoingCall, }); /// This method is called when a threat related dynamic hooking (e.g. Frida) @@ -93,7 +93,7 @@ class ThreatCallback extends TalsecPigeonApi { final VoidCallback? onDevMode; /// This method is called when the device has active ADB (Android Debug) - final VoidCallback? onADBEnabled; + final VoidCallback? onAdbEnabled; /// This method is called when screenshot detected final VoidCallback? onScreenshot; @@ -105,7 +105,7 @@ class ThreatCallback extends TalsecPigeonApi { final VoidCallback? onMultiInstance; /// This method is called when unsecure WiFi is detected - final VoidCallback? onUnsecureWiFi; + final VoidCallback? onUnsecureWifi; /// This method is called when time manipulation is detected final VoidCallback? onTimeSpoofing; @@ -122,13 +122,18 @@ class ThreatCallback extends TalsecPigeonApi { /// Android only final VoidCallback? onBootloader; - @override - void onMalwareDetected(List packageInfo) { - onMalware?.call(packageInfo); - } - /// This method is called when malware is detected. /// /// Android only final MalwareCallback? onMalware; + + /// This method is called when an ongoing call (cellular or VoIP) starts, + /// is already in progress, or stops while ongoing call detection is + /// running. + /// + /// Detection has to be started with + /// `Talsec.instance.startOngoingCallDetection()`. + /// + /// Android only + final OngoingCallCallback? onOngoingCall; } diff --git a/lib/src/enums/call_state.dart b/lib/src/enums/call_state.dart new file mode 100644 index 0000000..6264fd4 --- /dev/null +++ b/lib/src/enums/call_state.dart @@ -0,0 +1,13 @@ +/// State of an ongoing call reported by the ongoing call detection. +/// +/// Android only +enum CallState { + /// A call started while the detection was running. + started, + + /// A call was already in progress when the detection started. + ongoing, + + /// A previously reported call has ended. + stopped, +} diff --git a/lib/src/enums/enums.dart b/lib/src/enums/enums.dart index ec737bf..f2b5d15 100644 --- a/lib/src/enums/enums.dart +++ b/lib/src/enums/enums.dart @@ -1,2 +1,3 @@ +export 'call_state.dart'; export 'rasp_execution_state.dart'; export 'threat.dart'; diff --git a/lib/src/enums/rasp_execution_state.dart b/lib/src/enums/rasp_execution_state.dart index 2bb8d5f..67b78b9 100644 --- a/lib/src/enums/rasp_execution_state.dart +++ b/lib/src/enums/rasp_execution_state.dart @@ -1,8 +1,23 @@ /// Represents the state of the RASP (Runtime Application Self-Protection) /// execution. enum RaspExecutionState { - /// All security checks have been completed. - allChecksFinished, + /// The fast (standard) security checks have been completed. + /// + /// On Android, the postponed checks may still run afterwards. On iOS, this + /// is triggered when all security checks have been completed. + fastChecksFinished, + + /// The critical security checks have been completed, before the + /// non-critical checks. + /// + /// Android only + criticalChecksFinished, + + /// The postponed security checks have been completed, which ends the + /// initial evaluation. + /// + /// Android only + postponedChecksFinished, } /// Extensions for [RaspExecutionState]. @@ -11,7 +26,11 @@ extension RaspExecutionStateX on RaspExecutionState { static RaspExecutionState fromInt(int value) { switch (value) { case 187429: - return RaspExecutionState.allChecksFinished; + return RaspExecutionState.fastChecksFinished; + case 614702: + return RaspExecutionState.criticalChecksFinished; + case 952318: + return RaspExecutionState.postponedChecksFinished; default: throw ArgumentError('Unknown execution state code: $value'); } diff --git a/lib/src/enums/threat.dart b/lib/src/enums/threat.dart index 316a199..b7f5e21 100644 --- a/lib/src/enums/threat.dart +++ b/lib/src/enums/threat.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - /// Possible threats which can occur in Flutter app. enum Threat { /// The application is being hooked (e.g. Frida), indicating that the app is @@ -17,7 +15,7 @@ enum Threat { /// The application was reinstalled. /// /// iOS only. - deviceId, + deviceID, /// The application is running on a simulator, potentially indicating that /// it is being analyzed or reverse engineered. @@ -77,7 +75,7 @@ enum Threat { /// or weak security protocol. /// /// Android only - unsecureWiFi, + unsecureWifi, /// This method is called when the device time is manipulated timeSpoofing, @@ -102,8 +100,8 @@ enum Threat { extension ThreatX on Threat { /// Converts a [int] code to an instance of the [Threat] enum. /// - /// Kills an app if the given int [code] does not match any of the predefined - /// values for the [Threat] enum. + /// Throws an [ArgumentError] if the given int [code] does not match any of + /// the predefined values for the [Threat] enum. /// /// Example usage: /// @@ -111,12 +109,11 @@ extension ThreatX on Threat { /// final threat = ThreatX.fromInt(1268968002); // Threat.debug /// ``` /// - /// Shared error codes: + /// Threat codes: /// * 1268968002 - debug /// * 209533833 - hooks - /// * 55943254 - overlay /// * 1293399086 - passcode - /// * 1514211414 - deviceId + /// * 1514211414 - deviceID /// * 477190884 - simulator /// * 1115787534 - appIntegrity /// * 1001443554 - obfuscationIssues @@ -130,6 +127,9 @@ extension ThreatX on Threat { /// * 705651459 - screenshot /// * 64690214 - screenRecording /// * 859307284 - multiInstance + /// * 363588890 - unsecureWifi + /// * 189105221 - timeSpoofing + /// * 653273273 - locationSpoofing /// * 298453120 - automation /// * 1651076936 - bootloader static Threat fromInt(int code) { @@ -141,7 +141,7 @@ extension ThreatX on Threat { case 1293399086: return Threat.passcode; case 1514211414: - return Threat.deviceId; + return Threat.deviceID; case 477190884: return Threat.simulator; case 1115787534: @@ -169,7 +169,7 @@ extension ThreatX on Threat { case 859307284: return Threat.multiInstance; case 363588890: - return Threat.unsecureWiFi; + return Threat.unsecureWifi; case 189105221: return Threat.timeSpoofing; case 653273273: @@ -179,8 +179,7 @@ extension ThreatX on Threat { case 1651076936: return Threat.bootloader; default: - // Unknown data came from native code. This shouldn't normally happen. - exit(127); + throw ArgumentError('Unknown threat code: $code'); } } } diff --git a/lib/src/errors/errors.dart b/lib/src/errors/errors.dart index 1d47b6b..31f6a55 100644 --- a/lib/src/errors/errors.dart +++ b/lib/src/errors/errors.dart @@ -1,2 +1,5 @@ export 'configuration_exception.dart'; +export 'external_id_failure_exception.dart'; +export 'malware_failure_exception.dart'; export 'talsec_exception.dart'; +export 'unsupported_platform_exception.dart'; diff --git a/lib/src/errors/malware_failure_exception.dart b/lib/src/errors/malware_failure_exception.dart index f6cc147..a4a63f7 100644 --- a/lib/src/errors/malware_failure_exception.dart +++ b/lib/src/errors/malware_failure_exception.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:freerasp/freerasp.dart'; /// An exception that is thrown when there is a failure in malware data parsing. @@ -9,4 +10,9 @@ class MalwareFailureException extends TalsecException { /// Constructs an instance of [MalwareFailureException]. const MalwareFailureException({super.message, super.stackTrace}) : super(code: 'malware-failure'); + + /// Constructs an instance of [MalwareFailureException] from a + /// [PlatformException]. + MalwareFailureException.fromPlatformException(PlatformException exception) + : this(message: exception.message, stackTrace: exception.stacktrace); } diff --git a/lib/src/errors/unsupported_platform_exception.dart b/lib/src/errors/unsupported_platform_exception.dart new file mode 100644 index 0000000..c4cf120 --- /dev/null +++ b/lib/src/errors/unsupported_platform_exception.dart @@ -0,0 +1,13 @@ +import 'package:freerasp/freerasp.dart'; + +/// An exception that is thrown when a method is not supported on the current +/// platform (e.g. an Android-only method called on iOS). +/// +/// This exception extends the [TalsecException] class and includes an error +/// code of 'unsupported-platform'. It can include an optional [message] and +/// [stackTrace] parameter. +class UnsupportedPlatformException extends TalsecException { + /// Constructs an instance of [UnsupportedPlatformException]. + const UnsupportedPlatformException({super.message, super.stackTrace}) + : super(code: 'unsupported-platform'); +} diff --git a/lib/src/generated/talsec_pigeon_api.g.dart b/lib/src/generated/talsec_pigeon_api.g.dart deleted file mode 100644 index ef275d9..0000000 --- a/lib/src/generated/talsec_pigeon_api.g.dart +++ /dev/null @@ -1,168 +0,0 @@ -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers - -import 'dart:async'; -import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; - -import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; -import 'package:flutter/services.dart'; - -List wrapResponse( - {Object? result, PlatformException? error, bool empty = false}) { - if (empty) { - return []; - } - if (error == null) { - return [result]; - } - return [error.code, error.message, error.details]; -} - -class PackageInfo { - PackageInfo({ - required this.packageName, - this.appIcon, - this.appName, - this.version, - this.installerStore, - }); - - String packageName; - - String? appIcon; - - String? appName; - - String? version; - - String? installerStore; - - Object encode() { - return [ - packageName, - appIcon, - appName, - version, - installerStore, - ]; - } - - static PackageInfo decode(Object result) { - result as List; - return PackageInfo( - packageName: result[0]! as String, - appIcon: result[1] as String?, - appName: result[2] as String?, - version: result[3] as String?, - installerStore: result[4] as String?, - ); - } -} - -class SuspiciousAppInfo { - SuspiciousAppInfo({ - required this.packageInfo, - required this.reasons, - this.permissions, - }); - - PackageInfo packageInfo; - - List reasons; - - List? permissions; - - Object encode() { - return [ - packageInfo, - reasons, - permissions, - ]; - } - - static SuspiciousAppInfo decode(Object result) { - result as List; - return SuspiciousAppInfo( - packageInfo: result[0]! as PackageInfo, - reasons: (result[1] as List?)!.cast(), - permissions: (result[2] as List?)?.cast(), - ); - } -} - -class _PigeonCodec extends StandardMessageCodec { - const _PigeonCodec(); - @override - void writeValue(WriteBuffer buffer, Object? value) { - if (value is int) { - buffer.putUint8(4); - buffer.putInt64(value); - } else if (value is PackageInfo) { - buffer.putUint8(129); - writeValue(buffer, value.encode()); - } else if (value is SuspiciousAppInfo) { - buffer.putUint8(130); - writeValue(buffer, value.encode()); - } else { - super.writeValue(buffer, value); - } - } - - @override - Object? readValueOfType(int type, ReadBuffer buffer) { - switch (type) { - case 129: - return PackageInfo.decode(readValue(buffer)!); - case 130: - return SuspiciousAppInfo.decode(readValue(buffer)!); - default: - return super.readValueOfType(type, buffer); - } - } -} - -abstract class TalsecPigeonApi { - static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - - void onMalwareDetected(List packageInfo); - - static void setUp( - TalsecPigeonApi? api, { - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) { - messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( - 'dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected was null.'); - final List args = (message as List?)!; - final List? arg_packageInfo = - (args[0] as List?)?.cast(); - assert(arg_packageInfo != null, - 'Argument for dev.flutter.pigeon.freerasp.TalsecPigeonApi.onMalwareDetected was null, expected non-null List.'); - try { - api.onMalwareDetected(arg_packageInfo!); - return wrapResponse(empty: true); - } on PlatformException catch (e) { - return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString())); - } - }); - } - } - } -} diff --git a/lib/src/models/android_config.dart b/lib/src/models/android_config.dart index 5543259..3ec4d87 100644 --- a/lib/src/models/android_config.dart +++ b/lib/src/models/android_config.dart @@ -10,8 +10,8 @@ class AndroidConfig { /// Android configuration fields AndroidConfig({ required this.packageName, - required this.signingCertHashes, - this.supportedStores = const [], + required this.certificateHashes, + this.supportedAlternativeStores = const [], this.suspiciousAppDetectionConfig, }) { ConfigVerifier.verifyAndroid(this); @@ -28,10 +28,10 @@ class AndroidConfig { final String packageName; /// List of expected signing hashes. - final List signingCertHashes; + final List certificateHashes; /// List of supported sources where application can be installed from. - final List supportedStores; + final List supportedAlternativeStores; /// Suspicious app detection configuration for Android. final SuspiciousAppDetectionConfig? suspiciousAppDetectionConfig; diff --git a/lib/src/models/android_config.g.dart b/lib/src/models/android_config.g.dart index 43cb86f..2550b85 100644 --- a/lib/src/models/android_config.g.dart +++ b/lib/src/models/android_config.g.dart @@ -9,13 +9,14 @@ part of 'android_config.dart'; AndroidConfig _$AndroidConfigFromJson(Map json) => AndroidConfig( packageName: json['packageName'] as String, - signingCertHashes: (json['signingCertHashes'] as List) + certificateHashes: (json['certificateHashes'] as List) .map((e) => e as String) .toList(), - supportedStores: (json['supportedStores'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], + supportedAlternativeStores: + (json['supportedAlternativeStores'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], suspiciousAppDetectionConfig: json['suspiciousAppDetectionConfig'] == null ? null : SuspiciousAppDetectionConfig.fromJson( @@ -25,8 +26,8 @@ AndroidConfig _$AndroidConfigFromJson(Map json) => Map _$AndroidConfigToJson(AndroidConfig instance) { final val = { 'packageName': instance.packageName, - 'signingCertHashes': instance.signingCertHashes, - 'supportedStores': instance.supportedStores, + 'certificateHashes': instance.certificateHashes, + 'supportedAlternativeStores': instance.supportedAlternativeStores, }; void writeNotNull(String key, dynamic value) { diff --git a/lib/src/models/ios_config.dart b/lib/src/models/ios_config.dart index f71a7c6..0dffdde 100644 --- a/lib/src/models/ios_config.dart +++ b/lib/src/models/ios_config.dart @@ -8,8 +8,8 @@ part 'ios_config.g.dart'; class IOSConfig { /// iOS configuration fields IOSConfig({ - required this.bundleIds, - required this.teamId, + required this.appBundleIds, + required this.appTeamId, }) { ConfigVerifier.verifyIOS(this); } @@ -22,8 +22,8 @@ class IOSConfig { Map toJson() => _$IOSConfigToJson(this); /// Bundle ID of the app. - final List bundleIds; + final List appBundleIds; /// Team ID of the app. - final String teamId; + final String appTeamId; } diff --git a/lib/src/models/ios_config.g.dart b/lib/src/models/ios_config.g.dart index 11a9f13..ce5081b 100644 --- a/lib/src/models/ios_config.g.dart +++ b/lib/src/models/ios_config.g.dart @@ -7,12 +7,13 @@ part of 'ios_config.dart'; // ************************************************************************** IOSConfig _$IOSConfigFromJson(Map json) => IOSConfig( - bundleIds: - (json['bundleIds'] as List).map((e) => e as String).toList(), - teamId: json['teamId'] as String, + appBundleIds: (json['appBundleIds'] as List) + .map((e) => e as String) + .toList(), + appTeamId: json['appTeamId'] as String, ); Map _$IOSConfigToJson(IOSConfig instance) => { - 'bundleIds': instance.bundleIds, - 'teamId': instance.teamId, + 'appBundleIds': instance.appBundleIds, + 'appTeamId': instance.appTeamId, }; diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart index 2db18eb..1ff1a87 100644 --- a/lib/src/models/models.dart +++ b/lib/src/models/models.dart @@ -1,4 +1,6 @@ export 'android_config.dart'; export 'ios_config.dart'; +export 'package_info.dart'; export 'suspicious_app_detection_config.dart'; +export 'suspicious_app_info.dart'; export 'talsec_config.dart'; diff --git a/lib/src/models/package_info.dart b/lib/src/models/package_info.dart new file mode 100644 index 0000000..6916892 --- /dev/null +++ b/lib/src/models/package_info.dart @@ -0,0 +1,62 @@ +import 'package:meta/meta.dart'; + +/// Information about an installed application. +@immutable +class PackageInfo { + /// Creates a [PackageInfo]. + const PackageInfo({ + required this.packageName, + this.appIcon, + this.appName, + this.version, + this.installerStore, + }); + + /// Creates a [PackageInfo] from the map sent by the native platform. + /// + /// Throws if the map does not have the expected shape. + @internal + factory PackageInfo.fromMap(Map map) { + return PackageInfo( + packageName: map['packageName']! as String, + appIcon: map['appIcon'] as String?, + appName: map['appName'] as String?, + version: map['version'] as String?, + installerStore: map['installerStore'] as String?, + ); + } + + /// Package name of the application, e.g. `com.example.app`. + final String packageName; + + /// Application icon as a Base64-encoded PNG. + /// + /// Not sent with malware detections; use `Talsec.instance.getAppIcon()`. + final String? appIcon; + + /// User-visible name of the application. + final String? appName; + + /// Version code of the application. + final String? version; + + /// Package name of the store that installed the application. + final String? installerStore; + + @override + bool operator ==(Object other) => + other is PackageInfo && + other.packageName == packageName && + other.appIcon == appIcon && + other.appName == appName && + other.version == version && + other.installerStore == installerStore; + + @override + int get hashCode => + Object.hash(packageName, appIcon, appName, version, installerStore); + + @override + String toString() => 'PackageInfo(packageName: $packageName, ' + 'appName: $appName, version: $version, installerStore: $installerStore)'; +} diff --git a/lib/src/models/suspicious_app_info.dart b/lib/src/models/suspicious_app_info.dart new file mode 100644 index 0000000..972d196 --- /dev/null +++ b/lib/src/models/suspicious_app_info.dart @@ -0,0 +1,63 @@ +import 'package:freerasp/src/models/package_info.dart'; +import 'package:meta/meta.dart'; + +/// An application reported by the malware detection. +@immutable +class SuspiciousAppInfo { + /// Creates a [SuspiciousAppInfo]. + const SuspiciousAppInfo({ + required this.packageInfo, + required this.reasons, + this.permissions, + }); + + /// Creates a [SuspiciousAppInfo] from the map sent by the native platform. + /// + /// Throws if the map does not have the expected shape. + @internal + factory SuspiciousAppInfo.fromMap(Map map) { + return SuspiciousAppInfo( + packageInfo: + PackageInfo.fromMap(map['packageInfo']! as Map), + reasons: (map['reasons']! as List).cast().toList(), + permissions: + (map['permissions'] as List?)?.cast().toList(), + ); + } + + /// The application that was reported. + final PackageInfo packageInfo; + + /// Why the application was reported, e.g. `blacklist` or `permission`. + final List reasons; + + /// Permissions of the application that caused the report, if any. + final List? permissions; + + @override + bool operator ==(Object other) => + other is SuspiciousAppInfo && + other.packageInfo == packageInfo && + _listEquals(other.reasons, reasons) && + _listEquals(other.permissions, permissions); + + @override + int get hashCode => Object.hash( + packageInfo, + Object.hashAll(reasons), + permissions == null ? null : Object.hashAll(permissions!), + ); + + @override + String toString() => 'SuspiciousAppInfo(packageInfo: $packageInfo, ' + 'reasons: $reasons, permissions: $permissions)'; +} + +bool _listEquals(List? a, List? b) { + if (identical(a, b)) return true; + if (a == null || b == null || a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/lib/src/talsec.dart b/lib/src/talsec.dart index 532e705..2fbe3d7 100644 --- a/lib/src/talsec.dart +++ b/lib/src/talsec.dart @@ -5,9 +5,18 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:freerasp/freerasp.dart'; -import 'package:freerasp/src/errors/external_id_failure_exception.dart'; -import 'package:freerasp/src/errors/malware_failure_exception.dart'; -import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart' as pigeon; + +/// Creates a [Talsec] instance that communicates over the given channels. +/// +/// For tests of this package only; it is not exported from +/// `package:freerasp/freerasp.dart`. +@visibleForTesting +Talsec createTalsecForTesting( + MethodChannel methodChannel, + EventChannel eventChannel, + EventChannel executionStateChannel, +) => + Talsec._(methodChannel, eventChannel, executionStateChannel); /// A class which maintains all security related operations. /// @@ -29,59 +38,55 @@ import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart' as pigeon; /// singleton is initialized. However, subsequent calls to the instance getter /// will return the same singleton instance without any additional delay. class Talsec { - /// Private constructor for internal and testing purposes. - @visibleForTesting - Talsec.private( - this.methodChannel, - this.eventChannel, - this.executionStateChannel, + Talsec._( + this._methodChannel, + this._eventChannel, + this._executionStateChannel, ); /// Named channel used to communicate with platform plugins. /// /// When threat is detected on platform side, [int] is sent which is then /// transformed into [Threat] using [ThreatX.fromInt]. - static const EventChannel _eventChannel = + static const EventChannel _defaultEventChannel = EventChannel('talsec.app/freerasp/events'); /// Named channel used to communicate with platform plugins. /// /// Transforms data and function calls such as [start]. - static const MethodChannel _methodChannel = + static const MethodChannel _defaultMethodChannel = MethodChannel('talsec.app/freerasp/methods'); /// Named channel used to communicate with platform plugins. /// /// Stream of execution state events. - static const EventChannel _executionStateChannel = + static const EventChannel _defaultExecutionStateChannel = EventChannel('talsec.app/freerasp/execution_state'); /// Private [Talsec] variable which holds current instance of class. - static final _instance = Talsec.private( - _methodChannel, - _eventChannel, - _executionStateChannel, + static final _instance = Talsec._( + _defaultMethodChannel, + _defaultEventChannel, + _defaultExecutionStateChannel, ); /// Initialize Talsec lazily/obtain current instance of Talsec. static Talsec get instance => _instance; /// [MethodChannel] used to interact with native platform. - @visibleForTesting - late final MethodChannel methodChannel; + final MethodChannel _methodChannel; /// [EventChannel] used to receive Threats from the native platform. - @visibleForTesting - late final EventChannel eventChannel; + final EventChannel _eventChannel; /// [EventChannel] used to receive execution state events from the native /// platform. - @visibleForTesting - late final EventChannel executionStateChannel; + final EventChannel _executionStateChannel; - StreamSubscription? _streamSubscription; + StreamSubscription? _streamSubscription; StreamSubscription? _executionStateSubscription; + Stream? _events; Stream? _onThreatDetected; Stream? _onRaspExecutionState; @@ -113,15 +118,19 @@ class Talsec { return _onThreatDetected!; } - _onThreatDetected = eventChannel - .receiveBroadcastStream() + _onThreatDetected = _threatEvents + .where((event) => event is int) .cast() - .map(ThreatX.fromInt) - .handleError(_handleStreamError); + .map((code) => _decodeOrAbort(code, ThreatX.fromInt)); return _onThreatDetected!; } + /// Broadcast stream of all events of the threat channel: an [int] threat + /// code, or a map with the payload of a malware or ongoing call detection. + Stream get _threatEvents => _events ??= + _eventChannel.receiveBroadcastStream().handleError(_handleStreamError); + /// Returns a broadcast stream. When RASP execution state changes /// [onRaspExecutionState] receives what state it is. Stream get onRaspExecutionState { @@ -129,22 +138,29 @@ class Talsec { return _onRaspExecutionState!; } - _onRaspExecutionState = executionStateChannel + _onRaspExecutionState = _executionStateChannel .receiveBroadcastStream() .cast() - .map(RaspExecutionStateX.fromInt) + .map((code) => _decodeOrAbort(code, RaspExecutionStateX.fromInt)) .handleError(_handleStreamError); return _onRaspExecutionState!; } /// Starts freeRASP with configuration provided in [config]. - Future start(TalsecConfig config) { + /// + /// Throws a [ConfigurationException] when the config for the current + /// platform is missing, or a [TalsecException] when the start fails. + Future start(TalsecConfig config) async { _checkConfig(config); - return methodChannel.invokeMethod( - 'start', - {'config': jsonEncode(config.toJson())}, - ); + try { + await _methodChannel.invokeMethod( + 'start', + {'config': jsonEncode(config.toJson())}, + ); + } on PlatformException catch (e) { + throw TalsecException.fromPlatformException(e); + } } /// Adds [packageName] to the whitelist. @@ -157,16 +173,15 @@ class Talsec { /// whitelist, you need to remove application data or reinstall the /// application. Future addToWhitelist(String packageName) async { - if (!Platform.isAndroid) { - throw UnimplementedError( - 'Platform is not supported: $defaultTargetPlatform}', + _checkAndroid(); + try { + await _methodChannel.invokeMethod( + 'addToWhitelist', + {'packageName': packageName}, ); + } on PlatformException catch (e) { + throw MalwareFailureException.fromPlatformException(e); } - - return methodChannel.invokeMethod( - 'addToWhitelist', - {'packageName': packageName}, - ); } /// Prevents the screen from being captured. @@ -180,10 +195,10 @@ class Talsec { /// Future blockScreenCapture({required bool enabled}) async { try { - await methodChannel + await _methodChannel .invokeMethod('blockScreenCapture', {'enable': enabled}); } on PlatformException catch (e) { - throw Exception('Failed to call block screen capture: ${e.message}'); + throw TalsecException.fromPlatformException(e); } } @@ -197,7 +212,7 @@ class Talsec { Future isScreenCaptureBlocked() async { try { final result = - await methodChannel.invokeMethod('isScreenCaptureBlocked'); + await _methodChannel.invokeMethod('isScreenCaptureBlocked'); if (result is! bool) { throw const TalsecException(message: 'Screen capture state failed.'); @@ -205,7 +220,119 @@ class Talsec { return result; } on PlatformException catch (e) { - throw Exception('Failed to check screen capture state: ${e.message}'); + throw TalsecException.fromPlatformException(e); + } + } + + /// Prevents accessibility services from reading or controlling the current + /// application screen. + /// + /// Accessibility services are commonly abused by malware to read screen + /// content and perform actions on behalf of the user. + /// + /// Android only + Future blockScreenControl({required bool enabled}) async { + _checkAndroid(); + try { + await _methodChannel + .invokeMethod('blockScreenControl', {'enable': enabled}); + } on PlatformException catch (e) { + throw TalsecException.fromPlatformException(e); + } + } + + /// Checks status of screen control protection. + /// + /// For more details about screen control protection, see the + /// [blockScreenControl] method. + /// + /// Returns `true` if screen control protection is active, `false` + /// otherwise. + /// + /// Android only + Future isScreenControlBlocked() { + _checkAndroid(); + return _invokeBoolMethod('isScreenControlBlocked'); + } + + /// Hides overlay windows of other applications over the current application + /// screen. + /// + /// Requires Android 12 (API 31) or newer and the + /// `android.permission.HIDE_OVERLAY_WINDOWS` permission in the app's + /// `AndroidManifest.xml`. + /// + /// Returns `true` if the request was applied, `false` otherwise (e.g. on an + /// older Android version or when the permission is missing). + /// + /// Android only + Future blockOverlayWindows({required bool enabled}) { + _checkAndroid(); + return _invokeBoolMethod('blockOverlayWindows', {'enable': enabled}); + } + + /// Checks whether overlay windows of other applications are hidden over the + /// current application screen. + /// + /// For more details, see the [blockOverlayWindows] method. + /// + /// Android only + Future isOverlayWindowsBlocked() { + _checkAndroid(); + return _invokeBoolMethod('isOverlayWindowsBlocked'); + } + + /// Starts the ongoing call detection (cellular and VoIP calls) for the + /// current application screen. + /// + /// Call state changes are reported through [ThreatCallback.onOngoingCall]. + /// If a call is already in progress when the detection starts, it is + /// reported as [CallState.ongoing]. + /// + /// Android only + Future startOngoingCallDetection() async { + _checkAndroid(); + try { + await _methodChannel.invokeMethod('startOngoingCallDetection'); + } on PlatformException catch (e) { + throw TalsecException.fromPlatformException(e); + } + } + + /// Stops the ongoing call detection started by [startOngoingCallDetection]. + /// + /// Android only + Future stopOngoingCallDetection() async { + _checkAndroid(); + try { + await _methodChannel.invokeMethod('stopOngoingCallDetection'); + } on PlatformException catch (e) { + throw TalsecException.fromPlatformException(e); + } + } + + Future _invokeBoolMethod( + String method, [ + Map? arguments, + ]) async { + try { + final result = await _methodChannel.invokeMethod(method, arguments); + + if (result is! bool) { + throw TalsecException(message: '$method returned no result.'); + } + + return result; + } on PlatformException catch (e) { + throw TalsecException.fromPlatformException(e); + } + } + + void _checkAndroid() { + if (!Platform.isAndroid) { + throw UnsupportedPlatformException( + message: 'Platform is not supported: $defaultTargetPlatform', + ); } } @@ -215,7 +342,8 @@ class Talsec { /// Throws a [ExternalIdFailureException] when storing failed. Future storeExternalId(String data) async { try { - await methodChannel.invokeMethod('storeExternalId', {'data': data}); + await _methodChannel + .invokeMethod('storeExternalId', {'data': data}); } on PlatformException catch (e) { throw ExternalIdFailureException.fromPlatformException(e); } @@ -226,7 +354,7 @@ class Talsec { /// Throws a [ExternalIdFailureException] when removing failed. Future removeExternalId() async { try { - await methodChannel.invokeMethod('removeExternalId'); + await _methodChannel.invokeMethod('removeExternalId'); } on PlatformException catch (e) { throw ExternalIdFailureException.fromPlatformException(e); } @@ -250,7 +378,9 @@ class Talsec { case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: - throw UnimplementedError('Platform is not supported'); + throw UnsupportedPlatformException( + message: 'Platform is not supported: $defaultTargetPlatform', + ); } } @@ -258,63 +388,105 @@ class Talsec { /// already attached, current one will be detached and replaced with /// [callback]. /// - /// When invoked, functions starts listening to [onThreatDetected] and turns - /// stream events into function callbacks of [ThreatCallback]. + /// When invoked, functions starts listening to the threat events and turns + /// them into function callbacks of [ThreatCallback], in the order in which + /// they were detected. /// /// When threat is detected, respective callback of [ThreatCallback] is /// invoked. Future attachListener(ThreatCallback callback) async { - pigeon.TalsecPigeonApi.setUp(callback); - await detachListener(); - _streamSubscription ??= onThreatDetected.listen((event) { - switch (event) { - case Threat.hooks: - callback.onHooks?.call(); - case Threat.debug: - callback.onDebug?.call(); - case Threat.passcode: - callback.onPasscode?.call(); - case Threat.deviceId: - callback.onDeviceID?.call(); - case Threat.simulator: - callback.onSimulator?.call(); - case Threat.appIntegrity: - callback.onAppIntegrity?.call(); - case Threat.obfuscationIssues: - callback.onObfuscationIssues?.call(); - case Threat.deviceBinding: - callback.onDeviceBinding?.call(); - case Threat.unofficialStore: - callback.onUnofficialStore?.call(); - case Threat.privilegedAccess: - callback.onPrivilegedAccess?.call(); - case Threat.secureHardwareNotAvailable: - callback.onSecureHardwareNotAvailable?.call(); - case Threat.systemVPN: - callback.onSystemVPN?.call(); - case Threat.devMode: - callback.onDevMode?.call(); - case Threat.adbEnabled: - callback.onADBEnabled?.call(); - case Threat.screenshot: - callback.onScreenshot?.call(); - case Threat.screenRecording: - callback.onScreenRecording?.call(); - case Threat.multiInstance: - callback.onMultiInstance?.call(); - case Threat.unsecureWiFi: - callback.onUnsecureWiFi?.call(); - case Threat.timeSpoofing: - callback.onTimeSpoofing?.call(); - case Threat.locationSpoofing: - callback.onLocationSpoofing?.call(); - case Threat.automation: - callback.onAutomation?.call(); - case Threat.bootloader: - callback.onBootloader?.call(); + _streamSubscription ??= _threatEvents.listen( + (event) => _dispatchThreatEvent(callback, event), + ); + } + + void _dispatchThreatEvent(ThreatCallback callback, Object? event) { + switch (event) { + case final int code: + _dispatchThreat(callback, _decodeOrAbort(code, ThreatX.fromInt)); + case final Map payload: + _dispatchPayload(callback, payload); + default: + // Unknown data came from the native platform. + exit(127); + } + } + + /// Delivers a malware or ongoing call detection. + /// + /// An unexpected payload is handled like an unknown threat code. + void _dispatchPayload( + ThreatCallback callback, + Map payload, + ) { + final void Function() deliver; + try { + switch (payload['type']) { + case 'malware': + final apps = (payload['suspiciousApps']! as List) + .map((app) => SuspiciousAppInfo.fromMap(app! as Map)) + .toList(); + deliver = () => callback.onMalware?.call(apps); + case 'ongoingCall': + final state = CallState.values.byName(payload['state']! as String); + deliver = () => callback.onOngoingCall?.call(state); + default: + exit(127); } - }); + } on Object { + exit(127); + } + deliver(); + } + + void _dispatchThreat(ThreatCallback callback, Threat threat) { + switch (threat) { + case Threat.hooks: + callback.onHooks?.call(); + case Threat.debug: + callback.onDebug?.call(); + case Threat.passcode: + callback.onPasscode?.call(); + case Threat.deviceID: + callback.onDeviceID?.call(); + case Threat.simulator: + callback.onSimulator?.call(); + case Threat.appIntegrity: + callback.onAppIntegrity?.call(); + case Threat.obfuscationIssues: + callback.onObfuscationIssues?.call(); + case Threat.deviceBinding: + callback.onDeviceBinding?.call(); + case Threat.unofficialStore: + callback.onUnofficialStore?.call(); + case Threat.privilegedAccess: + callback.onPrivilegedAccess?.call(); + case Threat.secureHardwareNotAvailable: + callback.onSecureHardwareNotAvailable?.call(); + case Threat.systemVPN: + callback.onSystemVPN?.call(); + case Threat.devMode: + callback.onDevMode?.call(); + case Threat.adbEnabled: + callback.onAdbEnabled?.call(); + case Threat.screenshot: + callback.onScreenshot?.call(); + case Threat.screenRecording: + callback.onScreenRecording?.call(); + case Threat.multiInstance: + callback.onMultiInstance?.call(); + case Threat.unsecureWifi: + callback.onUnsecureWifi?.call(); + case Threat.timeSpoofing: + callback.onTimeSpoofing?.call(); + case Threat.locationSpoofing: + callback.onLocationSpoofing?.call(); + case Threat.automation: + callback.onAutomation?.call(); + case Threat.bootloader: + callback.onBootloader?.call(); + } } /// Removes instance of latest [ThreatCallback]. Also cancels @@ -326,6 +498,21 @@ class Talsec { _streamSubscription = null; } + /// Decodes a [code] received from the native platform. + /// + /// An unknown code means that the communication with the native platform was + /// tampered with, so the app is terminated, like in the other freeRASP + /// plugins. + static T _decodeOrAbort(int code, T Function(int) decode) { + try { + return decode(code); + // fromInt reports an unknown code with an ArgumentError. + // ignore: avoid_catching_errors + } on ArgumentError { + exit(127); + } + } + void _handleStreamError(Object error) { if (error is PlatformException) { throw TalsecException.fromPlatformException(error); @@ -342,8 +529,12 @@ class Talsec { await detachExecutionStateListener(); _executionStateSubscription ??= onRaspExecutionState.listen((event) { switch (event) { - case RaspExecutionState.allChecksFinished: - callback.onAllChecksFinished?.call(); + case RaspExecutionState.fastChecksFinished: + callback.onFastChecksFinished?.call(); + case RaspExecutionState.criticalChecksFinished: + callback.onCriticalChecksFinished?.call(); + case RaspExecutionState.postponedChecksFinished: + callback.onPostponedChecksFinished?.call(); } }); } @@ -359,22 +550,17 @@ class Talsec { /// Throws a [TalsecException] with error message description if /// the app with given package name couldn't be obtained. Future getAppIcon(String packageName) async { - if (!Platform.isAndroid) { - throw UnimplementedError( - 'Platform is not supported: $defaultTargetPlatform}', - ); - } - + _checkAndroid(); try { return await _getAppIcon(packageName); } on PlatformException catch (e) { - throw TalsecException.fromPlatformException(e); + throw MalwareFailureException.fromPlatformException(e); } } Future _getAppIcon(String packageName) async { final args = {'packageName': packageName}; - final result = await methodChannel.invokeMethod( + final result = await _methodChannel.invokeMethod( 'getAppIcon', args, ); diff --git a/lib/src/typedefs.dart b/lib/src/typedefs.dart index 0814189..a73c2e7 100644 --- a/lib/src/typedefs.dart +++ b/lib/src/typedefs.dart @@ -1,7 +1,13 @@ -import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart'; +import 'package:freerasp/src/enums/call_state.dart'; +import 'package:freerasp/src/models/suspicious_app_info.dart'; /// Typedef for void methods typedef VoidCallback = void Function(); /// Typedef for malware callback -typedef MalwareCallback = void Function(List packageInfo); +typedef MalwareCallback = void Function( + List suspiciousApps, +); + +/// Typedef for ongoing call callback +typedef OngoingCallCallback = void Function(CallState state); diff --git a/lib/src/utils/config_verifier.dart b/lib/src/utils/config_verifier.dart index e06eede..d21f150 100644 --- a/lib/src/utils/config_verifier.dart +++ b/lib/src/utils/config_verifier.dart @@ -10,7 +10,7 @@ class ConfigVerifier { /// Verifies if provided [androidConfig] is valid. /// Verification is run only if current TargetPlatform is Android. /// Verifies [AndroidConfig.packageName] presence and validity of provided - /// [AndroidConfig.signingCertHashes]. + /// [AndroidConfig.certificateHashes]. /// /// Throws [ConfigurationException] if config is invalid. static void verifyAndroid(AndroidConfig androidConfig) { @@ -24,15 +24,15 @@ class ConfigVerifier { ); } - if (androidConfig.signingCertHashes.isEmpty) { + if (androidConfig.certificateHashes.isEmpty) { throw const ConfigurationException( - message: 'signingCertHashes cannot be empty', + message: 'certificateHashes cannot be empty', ); } - _verifyHashesFormat(androidConfig.signingCertHashes); + _verifyHashesFormat(androidConfig.certificateHashes); - _verifyHashesValidity(androidConfig.signingCertHashes); + _verifyHashesValidity(androidConfig.certificateHashes); } static void _verifyHashLength(String hash) { @@ -65,7 +65,7 @@ class ConfigVerifier { /// Verifies if provided [iOSConfig] is valid. /// Verification is run only if current TargetPlatform is iOS. - /// Verifies [IOSConfig.bundleIds] and [IOSConfig.teamId] presence. + /// Verifies [IOSConfig.appBundleIds] and [IOSConfig.appTeamId] presence. /// /// Throws [ConfigurationException] if config is invalid. static void verifyIOS(IOSConfig iOSConfig) { @@ -73,13 +73,13 @@ class ConfigVerifier { return; } - if (iOSConfig.bundleIds.isEmpty) { + if (iOSConfig.appBundleIds.isEmpty) { throw const ConfigurationException( message: 'bundle IDs cannot be empty', ); } - if (iOSConfig.teamId.isEmpty) { + if (iOSConfig.appTeamId.isEmpty) { throw const ConfigurationException( message: 'team ID cannot be empty', ); diff --git a/pigeons/build.sh b/pigeons/build.sh deleted file mode 100755 index 328c92f..0000000 --- a/pigeons/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/zsh - -# Exit on error -set -euo pipefail - -# Variables -INPUT_FILES=("talsec_pigeon_api.dart" "rasp_execution_state.dart") - -# Check current directory -if [ "$(basename "$PWD")" != "pigeons" ]; then - echo "⚠️ Not in the /pigeons directory." - echo "⚠️ Current directory: $PWD" - echo "Run this script from the /pigeons directory" - exit 1 -fi - -# Generate Pigeon code -cd .. -for file in "${INPUT_FILES[@]}"; do - echo "🚀 Generating Pigeon code for $file..." - dart run pigeon --input "./pigeons/$file" -done diff --git a/pigeons/talsec_pigeon_api.dart b/pigeons/talsec_pigeon_api.dart deleted file mode 100644 index 83bb736..0000000 --- a/pigeons/talsec_pigeon_api.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:pigeon/pigeon.dart'; - -@ConfigurePigeon( - PigeonOptions( - dartOut: 'lib/src/generated/talsec_pigeon_api.g.dart', - kotlinOut: - 'android/src/main/kotlin/com/aheaditec/freerasp/generated/TalsecPigeonApi.kt', - input: 'pigeons/talsec_pigeon_api.dart', - kotlinOptions: KotlinOptions(package: 'com.aheaditec.freerasp.generated'), - ), -) -class PackageInfo { - const PackageInfo({ - required this.packageName, - this.appIcon, - this.version, - this.appName, - this.installerStore, - }); - - final String packageName; - final String? appIcon; - final String? appName; - final String? version; - final String? installerStore; -} - -class SuspiciousAppInfo { - const SuspiciousAppInfo({ - required this.packageInfo, - required this.reasons, - this.permissions, - }); - - final PackageInfo packageInfo; - final List reasons; - final List? permissions; -} - -@FlutterApi() -// Migrate whole Talsec API to pigeon -// ignore: one_member_abstracts -abstract class TalsecPigeonApi { - void onMalwareDetected(List packageInfo); -} diff --git a/pubspec.yaml b/pubspec.yaml index 335510e..fdc092a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: freerasp description: Flutter library for improving app security and threat monitoring on Android and iOS mobile devices. Learn more about provided features on the freeRASP's homepage first. -version: 8.3.0 +version: 9.0.0 homepage: https://www.talsec.app/freerasp-in-app-protection-security-talsec repository: https://github.com/talsec/Free-RASP-Flutter @@ -29,14 +29,13 @@ dev_dependencies: sdk: flutter json_serializable: ^6.8.0 mocktail: ^1.0.4 - pigeon: ^22.7.2 very_good_analysis: ^7.0.0 flutter: plugin: platforms: android: - package: com.aheaditec.freerasp + package: app.talsec.rasp.flutter pluginClass: FreeraspPlugin ios: pluginClass: SwiftFreeraspPlugin diff --git a/test/src/enums/threat_test.dart b/test/src/enums/threat_test.dart index 1479f91..a15a933 100644 --- a/test/src/enums/threat_test.dart +++ b/test/src/enums/threat_test.dart @@ -14,7 +14,7 @@ void main() { expect(threatValues[0], Threat.hooks); expect(threatValues[1], Threat.debug); expect(threatValues[2], Threat.passcode); - expect(threatValues[3], Threat.deviceId); + expect(threatValues[3], Threat.deviceID); expect(threatValues[4], Threat.simulator); expect(threatValues[5], Threat.appIntegrity); expect(threatValues[6], Threat.obfuscationIssues); @@ -28,7 +28,7 @@ void main() { expect(threatValues[14], Threat.screenshot); expect(threatValues[15], Threat.screenRecording); expect(threatValues[16], Threat.multiInstance); - expect(threatValues[17], Threat.unsecureWiFi); + expect(threatValues[17], Threat.unsecureWifi); expect(threatValues[18], Threat.timeSpoofing); expect(threatValues[19], Threat.locationSpoofing); expect(threatValues[20], Threat.automation); @@ -42,7 +42,7 @@ void main() { expect(ThreatX.fromInt(1268968002), Threat.debug); expect(ThreatX.fromInt(209533833), Threat.hooks); expect(ThreatX.fromInt(1293399086), Threat.passcode); - expect(ThreatX.fromInt(1514211414), Threat.deviceId); + expect(ThreatX.fromInt(1514211414), Threat.deviceID); expect(ThreatX.fromInt(477190884), Threat.simulator); expect(ThreatX.fromInt(1115787534), Threat.appIntegrity); expect(ThreatX.fromInt(1001443554), Threat.obfuscationIssues); @@ -56,11 +56,15 @@ void main() { expect(ThreatX.fromInt(705651459), Threat.screenshot); expect(ThreatX.fromInt(64690214), Threat.screenRecording); expect(ThreatX.fromInt(859307284), Threat.multiInstance); - expect(ThreatX.fromInt(363588890), Threat.unsecureWiFi); + expect(ThreatX.fromInt(363588890), Threat.unsecureWifi); expect(ThreatX.fromInt(189105221), Threat.timeSpoofing); expect(ThreatX.fromInt(653273273), Threat.locationSpoofing); expect(ThreatX.fromInt(298453120), Threat.automation); expect(ThreatX.fromInt(1651076936), Threat.bootloader); }, ); + + test('ThreatX.fromInt should throw ArgumentError for unknown code', () { + expect(() => ThreatX.fromInt(55943254), throwsArgumentError); + }); } diff --git a/test/src/models/android_config_test.dart b/test/src/models/android_config_test.dart index 20cee1b..cfdf234 100644 --- a/test/src/models/android_config_test.dart +++ b/test/src/models/android_config_test.dart @@ -12,8 +12,8 @@ void main() { androidConfig = AndroidConfig( packageName: 'com.example.app', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], - supportedStores: ['adb'], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + supportedAlternativeStores: ['adb'], ); }); @@ -21,25 +21,25 @@ void main() { expect(androidConfig, isA()); expect(androidConfig.packageName, 'com.example.app'); expect( - androidConfig.signingCertHashes, + androidConfig.certificateHashes, ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ); - expect(androidConfig.supportedStores, ['adb']); + expect(androidConfig.supportedAlternativeStores, ['adb']); }); test('Should create AndroidConfig instance with empty stores', () { final androidConfig = AndroidConfig( packageName: 'com.example.app', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ); expect(androidConfig, isA()); expect(androidConfig.packageName, 'com.example.app'); expect( - androidConfig.signingCertHashes, + androidConfig.certificateHashes, ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ); - expect(androidConfig.supportedStores, []); + expect(androidConfig.supportedAlternativeStores, []); }); test('Should convert AndroidConfig to JSON', () { @@ -48,17 +48,17 @@ void main() { expect(json, isA>()); expect(json['packageName'], 'com.example.app'); expect( - json['signingCertHashes'], + json['certificateHashes'], ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ); - expect(json['supportedStores'], ['adb']); + expect(json['supportedAlternativeStores'], ['adb']); }); test('Should create AndroidConfig instance from JSON', () { final json = { 'packageName': 'com.example.app', - 'signingCertHashes': ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], - 'supportedStores': ['adb'], + 'certificateHashes': ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + 'supportedAlternativeStores': ['adb'], }; final androidConfigFromJson = AndroidConfig.fromJson(json); @@ -66,17 +66,17 @@ void main() { expect(androidConfigFromJson, isA()); expect(androidConfigFromJson.packageName, 'com.example.app'); expect( - androidConfigFromJson.signingCertHashes, + androidConfigFromJson.certificateHashes, ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ); - expect(androidConfigFromJson.supportedStores, ['adb']); + expect(androidConfigFromJson.supportedAlternativeStores, ['adb']); }); test('Should create AndroidConfig instance with empty stores from JSON', () { final json = { 'packageName': 'com.example.app', - 'signingCertHashes': ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + 'certificateHashes': ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], }; final androidConfigFromJson = AndroidConfig.fromJson(json); @@ -84,10 +84,10 @@ void main() { expect(androidConfigFromJson, isA()); expect(androidConfigFromJson.packageName, 'com.example.app'); expect( - androidConfigFromJson.signingCertHashes, + androidConfigFromJson.certificateHashes, ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ); - expect(androidConfigFromJson.supportedStores, []); + expect(androidConfigFromJson.supportedAlternativeStores, []); }); test('Should verify AndroidConfig using ConfigVerifier', () { @@ -101,7 +101,7 @@ void main() { expect( () => AndroidConfig( packageName: '', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], ), throwsA(isA()), ); @@ -111,7 +111,7 @@ void main() { expect( () => AndroidConfig( packageName: 'com.example.app', - signingCertHashes: [], + certificateHashes: [], ), throwsA(isA()), ); @@ -121,7 +121,7 @@ void main() { expect( () => AndroidConfig( packageName: 'com.example.app', - signingCertHashes: ['invalid'], + certificateHashes: ['invalid'], ), throwsA(isA()), ); @@ -130,7 +130,7 @@ void main() { test('Should serialize suspiciousAppDetectionConfig to JSON', () { final config = AndroidConfig( packageName: 'com.example.app', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], suspiciousAppDetectionConfig: const SuspiciousAppDetectionConfig( packageNames: ['com.malware.app'], scanScope: ScanScope(scopeType: ScopeType.all), @@ -159,7 +159,7 @@ void main() { test('Should deserialize suspiciousAppDetectionConfig from JSON', () { final json = { 'packageName': 'com.example.app', - 'signingCertHashes': ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + 'certificateHashes': ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], 'suspiciousAppDetectionConfig': { 'packageNames': ['com.malware.app'], 'scanScope': {'scopeType': 'SIDELOADED_AND_OEM'}, diff --git a/test/src/models/ios_config_test.dart b/test/src/models/ios_config_test.dart index 62c5ced..5d67f17 100644 --- a/test/src/models/ios_config_test.dart +++ b/test/src/models/ios_config_test.dart @@ -11,55 +11,55 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; iosConfig = IOSConfig( - bundleIds: ['com.example.app1', 'com.example.app2'], - teamId: 'team123', + appBundleIds: ['com.example.app1', 'com.example.app2'], + appTeamId: 'team123', ); }); test('Should create IOSConfig instance', () { expect(iosConfig, isA()); - expect(iosConfig.bundleIds, ['com.example.app1', 'com.example.app2']); - expect(iosConfig.teamId, 'team123'); + expect(iosConfig.appBundleIds, ['com.example.app1', 'com.example.app2']); + expect(iosConfig.appTeamId, 'team123'); }); test('Should convert IOSConfig to JSON', () { final json = iosConfig.toJson(); expect(json, isA>()); - expect(json['bundleIds'], ['com.example.app1', 'com.example.app2']); - expect(json['teamId'], 'team123'); + expect(json['appBundleIds'], ['com.example.app1', 'com.example.app2']); + expect(json['appTeamId'], 'team123'); }); test('Should create IOSConfig instance from JSON', () { final json = { - 'bundleIds': ['com.example.app1', 'com.example.app2'], - 'teamId': 'team123', + 'appBundleIds': ['com.example.app1', 'com.example.app2'], + 'appTeamId': 'team123', }; final iosConfigFromJson = IOSConfig.fromJson(json); expect(iosConfigFromJson, isA()); expect( - iosConfigFromJson.bundleIds, + iosConfigFromJson.appBundleIds, ['com.example.app1', 'com.example.app2'], ); - expect(iosConfigFromJson.teamId, 'team123'); + expect(iosConfigFromJson.appTeamId, 'team123'); }); test('Should verify IOSConfig using ConfigVerifier', () { expect(() => ConfigVerifier.verifyIOS(iosConfig), returnsNormally); }); - test('Should throw ConfigurationException when bundleIds is empty', () { + test('Should throw ConfigurationException when appBundleIds is empty', () { expect( - () => IOSConfig(bundleIds: [], teamId: 'team123'), + () => IOSConfig(appBundleIds: [], appTeamId: 'team123'), throwsA(isA()), ); }); - test('Should throw ConfigurationException when teamId is empty', () { + test('Should throw ConfigurationException when appTeamId is empty', () { expect( - () => IOSConfig(bundleIds: ['com.example.app1'], teamId: ''), + () => IOSConfig(appBundleIds: ['com.example.app1'], appTeamId: ''), throwsA(isA()), ); }); diff --git a/test/src/models/talsec_config_test.dart b/test/src/models/talsec_config_test.dart index f9848b4..9018e8e 100644 --- a/test/src/models/talsec_config_test.dart +++ b/test/src/models/talsec_config_test.dart @@ -14,12 +14,12 @@ void main() { final androidConfig = AndroidConfig( packageName: packageName, - signingCertHashes: signingHashes, + certificateHashes: signingHashes, ); final iosConfig = IOSConfig( - bundleIds: appBundleIds, - teamId: appTeamId, + appBundleIds: appBundleIds, + appTeamId: appTeamId, ); test('Provide configuration for each platform', () { diff --git a/test/src/talsec_test.dart b/test/src/talsec_test.dart index 6a3ef53..0b0dd54 100644 --- a/test/src/talsec_test.dart +++ b/test/src/talsec_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:freerasp/freerasp.dart'; +import 'package:freerasp/src/talsec.dart' show createTalsecForTesting; import '../test_utils/test_utils.dart'; @@ -19,12 +20,12 @@ void main() { const mockWatcherMail = 'your_mail@example.com'; final mockAndroidConfig = AndroidConfig( packageName: 'com.aheaditec.freeraspExample', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], - supportedStores: ['com.sec.android.app.samsungapps'], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + supportedAlternativeStores: ['com.sec.android.app.samsungapps'], ); final mockIOSConfig = IOSConfig( - bundleIds: ['com.aheaditec.freeraspExample'], - teamId: 'M8AK35...', + appBundleIds: ['com.aheaditec.freeraspExample'], + appTeamId: 'M8AK35...', ); tearDown(log.clear); @@ -43,7 +44,7 @@ void main() { watcherMail: mockWatcherMail, ); // Act - final talsec = Talsec.private( + final talsec = createTalsecForTesting( methodChannel.methodChannel, FakeEventChannel(), FakeEventChannel(), @@ -70,7 +71,7 @@ void main() { watcherMail: mockWatcherMail, ); // Act - final talsec = Talsec.private( + final talsec = createTalsecForTesting( methodChannel.methodChannel, FakeEventChannel(), FakeEventChannel(), @@ -93,7 +94,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; // Act - final talsec = Talsec.private( + final talsec = createTalsecForTesting( methodChannel.methodChannel, FakeEventChannel(), FakeEventChannel(), @@ -122,7 +123,7 @@ void main() { ); // Act - final talsec = Talsec.private( + final talsec = createTalsecForTesting( methodChannel.methodChannel, FakeEventChannel(), FakeEventChannel(), @@ -157,13 +158,13 @@ void main() { test('Should receive stream of Threats', () { // Arrange final eventChannel = MockEventChannel( - eventChannel: Talsec.instance.eventChannel, + eventChannel: const EventChannel('talsec.app/freerasp/events'), data: [ 44506749, 1115787534, ], ); - final talsec = Talsec.private( + final talsec = createTalsecForTesting( FakeMethodChannel(), eventChannel.eventChannel, FakeEventChannel(), @@ -185,11 +186,11 @@ void main() { test('Should transform every $PlatformException into $TalsecException', () { final eventChannel = MockEventChannel( - eventChannel: Talsec.instance.eventChannel, + eventChannel: const EventChannel('talsec.app/freerasp/events'), data: [], exceptions: [PlatformException(code: 'dummy-code')], ); - final talsec = Talsec.private( + final talsec = createTalsecForTesting( FakeMethodChannel(), eventChannel.eventChannel, FakeEventChannel(), @@ -216,14 +217,14 @@ void main() { test('Should survive thrown exception when listening', () { final eventChannel = MockEventChannel( - eventChannel: Talsec.instance.eventChannel, + eventChannel: const EventChannel('talsec.app/freerasp/events'), data: [ 44506749, 1115787534, ], exceptions: [PlatformException(code: 'dummy-code')], ); - final talsec = Talsec.private( + final talsec = createTalsecForTesting( FakeMethodChannel(), eventChannel.eventChannel, FakeEventChannel(), @@ -256,10 +257,10 @@ void main() { test('Should receive stream of RaspExecutionState', () { // Arrange final eventChannel = MockEventChannel( - eventChannel: Talsec.instance.executionStateChannel, + eventChannel: const EventChannel('talsec.app/freerasp/execution_state'), data: [187429], ); - final talsec = Talsec.private( + final talsec = createTalsecForTesting( FakeMethodChannel(), FakeEventChannel(), eventChannel.eventChannel, @@ -272,7 +273,7 @@ void main() { expectLater( stream, emitsInOrder([ - RaspExecutionState.allChecksFinished, + RaspExecutionState.fastChecksFinished, emitsDone, ]), ); @@ -281,10 +282,10 @@ void main() { test('attachExecutionStateListener should invoke callback', () async { // Arrange final eventChannel = MockEventChannel( - eventChannel: Talsec.instance.executionStateChannel, + eventChannel: const EventChannel('talsec.app/freerasp/execution_state'), data: [187429], ); - final talsec = Talsec.private( + final talsec = createTalsecForTesting( FakeMethodChannel(), FakeEventChannel(), eventChannel.eventChannel, @@ -292,7 +293,7 @@ void main() { final completer = Completer(); final callback = RaspExecutionStateCallback( - onAllChecksFinished: completer.complete, + onFastChecksFinished: completer.complete, ); // Act @@ -301,5 +302,89 @@ void main() { // Assert await expectLater(completer.future, completes); }); + + test('Should receive Android-only execution states', () { + // Arrange + final eventChannel = MockEventChannel( + eventChannel: const EventChannel('talsec.app/freerasp/execution_state'), + data: [614702, 187429, 952318], + ); + final talsec = createTalsecForTesting( + FakeMethodChannel(), + FakeEventChannel(), + eventChannel.eventChannel, + ); + + // Act + final stream = talsec.onRaspExecutionState; + + // Assert + expectLater( + stream, + emitsInOrder([ + RaspExecutionState.criticalChecksFinished, + RaspExecutionState.fastChecksFinished, + RaspExecutionState.postponedChecksFinished, + emitsDone, + ]), + ); + }); + + test('attachExecutionStateListener should invoke every callback', () async { + // Arrange + final eventChannel = MockEventChannel( + eventChannel: const EventChannel('talsec.app/freerasp/execution_state'), + data: [614702, 187429, 952318], + ); + final talsec = createTalsecForTesting( + FakeMethodChannel(), + FakeEventChannel(), + eventChannel.eventChannel, + ); + + final log = []; + final completer = Completer(); + final callback = RaspExecutionStateCallback( + onCriticalChecksFinished: () => log.add('critical'), + onFastChecksFinished: () => log.add('all'), + onPostponedChecksFinished: () { + log.add('postponed'); + completer.complete(); + }, + ); + + // Act + await talsec.attachExecutionStateListener(callback); + + // Assert + await expectLater(completer.future, completes); + expect(log, ['critical', 'all', 'postponed']); + }); + }); + + group('Android-only APIs', () { + // Tests run on the host, which is not Android. + final talsec = createTalsecForTesting( + FakeMethodChannel(), + FakeEventChannel(), + FakeEventChannel(), + ); + + final calls = Function()>{ + 'blockScreenControl': () => talsec.blockScreenControl(enabled: true), + 'isScreenControlBlocked': talsec.isScreenControlBlocked, + 'blockOverlayWindows': () => talsec.blockOverlayWindows(enabled: true), + 'isOverlayWindowsBlocked': talsec.isOverlayWindowsBlocked, + 'startOngoingCallDetection': talsec.startOngoingCallDetection, + 'stopOngoingCallDetection': talsec.stopOngoingCallDetection, + 'addToWhitelist': () => talsec.addToWhitelist('com.example.app'), + 'getAppIcon': () => talsec.getAppIcon('com.example.app'), + }; + + for (final entry in calls.entries) { + test('${entry.key} should throw outside Android', () { + expect(entry.value, throwsA(isA())); + }); + } }); } diff --git a/test/src/threat_events_test.dart b/test/src/threat_events_test.dart new file mode 100644 index 0000000..fd137a0 --- /dev/null +++ b/test/src/threat_events_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:freerasp/freerasp.dart'; +import 'package:freerasp/src/talsec.dart' show createTalsecForTesting; + +import '../test_utils/test_utils.dart'; + +const _events = EventChannel('talsec.app/freerasp/events'); + +const _malwareApp = { + 'packageInfo': { + 'packageName': 'com.example.malware', + 'appName': 'Malware', + 'version': '42', + 'installerStore': null, + }, + 'reasons': ['blacklist'], + 'permissions': null, +}; + +Talsec _talsecWith(List data) { + final channel = MockEventChannel(eventChannel: _events, data: data); + return createTalsecForTesting( + FakeMethodChannel(), + channel.eventChannel, + FakeEventChannel(), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('attachListener delivers threats and payloads in order', () async { + // Arrange + final log = []; + final talsec = _talsecWith([ + 44506749, + { + 'type': 'malware', + 'suspiciousApps': [_malwareApp], + }, + {'type': 'ongoingCall', 'state': 'started'}, + {'type': 'ongoingCall', 'state': 'stopped'}, + ]); + final callback = ThreatCallback( + onPrivilegedAccess: () => log.add(Threat.privilegedAccess), + onMalware: log.add, + onOngoingCall: log.add, + ); + + // Act + await talsec.attachListener(callback); + await pumpEventQueue(); + + // Assert + expect(log, [ + Threat.privilegedAccess, + [ + const SuspiciousAppInfo( + packageInfo: PackageInfo( + packageName: 'com.example.malware', + appName: 'Malware', + version: '42', + ), + reasons: ['blacklist'], + ), + ], + CallState.started, + CallState.stopped, + ]); + await talsec.detachListener(); + }); + + test('onThreatDetected skips payload events', () { + final talsec = _talsecWith([ + {'type': 'ongoingCall', 'state': 'ongoing'}, + 1115787534, + ]); + + expectLater( + talsec.onThreatDetected, + emitsInOrder([Threat.appIntegrity, emitsDone]), + ); + }); + + test('SuspiciousAppInfo.fromMap reads permissions', () { + final app = SuspiciousAppInfo.fromMap(const { + 'packageInfo': {'packageName': 'com.example.malware'}, + 'reasons': ['permission'], + 'permissions': ['android.permission.READ_SMS'], + }); + + expect(app.permissions, ['android.permission.READ_SMS']); + expect(app.packageInfo.packageName, 'com.example.malware'); + }); + + test('SuspiciousAppInfo.fromMap throws on a malformed map', () { + expect( + () => SuspiciousAppInfo.fromMap(const {'reasons': []}), + throwsA(anything), + ); + }); +} diff --git a/test/src/utils/config_verifier_test.dart b/test/src/utils/config_verifier_test.dart index fb84b34..9741e25 100644 --- a/test/src/utils/config_verifier_test.dart +++ b/test/src/utils/config_verifier_test.dart @@ -11,7 +11,7 @@ void main() { const validHash = 'AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='; final config = AndroidConfig( packageName: 'packageName', - signingCertHashes: [validHash], + certificateHashes: [validHash], ); // Act & Assert @@ -26,7 +26,7 @@ void main() { expect( () => AndroidConfig( packageName: 'packageName', - signingCertHashes: [invalidHash], + certificateHashes: [invalidHash], ), throwsA(isA()), ); @@ -43,7 +43,7 @@ void main() { expect( () => AndroidConfig( packageName: 'packageName', - signingCertHashes: [validHash, invalidHash], + certificateHashes: [validHash, invalidHash], ), throwsA(isA()), ); @@ -53,7 +53,7 @@ void main() { expect( () => AndroidConfig( packageName: 'packageName', - signingCertHashes: [], + certificateHashes: [], ), throwsA(isA()), ); @@ -67,7 +67,7 @@ void main() { expect( () => AndroidConfig( packageName: '', - signingCertHashes: [validHash], + certificateHashes: [validHash], ), throwsA(isA()), ); @@ -76,16 +76,16 @@ void main() { test('Should encode TalsecConfig to String', () { // Arrange const expectedString = - '{"androidConfig":{"packageName":"com.aheaditec.freeraspExample","signingCertHashes":["AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0="],"supportedStores":["com.sec.android.app.samsungapps"]},"iosConfig":{"bundleIds":["com.aheaditec.freeraspExample"],"teamId":"M8AK35..."},"watcherMail":"test_mail@example.com","isProd":false}'; + '{"androidConfig":{"packageName":"com.aheaditec.freeraspExample","certificateHashes":["AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0="],"supportedAlternativeStores":["com.sec.android.app.samsungapps"]},"iosConfig":{"appBundleIds":["com.aheaditec.freeraspExample"],"appTeamId":"M8AK35..."},"watcherMail":"test_mail@example.com","isProd":false}'; final config = TalsecConfig( androidConfig: AndroidConfig( packageName: 'com.aheaditec.freeraspExample', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], - supportedStores: ['com.sec.android.app.samsungapps'], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + supportedAlternativeStores: ['com.sec.android.app.samsungapps'], ), iosConfig: IOSConfig( - bundleIds: ['com.aheaditec.freeraspExample'], - teamId: 'M8AK35...', + appBundleIds: ['com.aheaditec.freeraspExample'], + appTeamId: 'M8AK35...', ), watcherMail: 'test_mail@example.com', isProd: false, @@ -103,19 +103,19 @@ void main() { final expectedConfig = TalsecConfig( androidConfig: AndroidConfig( packageName: 'com.aheaditec.freeraspExample', - signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], - supportedStores: ['com.sec.android.app.samsungapps'], + certificateHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='], + supportedAlternativeStores: ['com.sec.android.app.samsungapps'], ), iosConfig: IOSConfig( - bundleIds: ['com.aheaditec.freeraspExample'], - teamId: 'M8AK35...', + appBundleIds: ['com.aheaditec.freeraspExample'], + appTeamId: 'M8AK35...', ), watcherMail: 'test_mail@example.com', isProd: false, ); const config = - '{"androidConfig":{"packageName":"com.aheaditec.freeraspExample","signingCertHashes":["AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0="],"supportedStores":["com.sec.android.app.samsungapps"]},"iosConfig":{"bundleIds":["com.aheaditec.freeraspExample"],"teamId":"M8AK35..."},"watcherMail":"test_mail@example.com","isProd":false}'; + '{"androidConfig":{"packageName":"com.aheaditec.freeraspExample","certificateHashes":["AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0="],"supportedAlternativeStores":["com.sec.android.app.samsungapps"]},"iosConfig":{"appBundleIds":["com.aheaditec.freeraspExample"],"appTeamId":"M8AK35..."},"watcherMail":"test_mail@example.com","isProd":false}'; // Act final actualConfig = @@ -131,12 +131,12 @@ void main() { equals(expectedConfig.isProd), ); expect( - actualConfig.androidConfig?.signingCertHashes, - equals(expectedConfig.androidConfig?.signingCertHashes), + actualConfig.androidConfig?.certificateHashes, + equals(expectedConfig.androidConfig?.certificateHashes), ); expect( - actualConfig.iosConfig?.bundleIds, - equals(expectedConfig.iosConfig?.bundleIds), + actualConfig.iosConfig?.appBundleIds, + equals(expectedConfig.iosConfig?.appBundleIds), ); }); }); diff --git a/test/test_utils/mock_event_channel.dart b/test/test_utils/mock_event_channel.dart index 4aa6e7c..d7204c3 100644 --- a/test/test_utils/mock_event_channel.dart +++ b/test/test_utils/mock_event_channel.dart @@ -12,7 +12,7 @@ class MockEventChannel { } final EventChannel eventChannel; - final List data; + final List data; final List exceptions; MockStreamHandler _handler() { diff --git a/test/test_utils/spy_threat_callback.dart b/test/test_utils/spy_threat_callback.dart index 0e88c82..fddc545 100644 --- a/test/test_utils/spy_threat_callback.dart +++ b/test/test_utils/spy_threat_callback.dart @@ -11,7 +11,7 @@ class SpyThreatListener { onAppIntegrity: () => _log(Threat.appIntegrity), onDebug: () => _log(Threat.debug), onDeviceBinding: () => _log(Threat.deviceBinding), - onDeviceID: () => _log(Threat.deviceId), + onDeviceID: () => _log(Threat.deviceID), onHooks: () => _log(Threat.hooks), onPasscode: () => _log(Threat.passcode), onPrivilegedAccess: () => _log(Threat.privilegedAccess), @@ -20,7 +20,7 @@ class SpyThreatListener { onUnofficialStore: () => _log(Threat.unofficialStore), onSystemVPN: () => _log(Threat.systemVPN), onDevMode: () => _log(Threat.devMode), - onADBEnabled: () => _log(Threat.adbEnabled), + onAdbEnabled: () => _log(Threat.adbEnabled), onScreenshot: () => _log(Threat.screenshot), onScreenRecording: () => _log(Threat.screenRecording), onObfuscationIssues: () => _log(Threat.obfuscationIssues), @@ -53,7 +53,7 @@ class SpyThreatListener { callback.onUnofficialStore?.call(); case Threat.passcode: callback.onPasscode?.call(); - case Threat.deviceId: + case Threat.deviceID: callback.onDeviceID?.call(); case Threat.secureHardwareNotAvailable: callback.onSecureHardwareNotAvailable?.call(); @@ -62,15 +62,15 @@ class SpyThreatListener { case Threat.devMode: callback.onDevMode?.call(); case Threat.adbEnabled: - callback.onADBEnabled?.call(); + callback.onAdbEnabled?.call(); case Threat.screenshot: callback.onScreenshot?.call(); case Threat.screenRecording: callback.onScreenRecording?.call(); case Threat.multiInstance: callback.onMultiInstance?.call(); - case Threat.unsecureWiFi: - callback.onUnsecureWiFi?.call(); + case Threat.unsecureWifi: + callback.onUnsecureWifi?.call(); case Threat.timeSpoofing: callback.onTimeSpoofing?.call(); case Threat.locationSpoofing: