diff --git a/.gitignore b/.gitignore index 5d21bbc..63c5750 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,8 @@ build/ example/pubspec.lock # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +# SPM +.build/ +.swiftpm/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 177621c..e67737a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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). +## [8.0.1] - 2026-06-26 + +### Added +- Swift Package Manager (SPM) support for iOS. + ## [8.0.0] - 2026-05-13 - Android SDK version: 18.3.0 diff --git a/ios/freerasp.podspec b/ios/freerasp.podspec index d6971b9..c1a1e40 100644 --- a/ios/freerasp.podspec +++ b/ios/freerasp.podspec @@ -15,7 +15,7 @@ FreeRASP for iOS is a lightweight and easy-to-use mobile app protection and secu s.source = { :path => '.' } s.source_files = 'Classes/**/*', 'TalsecRuntime.xcframework' s.dependency 'Flutter' - s.platform = :ios, '8.0' + s.platform = :ios, '12.0' s.preserve_paths = 'TalsecRuntime.xcframework' s.xcconfig = { 'OTHER_LDFLAGS' => '-framework TalsecRuntime' } diff --git a/ios/freerasp/Package.swift b/ios/freerasp/Package.swift new file mode 100644 index 0000000..292ac71 --- /dev/null +++ b/ios/freerasp/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "freerasp", + platforms: [ + .iOS("12.0") + ], + products: [ + .library(name: "freerasp", targets: ["freerasp"]) + ], + dependencies: [], + targets: [ + .binaryTarget( + name: "TalsecRuntime", + path: "TalsecRuntime.xcframework" + ), + .target( + name: "freerasp", + dependencies: ["TalsecRuntime"], + path: "Sources/freerasp" + ) + ] +) diff --git a/ios/freerasp/Sources/freerasp/Dispatchers/ExecutionStateDispatcher.swift b/ios/freerasp/Sources/freerasp/Dispatchers/ExecutionStateDispatcher.swift new file mode 100644 index 0000000..28e36ca --- /dev/null +++ b/ios/freerasp/Sources/freerasp/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/freerasp/Sources/freerasp/Dispatchers/ThreatDispatcher.swift b/ios/freerasp/Sources/freerasp/Dispatchers/ThreatDispatcher.swift new file mode 100644 index 0000000..f415534 --- /dev/null +++ b/ios/freerasp/Sources/freerasp/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/freerasp/Sources/freerasp/FlutterTalsecConfig.swift b/ios/freerasp/Sources/freerasp/FlutterTalsecConfig.swift new file mode 100644 index 0000000..6d261b0 --- /dev/null +++ b/ios/freerasp/Sources/freerasp/FlutterTalsecConfig.swift @@ -0,0 +1,17 @@ +import TalsecRuntime + +/// Model classes which represents data received from Flutter +struct FlutterTalsecConfig : Decodable { + let watcherMail: String + let iosConfig: IOSConfig + let isProd: Bool + + func toNativeConfig() -> TalsecConfig { + return TalsecConfig(appBundleIds: iosConfig.bundleIds, appTeamId: iosConfig.teamId, watcherMailAddress: watcherMail, isProd: isProd) + } +} + +struct IOSConfig : Decodable { + let bundleIds: [String] + let teamId: String +} diff --git a/ios/freerasp/Sources/freerasp/Models/RaspExecutionStates.swift b/ios/freerasp/Sources/freerasp/Models/RaspExecutionStates.swift new file mode 100644 index 0000000..4ea3577 --- /dev/null +++ b/ios/freerasp/Sources/freerasp/Models/RaspExecutionStates.swift @@ -0,0 +1,3 @@ +enum RaspExecutionStates: Int { + case allChecksFinished = 187429 +} diff --git a/ios/freerasp/Sources/freerasp/Processors/ExecutionStreamHandler.swift b/ios/freerasp/Sources/freerasp/Processors/ExecutionStreamHandler.swift new file mode 100644 index 0000000..7d801a0 --- /dev/null +++ b/ios/freerasp/Sources/freerasp/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/freerasp/Sources/freerasp/SwiftFreeraspPlugin.swift b/ios/freerasp/Sources/freerasp/SwiftFreeraspPlugin.swift new file mode 100644 index 0000000..f7414ff --- /dev/null +++ b/ios/freerasp/Sources/freerasp/SwiftFreeraspPlugin.swift @@ -0,0 +1,169 @@ +import Flutter +import UIKit +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 singleton instance of `SwiftTalsecPlugin`. + static let instance = SwiftFreeraspPlugin() + + private override init() {} + + /// Registers this plugin with the given `FlutterPluginRegistrar`. + public static func register(with registrar: FlutterPluginRegistrar) { + let messenger = registrar.messenger() + 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) + } + + /// Handles a method call from Flutter. + /// + /// - Parameters: + /// - call: The `FlutterMethodCall` object representing the method call. + /// - result: The `FlutterResult` object to be returned to the caller. + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary ?? [:] + + switch call.method { + case "start": + start(configJson: args["config"] as? String, result: result) + return + case "blockScreenCapture": + blockScreenCapture(enable: args["enable"] as? Bool, result: result) + return + case "isScreenCaptureBlocked": + isScreenCaptureBlocked(result: result) + return + case "storeExternalId": + storeExternalId(data: args["data"] as? String, result: result) + return + case "removeExternalId": + removeExternalId(result: result) + return + default: + result(FlutterMethodNotImplemented) + } + } + + /// Runs Talsec with given configuration + /// + /// - Parameters: + /// - args: The arguments received from Flutter which contains configuration + /// - result: The `FlutterResult` object to be returned to the caller. + private func start(configJson: String?, result: @escaping FlutterResult) { + guard let data = configJson?.data(using: .utf8), + let flutterConfig = try? JSONDecoder().decode(FlutterTalsecConfig.self, from: data) + else { + result(FlutterError(code: "configuration-exception", message: "Unable to decode configuration", details: nil)) + return + } + + Talsec.start(config: flutterConfig.toNativeConfig()) + + // Flutter expects *some* result to be returned even if it's void + result(nil) + } + + /// Blocks screen capture for the current UIWindow. + /// + /// - Parameters: + /// - enable: Whether screen capture should be enabled / disabled. + /// - result: The `FlutterResult` object to be returned to the caller. + private func blockScreenCapture(enable: Bool?, result: @escaping FlutterResult){ + guard let enableSafe = enable else { + result(FlutterError(code: "block-screen-capture-failure", message: "Couldn't process data.", details: nil)) + return + } + + getProtectedWindow { window in + if let window = window { + Talsec.blockScreenCapture(enable: enableSafe, window: window) + result(nil) + } else { + result(FlutterError(code: "block-screen-capture-failure", message: "No windows found to block screen capture", details: nil)) + } + } + } + + /// Determines whether screen capture is blocked for the current UIWindow. + /// + /// - Parameters: + /// - nonce: The nonce to be used in the cryptogram calculation. + /// - result: The `FlutterResult` object to be returned to the caller. + private func isScreenCaptureBlocked(result: @escaping FlutterResult){ + getProtectedWindow { window in + if let window = window { + let isBlocked = Talsec.isScreenCaptureBlocked(in: window) + result(isBlocked) + } else { + result(FlutterError(code: "is-screen-capture-blocked-failure", message: "Error while checking if screen capture is blocked", details: nil)) + } + } + } + + private func getProtectedWindow(completion: @escaping (UIWindow?) -> Void) { + DispatchQueue.main.async { + if #available(iOS 13.0, *) { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + if let window = windowScene.windows.first { + completion(window) + } else { + completion(nil) + } + } else { + completion(nil) + } + } + } + } + + /// Stores the external ID in user defaults. + /// + /// - Parameters: + /// - data: The data to be stored. + /// - result: The `FlutterResult` object to be returned to the caller. + private func storeExternalId(data: String?, result: @escaping FlutterResult){ + 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 ThreatDispatcher and processes any detectedThreats in the queue. + /// + /// - Parameters: + /// - arguments: Unused + /// - events: The FlutterEventSink to be attached to the ThreatDispatcher. + /// - Returns: Always returns nil. + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + ThreatDispatcher.shared.listener = { threat in + events(threat.callbackIdentifier) + } + return nil + } + + // Detaches the current FlutterEventSink from the ThreatDispatcher. + /// + /// - Parameters: + /// - arguments: Unused + /// - Returns: Always returns nil. + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + ThreatDispatcher.shared.listener = nil + return nil + } +} diff --git a/ios/freerasp/Sources/freerasp/TalsecHandlers.swift b/ios/freerasp/Sources/freerasp/TalsecHandlers.swift new file mode 100644 index 0000000..51e2a68 --- /dev/null +++ b/ios/freerasp/Sources/freerasp/TalsecHandlers.swift @@ -0,0 +1,72 @@ +import TalsecRuntime + +private let unknownValue = -1 +private let signatureValue = 1115787534 +private let jailbreakValue = 44506749 +private let debuggerValue = 1268968002 +private let runtimeManipulationValue = 209533833 +private let passcodeValue = 1293399086 +private let simulatorValue = 477190884 +private let missingSecureEnclaveValue = 1564314755 +private let deviceChangeValue = 1806586319 +private let deviceIDValue = 1514211414 +private let unofficialStoreValue = 629780916 +private let systemVPNValue = 659382561 +private let screenshotValue = 705651459 +private let screenRecordingValue = 64690214 +private let timeSpoofingValue = 189105221 + +/// Extension with submits events to plugin +extension SecurityThreatCenter: SecurityThreatHandler, TalsecRuntime.RaspExecutionState { + + public func threatDetected(_ securityThreat: TalsecRuntime.SecurityThreat) { + if securityThreat == .passcodeChange { + return + } + ThreatDispatcher.shared.dispatch(threat: securityThreat) + } + + public func onAllChecksFinished() { + ExecutionStateDispatcher.shared.dispatch(event: .allChecksFinished) + } +} + +/// An extension to unify callback names with Flutter ones. +extension SecurityThreat { + var callbackIdentifier: Int { + switch self { + case .signature: + return signatureValue + case .jailbreak: + return jailbreakValue + case .debugger: + return debuggerValue + case .runtimeManipulation: + return runtimeManipulationValue + case .passcode: + return passcodeValue + case .passcodeChange: + return unknownValue + case .simulator: + return simulatorValue + case .missingSecureEnclave: + return missingSecureEnclaveValue + case .deviceChange: + return deviceChangeValue + case .deviceID: + return deviceIDValue + case .unofficialStore: + return unofficialStoreValue + case .systemVPN: + return systemVPNValue + case .screenshot: + return screenshotValue + case .screenRecording: + return screenRecordingValue + case .timeSpoofing: + return timeSpoofingValue + @unknown default: + return unknownValue + } + } +} diff --git a/ios/freerasp/TalsecRuntime.xcframework b/ios/freerasp/TalsecRuntime.xcframework new file mode 120000 index 0000000..94282a0 --- /dev/null +++ b/ios/freerasp/TalsecRuntime.xcframework @@ -0,0 +1 @@ +../TalsecRuntime.xcframework \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 3c119cb..e8509f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,4 +39,4 @@ flutter: package: com.aheaditec.freerasp pluginClass: FreeraspPlugin ios: - pluginClass: FreeraspPlugin + pluginClass: SwiftFreeraspPlugin