diff --git a/CHANGELOG.md b/CHANGELOG.md index 27bf515..c15963f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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). +## [7.4.0] - 2026-02-10 +- Android SDK version: 18.0.2 +- iOS SDK version: 6.13.0 + +### Flutter + +#### Added +- Added `onAutomation` callback to `ThreatCallback` for handling `Threat.automation` threat + +### Android + +#### Added + +- Added support for `KernelSU` to the existing root detection capabilities +- Added support for `HMA` to the existing root detection capabilities +- Added new malware detection capabilities +- Added `onAutomationDetected()` callback to `ThreatDetected` interface + - We are introducing a new capability, detecting whether the device is being automated using tools like Appium +- Added value restrictions to `externalId` + - Method `storeExternalId()` now returns `ExternalIdResult`, which indicates `Success` or `Error` when `externalId` violates restrictions + +#### Fixed + +- Fixed exception handling for the KeyStore `getEntry` operation +- Fixed issue in `ScreenProtector` concerning the `onScreenRecordingDetected` invocations +- Merged internal shared libraries into a single one, reducing the final APK size +- Fixed bug related to key storing in keystore type detection (hw-backed keystore check) +- Fixed manifest queries merge + +#### Changed + +- Removed unused library `tmlib` +- Refactoring of signature verification code +- Updated compile and target API to 36 +- Improved root detection capabilities +- Detection of wireless ADB added to ADB detections + ## [7.3.0] - 2025-10-20 - Android SDK version: 17.0.0 - iOS SDK version: 6.13.0 diff --git a/android/build.gradle b/android/build.gradle index 20a0f79..c065d70 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -3,7 +3,7 @@ version '1.0-SNAPSHOT' buildscript { ext.kotlin_version = '2.1.0' - ext.talsec_version = '17.0.0' + ext.talsec_version = '18.0.2' repositories { google() mavenCentral() @@ -32,7 +32,7 @@ android { namespace("com.aheaditec.freerasp") } - compileSdk 35 + compileSdk 36 compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt b/android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt index 8fdcfc0..cbbf80f 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt +++ b/android/src/main/kotlin/com/aheaditec/freerasp/FreeraspPlugin.kt @@ -6,6 +6,7 @@ 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 @@ -17,6 +18,7 @@ import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter /** FreeraspPlugin */ class FreeraspPlugin : FlutterPlugin, ActivityAware, LifecycleEventObserver { private var streamHandler: StreamHandler = StreamHandler() + private var executionStateStreamHandler: ExecutionStateStreamHandler = ExecutionStateStreamHandler() private var methodCallHandler: MethodCallHandler = MethodCallHandler() private var screenProtector: ScreenProtector? = if (Build.VERSION.SDK_INT >= 34) ScreenProtector else null @@ -30,11 +32,13 @@ class FreeraspPlugin : FlutterPlugin, ActivityAware, LifecycleEventObserver { context = flutterPluginBinding.applicationContext methodCallHandler.createMethodChannel(messenger, flutterPluginBinding.applicationContext) streamHandler.createEventChannel(messenger) + executionStateStreamHandler.createEventChannel(messenger) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { methodCallHandler.destroyMethodChannel() streamHandler.destroyEventChannel() + executionStateStreamHandler.destroyEventChannel() TalsecThreatHandler.detachListener(binding.applicationContext) } diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/RaspExecutionStateEvent.kt b/android/src/main/kotlin/com/aheaditec/freerasp/RaspExecutionStateEvent.kt new file mode 100644 index 0000000..998e75c --- /dev/null +++ b/android/src/main/kotlin/com/aheaditec/freerasp/RaspExecutionStateEvent.kt @@ -0,0 +1,5 @@ +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/Threat.kt b/android/src/main/kotlin/com/aheaditec/freerasp/Threat.kt index 6b4b9e2..aaf478f 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/Threat.kt +++ b/android/src/main/kotlin/com/aheaditec/freerasp/Threat.kt @@ -45,4 +45,6 @@ internal sealed class Threat(val value: Int) { object TimeSpoofing : Threat(189105221) object LocationSpoofing : Threat(653273273) + + object Automation : Threat(298453120) } \ No newline at end of file diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ExecutionStateDispatcher.kt b/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ExecutionStateDispatcher.kt new file mode 100644 index 0000000..b9d4adb --- /dev/null +++ b/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ExecutionStateDispatcher.kt @@ -0,0 +1,38 @@ +package com.aheaditec.freerasp.dispatchers + +import com.aheaditec.freerasp.RaspExecutionStateEvent +import io.flutter.plugin.common.EventChannel.EventSink + +internal class ExecutionStateDispatcher { + private val eventCache = mutableSetOf() + + var eventSink: EventSink? = null + set(value) { + field = value + if (value != null) { + flushCache(value) + } + } + + fun dispatch(event: RaspExecutionStateEvent) { + val sink = synchronized(eventCache) { + val currentSink = eventSink + if (currentSink != null) { + currentSink + } else { + eventCache.add(event) + null + } + } + sink?.success(event.value) + } + + private fun flushCache(sink: EventSink) { + val events = synchronized(eventCache) { + val snapshot = eventCache.toSet() + eventCache.clear() + snapshot + } + events.forEach { sink.success(it.value) } + } +} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ThreatDispatcher.kt b/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ThreatDispatcher.kt new file mode 100644 index 0000000..b3f36b3 --- /dev/null +++ b/android/src/main/kotlin/com/aheaditec/freerasp/dispatchers/ThreatDispatcher.kt @@ -0,0 +1,73 @@ +package com.aheaditec.freerasp.dispatchers + +import com.aheaditec.freerasp.Threat +import com.aheaditec.freerasp.handlers.MethodCallHandler +import com.aheaditec.talsec_security.security.api.SuspiciousAppInfo +import io.flutter.plugin.common.EventChannel.EventSink + +internal class ThreatDispatcher { + private val threatCache = mutableSetOf() + private val malwareCache = mutableListOf() + + var eventSink: EventSink? = null + set(value) { + field = value + if (value != null) { + flushThreatCache(value) + } + } + + var methodSink: MethodCallHandler.MethodSink? = null + set(value) { + field = value + if (value != null) { + flushMalwareCache(value) + } + } + + fun dispatchThreat(threat: Threat) { + val sink = synchronized(threatCache) { + val currentSink = eventSink + if (currentSink != null) { + currentSink + } else { + threatCache.add(threat) + null + } + } + sink?.success(threat.value) + } + + fun dispatchMalware(apps: List) { + val sink = synchronized(malwareCache) { + val currentSink = methodSink + if (currentSink != null) { + currentSink + } else { + malwareCache.addAll(apps) + null + } + } + sink?.onMalwareDetected(apps) + } + + private fun flushThreatCache(sink: EventSink) { + val threats = synchronized(threatCache) { + val snapshot = threatCache.toSet() + threatCache.clear() + snapshot + } + threats.forEach { sink.success(it.value) } + } + + private fun flushMalwareCache(sink: MethodCallHandler.MethodSink) { + val malware = synchronized(malwareCache) { + val snapshot = malwareCache.toMutableList() + malwareCache.clear() + snapshot + } + if (malware.isNotEmpty()) { + sink.onMalwareDetected(malware) + } + } +} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/generated/RaspExecutionState.kt b/android/src/main/kotlin/com/aheaditec/freerasp/generated/RaspExecutionState.kt deleted file mode 100644 index 32761fb..0000000 --- a/android/src/main/kotlin/com/aheaditec/freerasp/generated/RaspExecutionState.kt +++ /dev/null @@ -1,53 +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'.", "")} -private open class RaspExecutionStatePigeonCodec : StandardMessageCodec() { - override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return super.readValueOfType(type, buffer) - } - override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - super.writeValue(stream, value) - } -} - -/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ -class RaspExecutionState(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { - companion object { - /** The codec used by RaspExecutionState. */ - val codec: MessageCodec by lazy { - RaspExecutionStatePigeonCodec() - } - } - fun onAllChecksFinished(callback: (Result) -> Unit) -{ - val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" - val channelName = "dev.flutter.pigeon.freerasp.RaspExecutionState.onAllChecksFinished$separatedMessageChannelSuffix" - val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(null) { - 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/android/src/main/kotlin/com/aheaditec/freerasp/handlers/ExecutionStateStreamHandler.kt b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/ExecutionStateStreamHandler.kt new file mode 100644 index 0000000..215f7fd --- /dev/null +++ b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/ExecutionStateStreamHandler.kt @@ -0,0 +1,43 @@ +package com.aheaditec.freerasp.handlers + +import io.flutter.Log +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel + +/** + * A stream handler that creates and manages an [EventChannel] for freeRASP execution state events. + */ +internal class ExecutionStateStreamHandler : EventChannel.StreamHandler { + + private var eventChannel: EventChannel? = null + + companion object { + private const val CHANNEL_NAME: String = "talsec.app/freerasp/execution_state" + } + + internal fun createEventChannel(messenger: BinaryMessenger) { + if (eventChannel != null) { + Log.i("ExecStateStreamHandler", "Tried to create channel without disposing old one.") + destroyEventChannel() + } + eventChannel = EventChannel(messenger, CHANNEL_NAME).also { + it.setStreamHandler(this) + } + } + + internal fun destroyEventChannel() { + eventChannel?.setStreamHandler(null) + eventChannel = null + TalsecThreatHandler.detachExecutionStateSink() + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + events?.let { + TalsecThreatHandler.attachExecutionStateSink(it) + } + } + + override fun onCancel(arguments: Any?) { + TalsecThreatHandler.detachExecutionStateSink() + } +} diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt index 6bc4bb7..7d740f4 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt +++ b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/MethodCallHandler.kt @@ -9,9 +9,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import com.aheaditec.freerasp.Utils -import com.aheaditec.freerasp.generated.RaspExecutionState -import com.aheaditec.freerasp.runResultCatching import com.aheaditec.freerasp.generated.TalsecPigeonApi +import com.aheaditec.freerasp.runResultCatching import com.aheaditec.freerasp.toPigeon import com.aheaditec.talsec_security.security.api.SuspiciousAppInfo import com.aheaditec.talsec_security.security.api.Talsec @@ -28,7 +27,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { private var context: Context? = null private var methodChannel: MethodChannel? = null private var talsecPigeon: TalsecPigeonApi? = null - private var raspExecutionPigeon : RaspExecutionState? = null private val backgroundHandlerThread = HandlerThread("BackgroundThread").apply { start() } private val backgroundHandler = Handler(backgroundHandlerThread.looper) private val mainHandler = Handler(Looper.getMainLooper()) @@ -53,22 +51,10 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { } } } - - override fun onAllChecksFinished() { - raspExecutionPigeon?.onAllChecksFinished { 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 - } - } - } } internal interface MethodSink { fun onMalwareDetected(packageInfo: List) - fun onAllChecksFinished() } /** @@ -91,7 +77,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { this.context = context this.talsecPigeon = TalsecPigeonApi(messenger) - this.raspExecutionPigeon = RaspExecutionState(messenger) TalsecThreatHandler.attachMethodSink(sink) } @@ -105,7 +90,6 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { this.context = null this.talsecPigeon = null - this.raspExecutionPigeon = null TalsecThreatHandler.detachMethodSink() } @@ -138,6 +122,7 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { "blockScreenCapture" -> blockScreenCapture(call, result) "isScreenCaptureBlocked" -> isScreenCaptureBlocked(result) "storeExternalId" -> storeExternalId(call, result) + "removeExternalId" -> removeExternalId(result) else -> result.notImplemented() } } @@ -238,4 +223,20 @@ internal class MethodCallHandler : MethodCallHandler, LifecycleEventObserver { throw IllegalStateException("Unable to store external ID - context is null") } } + + /** + * Removes the external ID. + * + * @param result The result handler of the method call. + */ + private fun removeExternalId(result: MethodChannel.Result) { + runResultCatching(result) { + context?.let { + Talsec.removeExternalId(it) + result.success(null) + return@runResultCatching + } + throw IllegalStateException("Unable to remove external ID - context is null") + } + } } diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt index 0288a77..003c225 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt +++ b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/PluginThreatHandler.kt @@ -1,7 +1,10 @@ package com.aheaditec.freerasp.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 com.aheaditec.talsec_security.security.api.SuspiciousAppInfo import com.aheaditec.talsec_security.security.api.ThreatListener import com.aheaditec.talsec_security.security.api.ThreatListener.DeviceState @@ -9,128 +12,116 @@ import com.aheaditec.talsec_security.security.api.ThreatListener.RaspExecutionSt import com.aheaditec.talsec_security.security.api.ThreatListener.ThreatDetected /** - * A Singleton object that implements the [ThreatDetected] and [DeviceState] interfaces to handle - * detected security threats in the application. + * A Singleton object that manages the [ThreatListener] to handle detected security threats in the application. * The object provides methods to register a listener for threat notifications and notifies the * listener when a security threat is detected. */ -internal object PluginThreatHandler : ThreatDetected, DeviceState, RaspExecutionState() { - internal val detectedThreats = mutableSetOf() - internal val detectedMalware = mutableListOf() - internal var shouldNotifyAllChecksFinished = false +internal object PluginThreatHandler { - internal var listener: TalsecFlutter? = null - private val internalListener = ThreatListener(this, this, this) + internal val threatDispatcher = ThreatDispatcher() + internal val executionStateDispatcher = ExecutionStateDispatcher() - internal fun registerListener(context: Context) { - internalListener.registerListener(context) - } + private val threatDetected = object : ThreatDetected() { + override fun onRootDetected() { + threatDispatcher.dispatchThreat(Threat.PrivilegedAccess) + } - internal fun unregisterListener(context: Context) { - internalListener.unregisterListener(context) - } + override fun onDebuggerDetected() { + threatDispatcher.dispatchThreat(Threat.Debug) + } - override fun onAllChecksFinished() { - notifyAllChecksFinished() - } + override fun onEmulatorDetected() { + threatDispatcher.dispatchThreat(Threat.Simulator) + } - override fun onRootDetected() { - notify(Threat.PrivilegedAccess) - } + override fun onTamperDetected() { + threatDispatcher.dispatchThreat(Threat.AppIntegrity) + } - override fun onDebuggerDetected() { - notify(Threat.Debug) - } + override fun onUntrustedInstallationSourceDetected() { + threatDispatcher.dispatchThreat(Threat.UnofficialStore) + } - override fun onEmulatorDetected() { - notify(Threat.Simulator) - } - - override fun onTamperDetected() { - notify(Threat.AppIntegrity) - } - - override fun onUntrustedInstallationSourceDetected() { - notify(Threat.UnofficialStore) - } + override fun onHookDetected() { + threatDispatcher.dispatchThreat(Threat.Hooks) + } - override fun onHookDetected() { - notify(Threat.Hooks) - } + override fun onDeviceBindingDetected() { + threatDispatcher.dispatchThreat(Threat.DeviceBinding) + } - override fun onDeviceBindingDetected() { - notify(Threat.DeviceBinding) - } + override fun onObfuscationIssuesDetected() { + threatDispatcher.dispatchThreat(Threat.ObfuscationIssues) + } - override fun onObfuscationIssuesDetected() { - notify(Threat.ObfuscationIssues) - } + override fun onMalwareDetected(suspiciousApps: List) { + threatDispatcher.dispatchMalware(suspiciousApps) + } - override fun onUnlockedDeviceDetected() { - notify(Threat.Passcode) - } + override fun onScreenshotDetected() { + threatDispatcher.dispatchThreat(Threat.Screenshot) + } - override fun onHardwareBackedKeystoreNotAvailableDetected() { - notify(Threat.SecureHardwareNotAvailable) - } + override fun onScreenRecordingDetected() { + threatDispatcher.dispatchThreat(Threat.ScreenRecording) + } - override fun onSystemVPNDetected() { - notify(Threat.SystemVPN) - } + override fun onMultiInstanceDetected() { + threatDispatcher.dispatchThreat(Threat.MultiInstance) + } - override fun onDeveloperModeDetected() { - notify(Threat.DevMode) - } + override fun onUnsecureWifiDetected() { + threatDispatcher.dispatchThreat(Threat.UnsecureWiFi) + } - override fun onADBEnabledDetected() { - notify(Threat.ADBEnabled) - } + override fun onTimeSpoofingDetected() { + threatDispatcher.dispatchThreat(Threat.TimeSpoofing) + } - override fun onScreenshotDetected() { - notify(Threat.Screenshot) - } + override fun onLocationSpoofingDetected() { + threatDispatcher.dispatchThreat(Threat.LocationSpoofing) + } - override fun onScreenRecordingDetected() { - notify(Threat.ScreenRecording) + override fun onAutomationDetected() { + threatDispatcher.dispatchThreat(Threat.Automation) + } } - override fun onMalwareDetected(suspiciousApps: List) { - notify(suspiciousApps) - } + private val deviceState = object : DeviceState() { + override fun onUnlockedDeviceDetected() { + threatDispatcher.dispatchThreat(Threat.Passcode) + } - override fun onMultiInstanceDetected() { - notify(Threat.MultiInstance) - } + override fun onHardwareBackedKeystoreNotAvailableDetected() { + threatDispatcher.dispatchThreat(Threat.SecureHardwareNotAvailable) + } - override fun onUnsecureWifiDetected() { - notify(Threat.UnsecureWiFi) - } + override fun onSystemVPNDetected() { + threatDispatcher.dispatchThreat(Threat.SystemVPN) + } - override fun onTimeSpoofingDetected() { - notify(Threat.TimeSpoofing) - } + override fun onDeveloperModeDetected() { + threatDispatcher.dispatchThreat(Threat.DevMode) + } - override fun onLocationSpoofingDetected() { - notify(Threat.LocationSpoofing) + override fun onADBEnabledDetected() { + threatDispatcher.dispatchThreat(Threat.ADBEnabled) + } } - private fun notify(threat: Threat) { - listener?.threatDetected(threat) ?: detectedThreats.add(threat) + private val raspExecutionState = object : RaspExecutionState() { + override fun onAllChecksFinished() { + executionStateDispatcher.dispatch(RaspExecutionStateEvent.AllChecksFinished) + } } - private fun notify(suspiciousApps: List) { - listener?.malwareDetected(suspiciousApps) ?: detectedMalware.addAll(suspiciousApps) - } + private val internalListener = ThreatListener(threatDetected, deviceState, raspExecutionState) - private fun notifyAllChecksFinished() { - listener?.allChecksFinished() ?: run { shouldNotifyAllChecksFinished = true } + internal fun registerListener(context: Context) { + internalListener.registerListener(context) } - internal interface TalsecFlutter { - fun threatDetected(threatType: Threat) - - fun malwareDetected(suspiciousApps: List) - - fun allChecksFinished() + internal fun unregisterListener(context: Context) { + internalListener.unregisterListener(context) } -} +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt index adb1524..b4a51b8 100644 --- a/android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt +++ b/android/src/main/kotlin/com/aheaditec/freerasp/handlers/TalsecThreatHandler.kt @@ -2,9 +2,10 @@ package com.aheaditec.freerasp.handlers import android.content.Context import android.os.Build +import com.aheaditec.freerasp.RaspExecutionStateEvent import com.aheaditec.freerasp.ScreenProtector -import com.aheaditec.freerasp.Threat -import com.aheaditec.talsec_security.security.api.SuspiciousAppInfo +import com.aheaditec.freerasp.dispatchers.ExecutionStateDispatcher +import com.aheaditec.freerasp.dispatchers.ThreatDispatcher import com.aheaditec.talsec_security.security.api.Talsec import com.aheaditec.talsec_security.security.api.TalsecConfig import io.flutter.plugin.common.EventChannel.EventSink @@ -14,8 +15,6 @@ import io.flutter.plugin.common.EventChannel.EventSink * security threats to Flutter. */ internal object TalsecThreatHandler { - private var eventSink: EventSink? = null - private var methodSink: MethodCallHandler.MethodSink? = null private var isListening = false /** @@ -38,7 +37,6 @@ internal object TalsecThreatHandler { */ internal fun stop(context: Context) { detachListener(context) - PluginThreatHandler.listener = null Talsec.stop() } @@ -90,7 +88,11 @@ internal object TalsecThreatHandler { * [EventSink] is not destroyed but also is not able to send events. */ internal fun suspendListener() { - PluginThreatHandler.listener = null + savedThreatEventSink = PluginThreatHandler.threatDispatcher.eventSink + PluginThreatHandler.threatDispatcher.eventSink = null + + savedExecutionStateSink = PluginThreatHandler.executionStateDispatcher.eventSink + PluginThreatHandler.executionStateDispatcher.eventSink = null } /** @@ -105,12 +107,20 @@ internal object TalsecThreatHandler { * also is not able to send events. */ internal fun resumeListener() { - eventSink?.let { - PluginThreatHandler.listener = ThreatListener - flushThreatCache(it) + if (savedThreatEventSink != null) { + PluginThreatHandler.threatDispatcher.eventSink = savedThreatEventSink + savedThreatEventSink = null + } + if (savedExecutionStateSink != null) { + PluginThreatHandler.executionStateDispatcher.eventSink = savedExecutionStateSink + savedExecutionStateSink = null } } + private var savedThreatEventSink: EventSink? = null + private var savedExecutionStateSink: EventSink? = null + + /** * Called when a new listener subscribes to the event channel. Sends any previously detected * threats to the new listener. @@ -118,69 +128,33 @@ internal object TalsecThreatHandler { * @param eventSink The event sink of the new listener. */ internal fun attachEventSink(eventSink: EventSink) { - this.eventSink = eventSink - PluginThreatHandler.listener = ThreatListener - flushThreatCache(eventSink) + PluginThreatHandler.threatDispatcher.eventSink = eventSink } /** * Called when a listener unsubscribes from the event channel. */ internal fun detachEventSink() { - eventSink = null - PluginThreatHandler.listener = null + PluginThreatHandler.threatDispatcher.eventSink = null + savedThreatEventSink = null } - /** - * Sends any cached detected threats to the listener. - * - * @param eventSink The event sink of the new listener. - */ - private fun flushThreatCache(eventSink: EventSink?) { - PluginThreatHandler.detectedThreats.forEach { - eventSink?.success(it.value) - } - - PluginThreatHandler.detectedMalware.let { - if (it.isNotEmpty()) { - methodSink?.onMalwareDetected(it) - } - } - - if (PluginThreatHandler.shouldNotifyAllChecksFinished) { - methodSink?.onAllChecksFinished() - } + internal fun attachExecutionStateSink(eventSink: EventSink) { + PluginThreatHandler.executionStateDispatcher.eventSink = eventSink + } - PluginThreatHandler.detectedThreats.clear() - PluginThreatHandler.detectedMalware.clear() - PluginThreatHandler.shouldNotifyAllChecksFinished = false + internal fun detachExecutionStateSink() { + PluginThreatHandler.executionStateDispatcher.eventSink = null + savedExecutionStateSink = null } internal fun attachMethodSink(sink: MethodCallHandler.MethodSink) { - this.methodSink = sink + PluginThreatHandler.threatDispatcher.methodSink = sink } internal fun detachMethodSink() { - methodSink = null - } - - /** - * Called when a security threat is detected. Sends the threat information to the current - * listener (if one exists) or adds it to the [PluginThreatHandler.detectedThreats] list to be - * sent to the next listener that subscribes to the event channel. - */ - internal object ThreatListener : PluginThreatHandler.TalsecFlutter { - override fun threatDetected(threatType: Threat) { - eventSink?.success(threatType.value) - } - - override fun malwareDetected(suspiciousApps: List) { - methodSink?.onMalwareDetected(suspiciousApps) - } - - override fun allChecksFinished() { - methodSink?.onAllChecksFinished() - } + PluginThreatHandler.threatDispatcher.methodSink = null } } + diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 3363e13..4f8ed60 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -24,7 +24,7 @@ if (flutterVersionName == null) { android { namespace 'com.aheaditec.freerasp_example' - compileSdkVersion 35 + compileSdk 36 ndkVersion = "27.1.12297006" compileOptions { diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 455de43..ae93778 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -19,10 +19,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - freerasp: d77275f774facb901f52e9608e5bd34768728363 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + freerasp: bb827d80b926abcfb8f4ca4ff4557c2fe4a5ae21 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 PODFILE CHECKSUM: 0dbd5a87e0ace00c9610d2037ac22083a01f861d -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/example/lib/main.dart b/example/lib/main.dart index ab9ec32..7a9311f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -52,16 +52,12 @@ Future _initializeTalsec() async { ), watcherMail: 'your_mail@example.com', isProd: true, + killOnBypass: true, ); await Talsec.instance.start(config); } -/// Example of how to use [Talsec.storeExternalId]. -Future testStoreExternalId(String data) async { - await Talsec.instance.storeExternalId(data); -} - /// The root widget of the application class App extends StatelessWidget { const App({super.key}); @@ -77,11 +73,30 @@ class App extends StatelessWidget { } /// The home page that displays the threats and results -class HomePage extends ConsumerWidget { +class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState { + late final TextEditingController _externalIdController; + + @override + void initState() { + super.initState(); + _externalIdController = TextEditingController(); + } + + @override + void dispose() { + _externalIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final threatState = ref.watch(threatProvider); // Listen for changes in the threatProvider and show the malware modal @@ -103,25 +118,87 @@ class HomePage extends ConsumerWidget { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - ListTile( - title: const Text('Store External ID'), - trailing: IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - testStoreExternalId('testData'); - }, - ), + ExpansionTile( + title: const Text('Change External ID'), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _externalIdController, + decoration: const InputDecoration( + labelText: 'External ID', + hintText: 'Enter external ID here', + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ElevatedButton( + onPressed: () { + final id = _externalIdController.text; + if (id.isNotEmpty) { + Talsec.instance.storeExternalId(id); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Stored External ID: $id'), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter an External ID'), + ), + ); + } + }, + child: const Text('Store External ID'), + ), + ElevatedButton( + onPressed: () { + Talsec.instance.removeExternalId(); + _externalIdController.clear(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Removed External ID'), + ), + ); + }, + child: const Text('Remove External ID'), + ), + ], + ), + const SizedBox(height: 16), + ], ), - ListTile( - title: const Text('Change Screen Capture'), - leading: SafetyIcon( - isDetected: !(ref.watch(screenCaptureProvider).value ?? true), - ), - trailing: IconButton( - icon: const Icon(Icons.refresh), - onPressed: () { - ref.read(screenCaptureProvider.notifier).toggle(); - }, + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Screen Capture', + style: Theme.of(context).textTheme.titleMedium, + ), + ElevatedButton( + onPressed: () => + ref.read(screenCaptureProvider.notifier).toggle(), + style: ElevatedButton.styleFrom( + backgroundColor: + (ref.watch(screenCaptureProvider).value ?? false) + ? Colors.green + : Colors.red, + ), + child: Text( + (ref.watch(screenCaptureProvider).value ?? false) + ? 'Protected' + : 'Unprotected', + style: const TextStyle(color: Colors.white), + ), + ), + ], ), ), ListTile( diff --git a/example/lib/threat_notifier.dart b/example/lib/threat_notifier.dart index 927dc6d..ff49332 100644 --- a/example/lib/threat_notifier.dart +++ b/example/lib/threat_notifier.dart @@ -34,6 +34,7 @@ class ThreatNotifier extends AutoDisposeNotifier { onUnsecureWiFi: () => _updateThreat(Threat.unsecureWiFi), onTimeSpoofing: () => _updateThreat(Threat.timeSpoofing), onLocationSpoofing: () => _updateThreat(Threat.locationSpoofing), + onAutomation: () => _updateThreat(Threat.automation), ); final raspExecutionStateCallback = diff --git a/ios/Classes/Dispatchers/ExecutionStateDispatcher.swift b/ios/Classes/Dispatchers/ExecutionStateDispatcher.swift new file mode 100644 index 0000000..28e36ca --- /dev/null +++ b/ios/Classes/Dispatchers/ExecutionStateDispatcher.swift @@ -0,0 +1,35 @@ +import Foundation + +class ExecutionStateDispatcher { + static let shared = ExecutionStateDispatcher() + private var cache: Set = [] + private let lock = NSLock() + + var listener: ((RaspExecutionStates) -> Void)? { + didSet { + if listener != nil { + flushCache() + } + } + } + + func dispatch(event: RaspExecutionStates) { + lock.lock() + defer { lock.unlock() } + + if let listener = listener { + listener(event) + } else { + cache.insert(event) + } + } + + private func flushCache() { + lock.lock() + let events = cache + cache.removeAll() + lock.unlock() + + events.forEach { listener?($0) } + } +} diff --git a/ios/Classes/Dispatchers/ThreatDispatcher.swift b/ios/Classes/Dispatchers/ThreatDispatcher.swift new file mode 100644 index 0000000..f415534 --- /dev/null +++ b/ios/Classes/Dispatchers/ThreatDispatcher.swift @@ -0,0 +1,36 @@ +import Foundation +import TalsecRuntime + +class ThreatDispatcher { + static let shared = ThreatDispatcher() + private var threatCache: Set = [] + private let lock = NSLock() + + var listener: ((SecurityThreat) -> Void)? { + didSet { + if listener != nil { + flushCache() + } + } + } + + func dispatch(threat: SecurityThreat) { + lock.lock() + defer { lock.unlock() } + + if let listener = listener { + listener(threat) + } else { + threatCache.insert(threat) + } + } + + private func flushCache() { + lock.lock() + let threats = threatCache + threatCache.removeAll() + lock.unlock() + + threats.forEach { listener?($0) } + } +} diff --git a/ios/Classes/Generated/RaspExecutionState.swift b/ios/Classes/Generated/RaspExecutionState.swift deleted file mode 100644 index c40d6ee..0000000 --- a/ios/Classes/Generated/RaspExecutionState.swift +++ /dev/null @@ -1,97 +0,0 @@ -// Autogenerated from Pigeon (v22.7.4), do not edit directly. -// See also: https://pub.dev/packages/pigeon - -import Foundation - -#if os(iOS) - import Flutter -#elseif os(macOS) - import FlutterMacOS -#else - #error("Unsupported platform.") -#endif - -/// Error class for passing custom error details to Dart side. -final class PigeonError: Error { - let code: String - let message: String? - let details: Any? - - init(code: String, message: String?, details: Any?) { - self.code = code - self.message = message - self.details = details - } - - var localizedDescription: String { - return - "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" - } -} - -private func createConnectionError(withChannelName channelName: String) -> PigeonError { - return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") -} - -private func isNullish(_ value: Any?) -> Bool { - return value is NSNull || value == nil -} - -private func nilOrValue(_ value: Any?) -> T? { - if value is NSNull { return nil } - return value as! T? -} - -private class RaspExecutionStatePigeonCodecReader: FlutterStandardReader { -} - -private class RaspExecutionStatePigeonCodecWriter: FlutterStandardWriter { -} - -private class RaspExecutionStatePigeonCodecReaderWriter: FlutterStandardReaderWriter { - override func reader(with data: Data) -> FlutterStandardReader { - return RaspExecutionStatePigeonCodecReader(data: data) - } - - override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return RaspExecutionStatePigeonCodecWriter(data: data) - } -} - -class RaspExecutionStatePigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = RaspExecutionStatePigeonCodec(readerWriter: RaspExecutionStatePigeonCodecReaderWriter()) -} - -/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. -protocol RaspExecutionStateProtocol { - func onAllChecksFinished(completion: @escaping (Result) -> Void) -} -class RaspExecutionState: RaspExecutionStateProtocol { - private let binaryMessenger: FlutterBinaryMessenger - private let messageChannelSuffix: String - init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { - self.binaryMessenger = binaryMessenger - self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - } - var codec: RaspExecutionStatePigeonCodec { - return RaspExecutionStatePigeonCodec.shared - } - func onAllChecksFinished(completion: @escaping (Result) -> Void) { - let channelName: String = "dev.flutter.pigeon.freerasp.RaspExecutionState.onAllChecksFinished\(messageChannelSuffix)" - let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage(nil) { response in - guard let listResponse = response as? [Any?] else { - completion(.failure(createConnectionError(withChannelName: channelName))) - return - } - if listResponse.count > 1 { - let code: String = listResponse[0] as! String - let message: String? = nilOrValue(listResponse[1]) - let details: String? = nilOrValue(listResponse[2]) - completion(.failure(PigeonError(code: code, message: message, details: details))) - } else { - completion(.success(Void())) - } - } - } -} diff --git a/ios/Classes/Models/RaspExecutionStates.swift b/ios/Classes/Models/RaspExecutionStates.swift new file mode 100644 index 0000000..4ea3577 --- /dev/null +++ b/ios/Classes/Models/RaspExecutionStates.swift @@ -0,0 +1,3 @@ +enum RaspExecutionStates: Int { + case allChecksFinished = 187429 +} diff --git a/ios/Classes/Processors/EventProcessor.swift b/ios/Classes/Processors/EventProcessor.swift deleted file mode 100644 index 0c89fde..0000000 --- a/ios/Classes/Processors/EventProcessor.swift +++ /dev/null @@ -1,66 +0,0 @@ -import TalsecRuntime - -/// A class responsible for processing and managing security threat events. -/// -/// The `EventProcessor` handles the communication of security threat events from -/// the native security engine to Flutter. It provides a queuing mechanism for -/// events that occur before a Flutter listener is attached, ensuring no events -/// are lost during the initialization phase. -/// -/// This class implements a producer-consumer pattern where security threats are -/// produced by the native engine and consumed by Flutter through event streams. -class EventProcessor { - /// A set of security threats that have been detected but not yet processed. - /// - /// This collection serves as a queue for security threat events that occur - /// before a Flutter event sink is attached. Events are stored here until - /// a sink becomes available for processing. - private var detectedThreats = Set() - - /// A sink for sending processed security threat events to Flutter. - /// - /// This property holds the Flutter event sink that is used to send security - /// threat events to the Flutter side. When nil, events are queued in the - /// `detectedThreats` set for later processing. - private var sink: FlutterEventSink? - - /// Attaches a new sink for sending processed security threat events. - /// - /// This method should be called in the `onListen` callback of a `FlutterStreamHandler`. - /// When a sink is attached, any previously queued events are immediately - /// processed and sent to Flutter. - /// - /// - Parameter sink: A closure that takes a `String` argument and returns void. - /// This closure is used to send processed security threat events to Flutter. - func attachSink(sink: @escaping FlutterEventSink){ - self.sink = sink - detectedThreats.forEach(processEvent) - detectedThreats.removeAll() - } - - /// Detaches the current sink. - /// - /// This method should be called in the `onCancel` callback of a `FlutterStreamHandler`. - /// After detaching, new security threat events will be queued until a new - /// sink is attached. - func detachSink(){ - self.sink = nil - } - - /// Processes a security threat event. - /// - /// This method handles the processing of individual security threat events. - /// If a Flutter event sink is available, the event is immediately sent to - /// Flutter. Otherwise, the event is cached in the `detectedThreats` set - /// for later processing when a sink becomes available. - /// - /// - Parameter event: The `SecurityThreat` event to be processed. - func processEvent(_ event: SecurityThreat){ - guard let eventSink = sink else { - detectedThreats.insert(event) - return - } - - eventSink(event.callbackIdentifier) - } -} diff --git a/ios/Classes/Processors/ExecutionStreamHandler.swift b/ios/Classes/Processors/ExecutionStreamHandler.swift new file mode 100644 index 0000000..7d801a0 --- /dev/null +++ b/ios/Classes/Processors/ExecutionStreamHandler.swift @@ -0,0 +1,17 @@ +import Flutter + +class ExecutionStreamHandler: NSObject, FlutterStreamHandler { + static let shared = ExecutionStreamHandler() + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + ExecutionStateDispatcher.shared.listener = { state in + events(state.rawValue) + } + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + ExecutionStateDispatcher.shared.listener = nil + return nil + } +} diff --git a/ios/Classes/Processors/StateProcessor.swift b/ios/Classes/Processors/StateProcessor.swift deleted file mode 100644 index d106296..0000000 --- a/ios/Classes/Processors/StateProcessor.swift +++ /dev/null @@ -1,32 +0,0 @@ -class StateProcessor { - private var hasChecksFinished = false - - private var raspStatePigeon: RaspExecutionStateProtocol? - - func attachPigeon(pigeon: RaspExecutionStateProtocol) { - self.raspStatePigeon = pigeon - if (hasChecksFinished) { - processState() - } - } - - func detachPigeon() { - self.raspStatePigeon = nil - } - - func processState() { - guard let pigeon = raspStatePigeon else { - hasChecksFinished = true - return - } - - pigeon.onAllChecksFinished{ - result in - if case .failure(let error) = result { - print("Error: \(error)") - } else { - print("Success!") - } - } - } -} diff --git a/ios/Classes/SwiftFreeraspPlugin.swift b/ios/Classes/SwiftFreeraspPlugin.swift index da92c55..f7414ff 100644 --- a/ios/Classes/SwiftFreeraspPlugin.swift +++ b/ios/Classes/SwiftFreeraspPlugin.swift @@ -4,16 +4,10 @@ import TalsecRuntime /// A Flutter plugin that interacts with the Talsec runtime library, handles method calls and provides event streams. public class SwiftFreeraspPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { - /// The event processor used to handle and dispatch events. - private let eventProcessor = EventProcessor() - - private static let stateProcessor = StateProcessor() /// The singleton instance of `SwiftTalsecPlugin`. static let instance = SwiftFreeraspPlugin() - private var raspExecutionStatePigeon: RaspExecutionStateProtocol? = nil - private override init() {} /// Registers this plugin with the given `FlutterPluginRegistrar`. @@ -22,12 +16,12 @@ public class SwiftFreeraspPlugin: NSObject, FlutterPlugin, FlutterStreamHandler let eventChannel = FlutterEventChannel(name: "talsec.app/freerasp/events", binaryMessenger: messenger) eventChannel.setStreamHandler(instance) + let executionStateChannel = FlutterEventChannel(name: "talsec.app/freerasp/execution_state", binaryMessenger: messenger) + executionStateChannel.setStreamHandler(ExecutionStreamHandler.shared) + //Channels init let methodChannel : FlutterMethodChannel = FlutterMethodChannel(name: "talsec.app/freerasp/methods", binaryMessenger: messenger) registrar.addMethodCallDelegate(instance, channel: methodChannel) - - let pigeon = RaspExecutionState(binaryMessenger: messenger) - stateProcessor.attachPigeon(pigeon: pigeon) } /// Handles a method call from Flutter. @@ -51,6 +45,9 @@ public class SwiftFreeraspPlugin: NSObject, FlutterPlugin, FlutterStreamHandler case "storeExternalId": storeExternalId(data: args["data"] as? String, result: result) return + case "removeExternalId": + removeExternalId(result: result) + return default: result(FlutterMethodNotImplemented) } @@ -137,48 +134,36 @@ public class SwiftFreeraspPlugin: NSObject, FlutterPlugin, FlutterStreamHandler UserDefaults.standard.set(data, forKey: "app.talsec.externalid") result(nil) } + + /// Removes the external ID from user defaults. + /// + /// - Parameters: + /// - result: The `FlutterResult` object to be returned to the caller. + private func removeExternalId(result: @escaping FlutterResult){ + UserDefaults.standard.removeObject(forKey: "app.talsec.externalid") + result(nil) + } - /// Attaches a FlutterEventSink to the EventProcessor and processes any detectedThreats in the queue. + /// Attaches a FlutterEventSink to the ThreatDispatcher and processes any detectedThreats in the queue. /// /// - Parameters: /// - arguments: Unused - /// - events: The FlutterEventSink to be attached to the EventProcessor. + /// - events: The FlutterEventSink to be attached to the ThreatDispatcher. /// - Returns: Always returns nil. public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - eventProcessor.attachSink(sink: events) + ThreatDispatcher.shared.listener = { threat in + events(threat.callbackIdentifier) + } return nil } - // Detaches the current FlutterEventSink from the EventProcessor. + // Detaches the current FlutterEventSink from the ThreatDispatcher. /// /// - Parameters: /// - arguments: Unused /// - Returns: Always returns nil. public func onCancel(withArguments arguments: Any?) -> FlutterError? { - eventProcessor.detachSink() + ThreatDispatcher.shared.listener = nil return nil } - - /// Processes a submitted SecurityThreat event. - /// - /// - Parameters: - /// - submittedEvent: The SecurityThreat event to be processed. - public func submitEvent(_ submittedEvent: SecurityThreat) { - if (submittedEvent == SecurityThreat.passcodeChange){ - return - } - eventProcessor.processEvent(submittedEvent) - } - - /// Submits a finished event to notify Flutter that all security checks are complete. - /// - /// This method is called by the native security engine when all security - /// validation checks have been completed. It triggers the state processor - /// to send a completion notification to Flutter through the Pigeon protocol. - /// - /// This method should be called after the security engine has finished - /// executing all its validation routines. - public func submitFinishedEvent() { - SwiftFreeraspPlugin.stateProcessor.processState() - } } diff --git a/ios/Classes/TalsecHandlers.swift b/ios/Classes/TalsecHandlers.swift index eb78e17..be57721 100644 --- a/ios/Classes/TalsecHandlers.swift +++ b/ios/Classes/TalsecHandlers.swift @@ -19,11 +19,14 @@ private let screenRecordingValue = 64690214 extension SecurityThreatCenter: SecurityThreatHandler, TalsecRuntime.RaspExecutionState { public func threatDetected(_ securityThreat: TalsecRuntime.SecurityThreat) { - SwiftFreeraspPlugin.instance.submitEvent(securityThreat) + if securityThreat == .passcodeChange { + return + } + ThreatDispatcher.shared.dispatch(threat: securityThreat) } public func onAllChecksFinished() { - SwiftFreeraspPlugin.instance.submitFinishedEvent() + ExecutionStateDispatcher.shared.dispatch(event: .allChecksFinished) } } diff --git a/lib/src/callbacks/rasp_execution_state_callback.dart b/lib/src/callbacks/rasp_execution_state_callback.dart index f9c871f..9f9eeae 100644 --- a/lib/src/callbacks/rasp_execution_state_callback.dart +++ b/lib/src/callbacks/rasp_execution_state_callback.dart @@ -1,4 +1,3 @@ -import 'package:freerasp/src/generated/rasp_execution_state.g.dart' as pigeon; import 'package:freerasp/src/typedefs.dart'; /// A callback class that handles RASP (Runtime Application Self-Protection) @@ -15,7 +14,7 @@ import 'package:freerasp/src/typedefs.dart'; /// /// Talsec.instance.attachExecutionStateListener(callback); /// ``` -class RaspExecutionStateCallback extends pigeon.RaspExecutionState { +class RaspExecutionStateCallback { /// Creates a new [RaspExecutionStateCallback] instance. /// /// The [onAllChecksDone] callback will be invoked when all security checks @@ -26,9 +25,4 @@ class RaspExecutionStateCallback extends pigeon.RaspExecutionState { /// Callback invoked when all security checks are completed. final VoidCallback? onAllChecksDone; - - @override - void onAllChecksFinished() { - onAllChecksDone?.call(); - } } diff --git a/lib/src/callbacks/threat_callback.dart b/lib/src/callbacks/threat_callback.dart index c9dee52..8d7e935 100644 --- a/lib/src/callbacks/threat_callback.dart +++ b/lib/src/callbacks/threat_callback.dart @@ -42,6 +42,7 @@ class ThreatCallback extends TalsecPigeonApi { this.onUnsecureWiFi, this.onTimeSpoofing, this.onLocationSpoofing, + this.onAutomation, }); /// This method is called when a threat related dynamic hooking (e.g. Frida) @@ -111,6 +112,9 @@ class ThreatCallback extends TalsecPigeonApi { /// This method is called when location manipulation is detected final VoidCallback? onLocationSpoofing; + /// This method is called when automation is detected + final VoidCallback? onAutomation; + @override void onMalwareDetected(List packageInfo) { onMalware?.call(packageInfo); diff --git a/lib/src/enums/enums.dart b/lib/src/enums/enums.dart index a60d5c2..ec737bf 100644 --- a/lib/src/enums/enums.dart +++ b/lib/src/enums/enums.dart @@ -1 +1,2 @@ +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 new file mode 100644 index 0000000..2bb8d5f --- /dev/null +++ b/lib/src/enums/rasp_execution_state.dart @@ -0,0 +1,19 @@ +/// Represents the state of the RASP (Runtime Application Self-Protection) +/// execution. +enum RaspExecutionState { + /// All security checks have been completed. + allChecksFinished, +} + +/// Extensions for [RaspExecutionState]. +extension RaspExecutionStateX on RaspExecutionState { + /// Converts an integer value to a [RaspExecutionState]. + static RaspExecutionState fromInt(int value) { + switch (value) { + case 187429: + return RaspExecutionState.allChecksFinished; + default: + throw ArgumentError('Unknown execution state code: $value'); + } + } +} diff --git a/lib/src/enums/threat.dart b/lib/src/enums/threat.dart index cbf4a58..ba20e48 100644 --- a/lib/src/enums/threat.dart +++ b/lib/src/enums/threat.dart @@ -88,6 +88,11 @@ enum Threat { /// /// Android only locationSpoofing, + + /// This method is called when automation is detected + /// + /// Android only + automation, } /// An extension on the [Threat] enum to provide additional functionality. @@ -122,6 +127,7 @@ extension ThreatX on Threat { /// * 705651459 - screenshot /// * 64690214 - screenRecording /// * 859307284 - multiInstance + /// * 298453120 - automation static Threat fromInt(int code) { switch (code) { case 1268968002: @@ -164,6 +170,8 @@ extension ThreatX on Threat { return Threat.timeSpoofing; case 653273273: return Threat.locationSpoofing; + case 298453120: + return Threat.automation; default: // Unknown data came from native code. This shouldn't normally happen. exit(127); diff --git a/lib/src/generated/rasp_execution_state.g.dart b/lib/src/generated/rasp_execution_state.g.dart deleted file mode 100644 index e800710..0000000 --- a/lib/src/generated/rasp_execution_state.g.dart +++ /dev/null @@ -1,79 +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 _PigeonCodec extends StandardMessageCodec { - const _PigeonCodec(); - @override - void writeValue(WriteBuffer buffer, Object? value) { - if (value is int) { - buffer.putUint8(4); - buffer.putInt64(value); - } else { - super.writeValue(buffer, value); - } - } - - @override - Object? readValueOfType(int type, ReadBuffer buffer) { - switch (type) { - default: - return super.readValueOfType(type, buffer); - } - } -} - -abstract class RaspExecutionState { - static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - - void onAllChecksFinished(); - - static void setUp( - RaspExecutionState? api, { - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) { - messageChannelSuffix = - messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( - 'dev.flutter.pigeon.freerasp.RaspExecutionState.onAllChecksFinished$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger); - if (api == null) { - pigeonVar_channel.setMessageHandler(null); - } else { - pigeonVar_channel.setMessageHandler((Object? message) async { - try { - api.onAllChecksFinished(); - 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/talsec.dart b/lib/src/talsec.dart index d64027e..edd080b 100644 --- a/lib/src/talsec.dart +++ b/lib/src/talsec.dart @@ -7,7 +7,6 @@ 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/rasp_execution_state.g.dart' as pigeon; import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart' as pigeon; /// A class which maintains all security related operations. @@ -32,7 +31,11 @@ import 'package:freerasp/src/generated/talsec_pigeon_api.g.dart' as pigeon; class Talsec { /// Private constructor for internal and testing purposes. @visibleForTesting - Talsec.private(this.methodChannel, this.eventChannel); + Talsec.private( + this.methodChannel, + this.eventChannel, + this.executionStateChannel, + ); /// Named channel used to communicate with platform plugins. /// @@ -47,8 +50,18 @@ class Talsec { static const MethodChannel _methodChannel = MethodChannel('talsec.app/freerasp/methods'); + /// Named channel used to communicate with platform plugins. + /// + /// Stream of execution state events. + static const EventChannel _executionStateChannel = + EventChannel('talsec.app/freerasp/execution_state'); + /// Private [Talsec] variable which holds current instance of class. - static final _instance = Talsec.private(_methodChannel, _eventChannel); + static final _instance = Talsec.private( + _methodChannel, + _eventChannel, + _executionStateChannel, + ); /// Initialize Talsec lazily/obtain current instance of Talsec. static Talsec get instance => _instance; @@ -61,9 +74,16 @@ class Talsec { @visibleForTesting late final EventChannel eventChannel; + /// [EventChannel] used to receive execution state events from the native + /// platform. + @visibleForTesting + late final EventChannel executionStateChannel; + StreamSubscription? _streamSubscription; + StreamSubscription? _executionStateSubscription; Stream? _onThreatDetected; + Stream? _onRaspExecutionState; /// Returns a broadcast stream. When security is compromised /// [onThreatDetected] receives what type of Threat caused it. @@ -102,6 +122,22 @@ class Talsec { return _onThreatDetected!; } + /// Returns a broadcast stream. When RASP execution state changes + /// [onRaspExecutionState] receives what state it is. + Stream get onRaspExecutionState { + if (_onRaspExecutionState != null) { + return _onRaspExecutionState!; + } + + _onRaspExecutionState = executionStateChannel + .receiveBroadcastStream() + .cast() + .map(RaspExecutionStateX.fromInt) + .handleError(_handleStreamError); + + return _onRaspExecutionState!; + } + /// Starts freeRASP with configuration provided in [config]. Future start(TalsecConfig config) { _checkConfig(config); @@ -185,6 +221,17 @@ class Talsec { } } + /// Removes the external ID. + /// + /// Throws a [ExternalIdFailureException] when removing failed. + Future removeExternalId() async { + try { + await methodChannel.invokeMethod('removeExternalId'); + } on PlatformException catch (e) { + throw ExternalIdFailureException.fromPlatformException(e); + } + } + void _checkConfig(TalsecConfig config) { switch (defaultTargetPlatform) { case TargetPlatform.android: @@ -262,6 +309,8 @@ class Talsec { callback.onTimeSpoofing?.call(); case Threat.locationSpoofing: callback.onLocationSpoofing?.call(); + case Threat.automation: + callback.onAutomation?.call(); } }); } @@ -285,13 +334,22 @@ class Talsec { } /// Attaches instance of [RaspExecutionStateCallback] to Talsec. - void attachExecutionStateListener(RaspExecutionStateCallback callback) { - pigeon.RaspExecutionState.setUp(callback); + Future attachExecutionStateListener( + RaspExecutionStateCallback callback, + ) async { + await detachExecutionStateListener(); + _executionStateSubscription ??= onRaspExecutionState.listen((event) { + switch (event) { + case RaspExecutionState.allChecksFinished: + callback.onAllChecksDone?.call(); + } + }); } /// Detaches instance of latest [RaspExecutionStateCallback]. - void detachExecutionStateListener() { - pigeon.RaspExecutionState.setUp(null); + Future detachExecutionStateListener() async { + await _executionStateSubscription?.cancel(); + _executionStateSubscription = null; } /// Retrieves the app icon for the given [packageName] as base64 string. @@ -314,7 +372,10 @@ class Talsec { Future _getAppIcon(String packageName) async { final args = {'packageName': packageName}; - final result = await methodChannel.invokeMethod('getAppIcon', args); + final result = await methodChannel.invokeMethod( + 'getAppIcon', + args, + ); if (result is! String) { throw const MalwareFailureException(message: 'Malware App icon is null.'); diff --git a/pigeons/rasp_execution_state.dart b/pigeons/rasp_execution_state.dart deleted file mode 100644 index f4b39b4..0000000 --- a/pigeons/rasp_execution_state.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:pigeon/pigeon.dart'; - -@ConfigurePigeon( - PigeonOptions( - input: 'pigeons/rasp_execution_state.dart', - dartOut: 'lib/src/generated/rasp_execution_state.g.dart', - kotlinOut: - 'android/src/main/kotlin/com/aheaditec/freerasp/generated/RaspExecutionState.kt', - kotlinOptions: KotlinOptions( - package: 'com.aheaditec.freerasp.generated', - includeErrorClass: false, - ), - swiftOut: 'ios/Classes/Generated/RaspExecutionState.swift', - swiftOptions: SwiftOptions( - fileSpecificClassNameComponent: 'Pigeon', - ), - ), -) -@FlutterApi() -// Might be extended in the future -// ignore: one_member_abstracts -abstract class RaspExecutionState { - void onAllChecksFinished(); -} diff --git a/pubspec.yaml b/pubspec.yaml index c3d8a2f..6b6b6ac 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: 7.3.0 +version: 7.4.0 homepage: https://www.talsec.app/freerasp-in-app-protection-security-talsec repository: https://github.com/talsec/Free-RASP-Flutter diff --git a/test/src/enums/threat_test.dart b/test/src/enums/threat_test.dart index 2ae8985..64e82ae 100644 --- a/test/src/enums/threat_test.dart +++ b/test/src/enums/threat_test.dart @@ -5,7 +5,7 @@ void main() { test('Threat enum should contain 20 values', () { final threatValuesLength = Threat.values.length; - expect(threatValuesLength, 20); + expect(threatValuesLength, 21); }); test('Threat enum should match its values index', () { @@ -31,6 +31,7 @@ void main() { expect(threatValues[17], Threat.unsecureWiFi); expect(threatValues[18], Threat.timeSpoofing); expect(threatValues[19], Threat.locationSpoofing); + expect(threatValues[20], Threat.automation); }); test( @@ -57,6 +58,7 @@ void main() { expect(ThreatX.fromInt(363588890), Threat.unsecureWiFi); expect(ThreatX.fromInt(189105221), Threat.timeSpoofing); expect(ThreatX.fromInt(653273273), Threat.locationSpoofing); + expect(ThreatX.fromInt(298453120), Threat.automation); }, ); } diff --git a/test/src/talsec_test.dart b/test/src/talsec_test.dart index c94c681..e710e86 100644 --- a/test/src/talsec_test.dart +++ b/test/src/talsec_test.dart @@ -46,6 +46,7 @@ void main() { final talsec = Talsec.private( methodChannel.methodChannel, FakeEventChannel(), + FakeEventChannel(), ); // Assert @@ -72,6 +73,7 @@ void main() { final talsec = Talsec.private( methodChannel.methodChannel, FakeEventChannel(), + FakeEventChannel(), ); // Assert @@ -94,6 +96,7 @@ void main() { final talsec = Talsec.private( methodChannel.methodChannel, FakeEventChannel(), + FakeEventChannel(), ); // Assert @@ -122,6 +125,7 @@ void main() { final talsec = Talsec.private( methodChannel.methodChannel, FakeEventChannel(), + FakeEventChannel(), ); // Assert @@ -162,6 +166,7 @@ void main() { final talsec = Talsec.private( FakeMethodChannel(), eventChannel.eventChannel, + FakeEventChannel(), ); // Act @@ -187,6 +192,7 @@ void main() { final talsec = Talsec.private( FakeMethodChannel(), eventChannel.eventChannel, + FakeEventChannel(), ); // Act @@ -220,6 +226,7 @@ void main() { final talsec = Talsec.private( FakeMethodChannel(), eventChannel.eventChannel, + FakeEventChannel(), ); // Act @@ -244,4 +251,55 @@ void main() { expect(identical(firstStream, secondStream), isTrue); }); }); + + group('RaspExecutionState', () { + test('Should receive stream of RaspExecutionState', () { + // Arrange + final eventChannel = MockEventChannel( + eventChannel: Talsec.instance.executionStateChannel, + data: [187429], + ); + final talsec = Talsec.private( + FakeMethodChannel(), + FakeEventChannel(), + eventChannel.eventChannel, + ); + + // Act + final stream = talsec.onRaspExecutionState; + + //Assert + expectLater( + stream, + emitsInOrder([ + RaspExecutionState.allChecksFinished, + emitsDone, + ]), + ); + }); + + test('attachExecutionStateListener should invoke callback', () async { + // Arrange + final eventChannel = MockEventChannel( + eventChannel: Talsec.instance.executionStateChannel, + data: [187429], + ); + final talsec = Talsec.private( + FakeMethodChannel(), + FakeEventChannel(), + eventChannel.eventChannel, + ); + + final completer = Completer(); + final callback = RaspExecutionStateCallback( + onAllChecksDone: completer.complete, + ); + + // Act + await talsec.attachExecutionStateListener(callback); + + // Assert + await expectLater(completer.future, completes); + }); + }); } diff --git a/test/test_utils/spy_threat_callback.dart b/test/test_utils/spy_threat_callback.dart index 4a32566..fa2496b 100644 --- a/test/test_utils/spy_threat_callback.dart +++ b/test/test_utils/spy_threat_callback.dart @@ -25,6 +25,7 @@ class SpyThreatListener { onScreenRecording: () => _log(Threat.screenRecording), onObfuscationIssues: () => _log(Threat.obfuscationIssues), onMultiInstance: () => _log(Threat.multiInstance), + onAutomation: () => _log(Threat.automation), ); static void _log(Threat threat) { @@ -73,6 +74,8 @@ class SpyThreatListener { callback.onTimeSpoofing?.call(); case Threat.locationSpoofing: callback.onLocationSpoofing?.call(); + case Threat.automation: + callback.onAutomation?.call(); } } }