From 047a5c6cf165e348249d1b2229a21ac2e9a53116 Mon Sep 17 00:00:00 2001 From: Muritz Date: Fri, 28 Feb 2025 16:23:08 +0100 Subject: [PATCH 1/3] add flag forceBiometricAuthentication --- CHANGELOG.md | 5 + .../BiometricStoragePlugin.kt | 14 +- example/lib/main.dart | 22 +- lib/src/biometric_storage.dart | 28 +- lib/src/biometric_storage_web.dart | 5 +- lib/src/biometric_storage_win32.dart | 5 +- macos/Classes/BiometricStorageImpl.swift | 603 +++++++++--------- 7 files changed, 368 insertions(+), 314 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6b90af..bf6aabbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 5.1.1-dev.2 + +* Add flag `forceBiometricAuthentication` for `BiometricStorage.read`to enforce biometric prompt in any case. Only for iOS and android. + + ## 5.1.1-dev.1 * Improve `canAuthenticate` to include `InitOptions` to decide for which authenticaiton type to check. diff --git a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt index 97771b31..79dd16a2 100644 --- a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt +++ b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt @@ -97,7 +97,7 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { const val PARAM_NAME = "name" const val PARAM_WRITE_CONTENT = "content" const val PARAM_ANDROID_PROMPT_INFO = "androidPromptInfo" - + const val PARAM_FORCE_BIOMETRIC_AUTHENTICATION = "forceBiometricAuthentication" } private val executor: ExecutorService by lazy { Executors.newSingleThreadExecutor() } @@ -167,7 +167,9 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { @UiThread fun BiometricStorageFile.withAuth( mode: CipherMode, - @WorkerThread cb: BiometricStorageFile.(cipher: Cipher?) -> Unit + forceBiometricAuthentication: Boolean = false, + @WorkerThread cb: BiometricStorageFile.(cipher: Cipher?) -> Unit, + ) { if (!options.authenticationRequired) { return cb(null) @@ -190,7 +192,8 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { cipherForMode() } - if (cipher == null) { + // if forceBiometricAuthentication is true, show prompt in any case. + if (cipher == null && !forceBiometricAuthentication) { // if we have no cipher, just try the callback and see if the // user requires authentication. try { @@ -249,7 +252,9 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { "read" -> withStorage { if (exists()) { - withAuth(CipherMode.Decrypt) { + withAuth(CipherMode.Decrypt, + forceBiometricAuthentication = requiredArgument(PARAM_FORCE_BIOMETRIC_AUTHENTICATION), + ) { val ret = readFile( it, ) @@ -269,6 +274,7 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { } "write" -> withStorage { + withAuth(CipherMode.Encrypt) { writeFile(it, requiredArgument(PARAM_WRITE_CONTENT)) ui(resultError) { result.success(true) } diff --git a/example/lib/main.dart b/example/lib/main.dart index a607995f..3ab09707 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -258,8 +258,7 @@ class StorageActions extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + return Wrap( children: [ ElevatedButton( child: const Text('read'), @@ -277,6 +276,25 @@ class StorageActions extends StatelessWidget { } }, ), + ElevatedButton( + child: const Text('read with force'), + onPressed: () async { + _logger.fine( + 'reading with forceBiometricAuthentication from ${storageFile.name}'); + try { + final result = await storageFile.read( + forceBiometricAuthentication: true, + ); + _logger.fine('read: {$result}'); + } on AuthException catch (e) { + if (e.code == AuthExceptionCode.userCanceled) { + _logger.info('User canceled.'); + return; + } + rethrow; + } + }, + ), ElevatedButton( child: const Text('write'), onPressed: () async { diff --git a/lib/src/biometric_storage.dart b/lib/src/biometric_storage.dart index d296c3b0..f9961468 100644 --- a/lib/src/biometric_storage.dart +++ b/lib/src/biometric_storage.dart @@ -272,11 +272,18 @@ abstract class BiometricStorage extends PlatformInterface { PromptInfo promptInfo = PromptInfo.defaultValues, }); + /// Reads the content of a secure storage file identified by [name]. + /// + /// Returns the stored string content or null. + /// If [forceBiometricAuthentication] is set to true, show biometric prompt + /// even auth validity duration is not expired yet. + /// Only works on ios and android. @protected Future read( String name, - PromptInfo promptInfo, - ); + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }); @protected Future delete( @@ -396,10 +403,12 @@ class MethodChannelBiometricStorage extends BiometricStorage { @override Future read( String name, - PromptInfo promptInfo, - ) => + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }) => _transformErrors(_channel.invokeMethod('read', { 'name': name, + 'forceBiometricAuthentication': forceBiometricAuthentication, ..._promptInfoForCurrentPlatform(promptInfo), })); @@ -491,8 +500,15 @@ class BiometricStorageFile { /// read from the secure file and returns the content. /// Will return `null` if file does not exist. - Future read({PromptInfo? promptInfo}) => - _plugin.read(name, promptInfo ?? defaultPromptInfo); + Future read({ + PromptInfo? promptInfo, + bool forceBiometricAuthentication = false, + }) => + _plugin.read( + name, + promptInfo ?? defaultPromptInfo, + forceBiometricAuthentication: forceBiometricAuthentication, + ); /// Write content of this file. Previous value will be overwritten. Future write(String content, {PromptInfo? promptInfo}) => diff --git a/lib/src/biometric_storage_web.dart b/lib/src/biometric_storage_web.dart index e4409c94..a1626d4a 100644 --- a/lib/src/biometric_storage_web.dart +++ b/lib/src/biometric_storage_web.dart @@ -46,8 +46,9 @@ class BiometricStoragePluginWeb extends BiometricStorage { @override Future read( String name, - PromptInfo promptInfo, - ) async { + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }) async { return web.window.localStorage.getItem(name); } diff --git a/lib/src/biometric_storage_win32.dart b/lib/src/biometric_storage_win32.dart index 27754c18..c19af617 100644 --- a/lib/src/biometric_storage_win32.dart +++ b/lib/src/biometric_storage_win32.dart @@ -65,8 +65,9 @@ class Win32BiometricStoragePlugin extends BiometricStorage { @override Future read( String name, - PromptInfo promptInfo, - ) async { + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }) async { _logger.finer('read($name)'); final credPointer = calloc>(); final namePointer = TEXT(name); diff --git a/macos/Classes/BiometricStorageImpl.swift b/macos/Classes/BiometricStorageImpl.swift index 586eebe1..97069a86 100644 --- a/macos/Classes/BiometricStorageImpl.swift +++ b/macos/Classes/BiometricStorageImpl.swift @@ -8,342 +8,349 @@ typealias StorageCallback = (Any?) -> Void typealias StorageError = (String, String?, Any?) -> Any struct StorageMethodCall { - let method: String - let arguments: Any? + let method: String + let arguments: Any? } class InitOptions { - init(params: [String: Any]) { - darwinTouchIDAuthenticationAllowableReuseDuration = params["darwinTouchIDAuthenticationAllowableReuseDurationSeconds"] as? Int - darwinTouchIDAuthenticationForceReuseContextDuration = params["darwinTouchIDAuthenticationForceReuseContextDurationSeconds"] as? Int - authenticationRequired = params["authenticationRequired"] as? Bool - darwinBiometricOnly = params["darwinBiometricOnly"] as? Bool - } - let darwinTouchIDAuthenticationAllowableReuseDuration: Int? - let darwinTouchIDAuthenticationForceReuseContextDuration: Int? - let authenticationRequired: Bool! - let darwinBiometricOnly: Bool! + init(params: [String: Any]) { + darwinTouchIDAuthenticationAllowableReuseDuration = params["darwinTouchIDAuthenticationAllowableReuseDurationSeconds"] as? Int + darwinTouchIDAuthenticationForceReuseContextDuration = params["darwinTouchIDAuthenticationForceReuseContextDurationSeconds"] as? Int + authenticationRequired = params["authenticationRequired"] as? Bool + darwinBiometricOnly = params["darwinBiometricOnly"] as? Bool + } + let darwinTouchIDAuthenticationAllowableReuseDuration: Int? + let darwinTouchIDAuthenticationForceReuseContextDuration: Int? + let authenticationRequired: Bool! + let darwinBiometricOnly: Bool! } class IOSPromptInfo { - init(params: [String: Any]) { - saveTitle = params["saveTitle"] as? String - accessTitle = params["accessTitle"] as? String - } - let saveTitle: String! - let accessTitle: String! + init(params: [String: Any]) { + saveTitle = params["saveTitle"] as? String + accessTitle = params["accessTitle"] as? String + } + let saveTitle: String! + let accessTitle: String! } private func hpdebug(_ message: String) { - print(message); + print(message); } class BiometricStorageImpl { - - init(storageError: @escaping StorageError, storageMethodNotImplemented: Any) { - self.storageError = storageError - self.storageMethodNotImplemented = storageMethodNotImplemented - } - - private var stores: [String: BiometricStorageFile] = [:] - private let storageError: StorageError - private let storageMethodNotImplemented: Any - - private func storageError(code: String, message: String?, details: Any?) -> Any { - return storageError(code, message, details) - } - - public func handle(_ call: StorageMethodCall, result: @escaping StorageCallback) { - func requiredArg(_ name: String, _ cb: (T) -> Void) { - guard let args = call.arguments as? Dictionary else { - result(storageError(code: "InvalidArguments", message: "Invalid arguments \(String(describing: call.arguments))", details: nil)) - return - } - guard let value = args[name] else { - result(storageError(code: "InvalidArguments", message: "Missing argument \(name)", details: nil)) - return - } - guard let valueTyped = value as? T else { - result(storageError(code: "InvalidArguments", message: "Invalid argument for \(name): expected \(T.self) got \(value)", details: nil)) - return - } - cb(valueTyped) - return + init(storageError: @escaping StorageError, storageMethodNotImplemented: Any) { + self.storageError = storageError + self.storageMethodNotImplemented = storageMethodNotImplemented } - func requireStorage(_ name: String, _ cb: (BiometricStorageFile) -> Void) { - guard let file = stores[name] else { - result(storageError(code: "InvalidArguments", message: "Storage was not initialized \(name)", details: nil)) - return - } - cb(file) + + private var stores: [String: BiometricStorageFile] = [:] + private let storageError: StorageError + private let storageMethodNotImplemented: Any + + private func storageError(code: String, message: String?, details: Any?) -> Any { + return storageError(code, message, details) } - if ("canAuthenticate" == call.method) { - canAuthenticate(result: result) - } else if ("init" == call.method) { - requiredArg("name") { name in - requiredArg("options") { options in - stores[name] = BiometricStorageFile(name: name, initOptions: InitOptions(params: options), storageError: storageError) - } - } - result(true) - } else if ("dispose" == call.method) { - // nothing to dispose - result(true) - } else if ("read" == call.method) { - requiredArg("name") { name in - requiredArg("iosPromptInfo") { promptInfo in - requireStorage(name) { file in - file.read(result, IOSPromptInfo(params: promptInfo)) - } + public func handle(_ call: StorageMethodCall, result: @escaping StorageCallback) { + + func requiredArg(_ name: String, _ cb: (T) -> Void) { + guard let args = call.arguments as? Dictionary else { + result(storageError(code: "InvalidArguments", message: "Invalid arguments \(String(describing: call.arguments))", details: nil)) + return + } + guard let value = args[name] else { + result(storageError(code: "InvalidArguments", message: "Missing argument \(name)", details: nil)) + return + } + guard let valueTyped = value as? T else { + result(storageError(code: "InvalidArguments", message: "Invalid argument for \(name): expected \(T.self) got \(value)", details: nil)) + return + } + cb(valueTyped) + return } - } - } else if ("write" == call.method) { - requiredArg("name") { name in - requiredArg("content") { content in - requiredArg("iosPromptInfo") { promptInfo in - requireStorage(name) { file in - file.write(content, result, IOSPromptInfo(params: promptInfo)) + func requireStorage(_ name: String, _ cb: (BiometricStorageFile) -> Void) { + guard let file = stores[name] else { + result(storageError(code: "InvalidArguments", message: "Storage was not initialized \(name)", details: nil)) + return } - } + cb(file) } - } - } else if ("delete" == call.method) { - requiredArg("name") { name in - requiredArg("iosPromptInfo") { promptInfo in - requireStorage(name) { file in - file.delete(result, IOSPromptInfo(params: promptInfo)) - } + + if ("canAuthenticate" == call.method) { + canAuthenticate(result: result) + } else if ("init" == call.method) { + requiredArg("name") { name in + requiredArg("options") { options in + stores[name] = BiometricStorageFile(name: name, initOptions: InitOptions(params: options), storageError: storageError) + } + } + result(true) + } else if ("dispose" == call.method) { + // nothing to dispose + result(true) + } else if ("read" == call.method) { + requiredArg("name") { name in + requiredArg("forceBiometricAuthentication") { forceBiometricAuthentication in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.read(result, IOSPromptInfo(params: promptInfo), forceBiometricAuthentication) + } + } + } + } + } else if ("write" == call.method) { + requiredArg("name") { name in + requiredArg("content") { content in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.write(content, result, IOSPromptInfo(params: promptInfo)) + } + } + } + } + } else if ("delete" == call.method) { + requiredArg("name") { name in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.delete(result, IOSPromptInfo(params: promptInfo)) + } + } + } + } else { + result(storageMethodNotImplemented) } - } - } else { - result(storageMethodNotImplemented) - } - } - - - private func canAuthenticate(result: @escaping StorageCallback) { - var error: NSError? - let context = LAContext() - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { - result("Success") - return } - guard let err = error else { - result("ErrorUnknown") - return - } - let laError = LAError(_nsError: err) - NSLog("LAError: \(laError)"); - switch laError.code { - case .touchIDNotAvailable: - result("ErrorHwUnavailable") - break; - case .passcodeNotSet: - result("ErrorPasscodeNotSet") - break; - case .touchIDNotEnrolled: - result("ErrorNoBiometricEnrolled") - break; - case .invalidContext: fallthrough - default: - result("ErrorUnknown") - break; + + + private func canAuthenticate(result: @escaping StorageCallback) { + var error: NSError? + let context = LAContext() + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + result("Success") + return + } + guard let err = error else { + result("ErrorUnknown") + return + } + let laError = LAError(_nsError: err) + NSLog("LAError: \(laError)"); + switch laError.code { + case .touchIDNotAvailable: + result("ErrorHwUnavailable") + break; + case .passcodeNotSet: + result("ErrorPasscodeNotSet") + break; + case .touchIDNotEnrolled: + result("ErrorNoBiometricEnrolled") + break; + case .invalidContext: fallthrough + default: + result("ErrorUnknown") + break; + } } - } } typealias StoredContext = (context: LAContext, expireAt: Date) class BiometricStorageFile { - private let name: String - private let initOptions: InitOptions - private var _context: StoredContext? - private var context: LAContext { - get { - if let context = _context { - if context.expireAt.timeIntervalSinceNow < 0 { - // already expired. - _context = nil - } else { - return context.context - } - } - - let context = LAContext() - if (initOptions.authenticationRequired) { - if let duration = initOptions.darwinTouchIDAuthenticationAllowableReuseDuration { - if #available(OSX 10.12, *) { - context.touchIDAuthenticationAllowableReuseDuration = Double(duration) - } else { - // Fallback on earlier versions - hpdebug("Pre OSX 10.12 no touchIDAuthenticationAllowableReuseDuration available. ignoring.") - } - } - - if let duration = initOptions.darwinTouchIDAuthenticationForceReuseContextDuration { - _context = (context: context, expireAt: Date(timeIntervalSinceNow: Double(duration))) + private let name: String + private let initOptions: InitOptions + private var _context: StoredContext? + private var context: LAContext { + get { + if let context = _context { + if context.expireAt.timeIntervalSinceNow < 0 { + // already expired. + _context = nil + } else { + return context.context + } + } + + let context = LAContext() + if (initOptions.authenticationRequired) { + if let duration = initOptions.darwinTouchIDAuthenticationAllowableReuseDuration { + if #available(OSX 10.12, *) { + context.touchIDAuthenticationAllowableReuseDuration = Double(duration) + } else { + // Fallback on earlier versions + hpdebug("Pre OSX 10.12 no touchIDAuthenticationAllowableReuseDuration available. ignoring.") + } + } + + if let duration = initOptions.darwinTouchIDAuthenticationForceReuseContextDuration { + _context = (context: context, expireAt: Date(timeIntervalSinceNow: Double(duration))) + } + } + return context } - } - return context } - } - private let storageError: StorageError - - init(name: String, initOptions: InitOptions, storageError: @escaping StorageError) { - self.name = name - self.initOptions = initOptions - self.storageError = storageError - } - - private func baseQuery(_ result: @escaping StorageCallback) -> [String: Any]? { - var query = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "flutter_biometric_storage", - kSecAttrAccount as String: name, - ] as [String : Any] - if initOptions.authenticationRequired { - guard let access = accessControl(result) else { - return nil - } - if #available(iOS 13.0, macOS 10.15, *) { - query[kSecUseDataProtectionKeychain as String] = true - } - query[kSecAttrAccessControl as String] = access + private let storageError: StorageError + + init(name: String, initOptions: InitOptions, storageError: @escaping StorageError) { + self.name = name + self.initOptions = initOptions + self.storageError = storageError } - return query - } - - private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? { - let accessControlFlags: SecAccessControlCreateFlags - if initOptions.darwinBiometricOnly { - if #available(iOS 11.3, *) { - accessControlFlags = .biometryCurrentSet - } else { - accessControlFlags = .touchIDCurrentSet - } - } else { - accessControlFlags = .userPresence + private func baseQuery(_ result: @escaping StorageCallback) -> [String: Any]? { + var query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "flutter_biometric_storage", + kSecAttrAccount as String: name, + ] as [String : Any] + if initOptions.authenticationRequired { + guard let access = accessControl(result) else { + return nil + } + if #available(iOS 13.0, macOS 10.15, *) { + query[kSecUseDataProtectionKeychain as String] = true + } + query[kSecAttrAccessControl as String] = access + } + return query } + + private func accessControl(_ result: @escaping StorageCallback) -> SecAccessControl? { + let accessControlFlags: SecAccessControlCreateFlags -// access = SecAccessControlCreateWithFlags(nil, -// kSecAttrAccessibleWhenUnlockedThisDeviceOnly, -// accessControlFlags, -// &error) - var error: Unmanaged? - guard let access = SecAccessControlCreateWithFlags( - nil, // Use the default allocator. - kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, - accessControlFlags, - &error) else { - hpdebug("Error while creating access control flags. \(String(describing: error))") - result(storageError("writing data", "error writing data", "\(String(describing: error))")); - return nil - } - - return access - } - - func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { - - guard var query = baseQuery(result) else { - return; + if initOptions.darwinBiometricOnly { + if #available(iOS 11.3, *) { + accessControlFlags = .biometryCurrentSet + } else { + accessControlFlags = .touchIDCurrentSet + } + } else { + accessControlFlags = .userPresence + } + + // access = SecAccessControlCreateWithFlags(nil, + // kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + // accessControlFlags, + // &error) + var error: Unmanaged? + guard let access = SecAccessControlCreateWithFlags( + nil, // Use the default allocator. + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + accessControlFlags, + &error) else { + hpdebug("Error while creating access control flags. \(String(describing: error))") + result(storageError("writing data", "error writing data", "\(String(describing: error))")); + return nil + } + + return access } - query[kSecMatchLimit as String] = kSecMatchLimitOne - query[kSecUseOperationPrompt as String] = promptInfo.accessTitle - query[kSecReturnAttributes as String] = true - query[kSecReturnData as String] = true - query[kSecUseAuthenticationContext as String] = context - var item: CFTypeRef? - - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status != errSecItemNotFound else { - result(nil) - return - } - guard status == errSecSuccess else { - handleOSStatusError(status, result, "Error retrieving item. \(status)") - return - } - guard let existingItem = item as? [String : Any], - let data = existingItem[kSecValueData as String] as? Data, - let dataString = String(data: data, encoding: String.Encoding.utf8) - else { - result(storageError("RetrieveError", "Unexpected data.", nil)) - return - } - result(dataString) - } - - func delete(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { - guard let query = baseQuery(result) else { - return; - } - // query[kSecMatchLimit as String] = kSecMatchLimitOne - // query[kSecReturnData as String] = true - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess { - result(true) - return - } - if status == errSecItemNotFound { - hpdebug("Item not in keychain. Nothing to delete.") - result(true) - return - } - handleOSStatusError(status, result, "writing data") - } - - func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { - guard var query = baseQuery(result) else { - return; - } - - if (initOptions.authenticationRequired) { - query.merge([ - kSecUseAuthenticationContext as String: context, - ]) { (_, new) in new } - if let operationPrompt = promptInfo.saveTitle { - query[kSecUseOperationPrompt as String] = operationPrompt - } - } else { - hpdebug("No authentication required for \(name)") - } - query.merge([ - // kSecMatchLimit as String: kSecMatchLimitOne, - kSecValueData as String: content.data(using: String.Encoding.utf8) as Any, - ]) { (_, new) in new } - var status = SecItemAdd(query as CFDictionary, nil) - if (status == errSecDuplicateItem) { - hpdebug("Value already exists. updating.") - let update = [kSecValueData as String: query[kSecValueData as String]] - query.removeValue(forKey: kSecValueData as String) - status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + func read(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo, _ forceBiometricAuthentication: Bool) { + if(forceBiometricAuthentication){ + // invalidate current LAContext to enforce recreation of LAContext in context getter. + _context = nil + } + + + guard var query = baseQuery(result) else { + return; + } + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecUseOperationPrompt as String] = promptInfo.accessTitle + query[kSecReturnAttributes as String] = true + query[kSecReturnData as String] = true + query[kSecUseAuthenticationContext as String] = context + + var item: CFTypeRef? + + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { + result(nil) + return + } + guard status == errSecSuccess else { + handleOSStatusError(status, result, "Error retrieving item. \(status)") + return + } + guard let existingItem = item as? [String : Any], + let data = existingItem[kSecValueData as String] as? Data, + let dataString = String(data: data, encoding: String.Encoding.utf8) + else { + result(storageError("RetrieveError", "Unexpected data.", nil)) + return + } + result(dataString) } - guard status == errSecSuccess else { - handleOSStatusError(status, result, "writing data") - return + + func delete(_ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + guard let query = baseQuery(result) else { + return; + } + // query[kSecMatchLimit as String] = kSecMatchLimitOne + // query[kSecReturnData as String] = true + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess { + result(true) + return + } + if status == errSecItemNotFound { + hpdebug("Item not in keychain. Nothing to delete.") + result(true) + return + } + handleOSStatusError(status, result, "writing data") } - result(nil) - } - - private func handleOSStatusError(_ status: OSStatus, _ result: @escaping StorageCallback, _ message: String) { - var errorMessage: String? = nil - if #available(iOS 11.3, OSX 10.12, *) { - errorMessage = SecCopyErrorMessageString(status, nil) as String? + + func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + guard var query = baseQuery(result) else { + return; + } + + if (initOptions.authenticationRequired) { + query.merge([ + kSecUseAuthenticationContext as String: context, + ]) { (_, new) in new } + if let operationPrompt = promptInfo.saveTitle { + query[kSecUseOperationPrompt as String] = operationPrompt + } + } else { + hpdebug("No authentication required for \(name)") + } + query.merge([ + // kSecMatchLimit as String: kSecMatchLimitOne, + kSecValueData as String: content.data(using: String.Encoding.utf8) as Any, + ]) { (_, new) in new } + var status = SecItemAdd(query as CFDictionary, nil) + if (status == errSecDuplicateItem) { + hpdebug("Value already exists. updating.") + let update = [kSecValueData as String: query[kSecValueData as String]] + query.removeValue(forKey: kSecValueData as String) + status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + } + guard status == errSecSuccess else { + handleOSStatusError(status, result, "writing data") + return + } + result(nil) } - let code: String - switch status { - case errSecUserCanceled: - code = "AuthError:UserCanceled" - default: - code = "SecurityError" + + private func handleOSStatusError(_ status: OSStatus, _ result: @escaping StorageCallback, _ message: String) { + var errorMessage: String? = nil + if #available(iOS 11.3, OSX 10.12, *) { + errorMessage = SecCopyErrorMessageString(status, nil) as String? + } + let code: String + switch status { + case errSecUserCanceled: + code = "AuthError:UserCanceled" + default: + code = "SecurityError" + } + + result(storageError(code, "Error while \(message): \(status): \(errorMessage ?? "Unknown")", nil)) } - result(storageError(code, "Error while \(message): \(status): \(errorMessage ?? "Unknown")", nil)) - } - } From 4567534ebee9bcca5502facc8bef05dbe0727b0b Mon Sep 17 00:00:00 2001 From: Muritz Date: Mon, 3 Mar 2025 10:20:46 +0100 Subject: [PATCH 2/3] add forceBiometricAuthentication on write --- .../BiometricStoragePlugin.kt | 5 +++-- example/lib/main.dart | 19 +++++++++++++++++++ lib/src/biometric_storage_web.dart | 5 +++-- lib/src/biometric_storage_win32.dart | 5 +++-- macos/Classes/BiometricStorageImpl.swift | 17 ++++++++++++----- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt index 79dd16a2..e64971b1 100644 --- a/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt +++ b/android/src/main/kotlin/design/codeux/biometric_storage/BiometricStoragePlugin.kt @@ -274,8 +274,9 @@ class BiometricStoragePlugin : FlutterPlugin, ActivityAware, MethodCallHandler { } "write" -> withStorage { - - withAuth(CipherMode.Encrypt) { + withAuth(CipherMode.Encrypt, + forceBiometricAuthentication = requiredArgument(PARAM_FORCE_BIOMETRIC_AUTHENTICATION), + ) { writeFile(it, requiredArgument(PARAM_WRITE_CONTENT)) ui(resultError) { result.success(true) } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 3ab09707..02237255 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -312,6 +312,25 @@ class StorageActions extends StatelessWidget { } }, ), + ElevatedButton( + child: const Text('write with forceBiometricAuthentication'), + onPressed: () async { + _logger.fine('Going to write with force...'); + try { + await storageFile.write( + ' [${DateTime.now()}] ${writeController.text}', + forceBiometricAuthentication: true, + ); + _logger.info('Written content.'); + } on AuthException catch (e) { + if (e.code == AuthExceptionCode.userCanceled) { + _logger.info('User canceled.'); + return; + } + rethrow; + } + }, + ), ElevatedButton( child: const Text('delete'), onPressed: () async { diff --git a/lib/src/biometric_storage_web.dart b/lib/src/biometric_storage_web.dart index a1626d4a..6c6611f0 100644 --- a/lib/src/biometric_storage_web.dart +++ b/lib/src/biometric_storage_web.dart @@ -56,8 +56,9 @@ class BiometricStoragePluginWeb extends BiometricStorage { Future write( String name, String content, - PromptInfo promptInfo, - ) async { + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }) async { web.window.localStorage.setItem(name, content); } } diff --git a/lib/src/biometric_storage_win32.dart b/lib/src/biometric_storage_win32.dart index c19af617..d1546a74 100644 --- a/lib/src/biometric_storage_win32.dart +++ b/lib/src/biometric_storage_win32.dart @@ -103,8 +103,9 @@ class Win32BiometricStoragePlugin extends BiometricStorage { Future write( String name, String content, - PromptInfo promptInfo, - ) async { + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }) async { _logger.fine('write()'); final examplePassword = utf8.encode(content); final blob = examplePassword.allocatePointer(); diff --git a/macos/Classes/BiometricStorageImpl.swift b/macos/Classes/BiometricStorageImpl.swift index 97069a86..ec1de3fc 100644 --- a/macos/Classes/BiometricStorageImpl.swift +++ b/macos/Classes/BiometricStorageImpl.swift @@ -103,10 +103,12 @@ class BiometricStorageImpl { } } else if ("write" == call.method) { requiredArg("name") { name in - requiredArg("content") { content in - requiredArg("iosPromptInfo") { promptInfo in - requireStorage(name) { file in - file.write(content, result, IOSPromptInfo(params: promptInfo)) + requiredArg("content") { + content in requiredArg("forceBiometricAuthentication") { forceBiometricAuthentication in + requiredArg("iosPromptInfo") { promptInfo in + requireStorage(name) { file in + file.write(content, result, IOSPromptInfo(params: promptInfo), forceBiometricAuthentication) + } } } } @@ -304,7 +306,12 @@ class BiometricStorageFile { handleOSStatusError(status, result, "writing data") } - func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo) { + func write(_ content: String, _ result: @escaping StorageCallback, _ promptInfo: IOSPromptInfo, _ forceBiometricAuthentication: Bool) { + if(forceBiometricAuthentication){ + // invalidate current LAContext to enforce recreation of LAContext in context getter. + _context = nil + } + guard var query = baseQuery(result) else { return; } From 8aa234334baaec28300c5476b8c4b17c89f60b8c Mon Sep 17 00:00:00 2001 From: Muritz Date: Mon, 3 Mar 2025 10:57:52 +0100 Subject: [PATCH 3/3] add missing file --- lib/src/biometric_storage.dart | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/src/biometric_storage.dart b/lib/src/biometric_storage.dart index f9961468..26e87732 100644 --- a/lib/src/biometric_storage.dart +++ b/lib/src/biometric_storage.dart @@ -291,12 +291,18 @@ abstract class BiometricStorage extends PlatformInterface { PromptInfo promptInfo, ); + /// Write [content] for [name]. + /// + /// If [forceBiometricAuthentication] is set to true, show biometric prompt + /// even auth validity duration is not expired yet. + /// Only works on ios and android. @protected Future write( String name, String content, - PromptInfo promptInfo, - ); + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }); } class MethodChannelBiometricStorage extends BiometricStorage { @@ -426,11 +432,13 @@ class MethodChannelBiometricStorage extends BiometricStorage { Future write( String name, String content, - PromptInfo promptInfo, - ) => + PromptInfo promptInfo, { + bool forceBiometricAuthentication = false, + }) => _transformErrors(_channel.invokeMethod('write', { 'name': name, 'content': content, + 'forceBiometricAuthentication': forceBiometricAuthentication, ..._promptInfoForCurrentPlatform(promptInfo), })); @@ -500,6 +508,8 @@ class BiometricStorageFile { /// read from the secure file and returns the content. /// Will return `null` if file does not exist. + /// + /// If [forceBiometricAuthentication] is true, show biometric prompt in any case. Future read({ PromptInfo? promptInfo, bool forceBiometricAuthentication = false, @@ -511,8 +521,19 @@ class BiometricStorageFile { ); /// Write content of this file. Previous value will be overwritten. - Future write(String content, {PromptInfo? promptInfo}) => - _plugin.write(name, content, promptInfo ?? defaultPromptInfo); + /// + /// If [forceBiometricAuthentication] is true, show biometric prompt in any case. + Future write( + String content, { + PromptInfo? promptInfo, + bool forceBiometricAuthentication = false, + }) => + _plugin.write( + name, + content, + promptInfo ?? defaultPromptInfo, + forceBiometricAuthentication: forceBiometricAuthentication, + ); /// Delete the content of this storage. Future delete({PromptInfo? promptInfo}) =>