diff --git a/README.md b/README.md index 24df6c45..d056d30a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,35 @@ android:allowBackup="false" ### macOS & iOS +#### Secure Enclave (iOS/macOS) + +You can opt-in to hardware-backed protection using the Secure Enclave by enabling `useSecureEnclave` in `AppleOptions` (iOS/macOS). When enabled, values are encrypted with a per-item AES key that is wrapped by an Enclave-backed private key. Access control prompts (Face ID/Touch ID/passcode) are enforced according to your `accessControlFlags`. + +Example: + +```dart +final storage = FlutterSecureStorage(); + +await storage.write( + key: 'token', + value: 'secret', + iOptions: IOSOptions( + useSecureEnclave: true, + accessControlFlags: const [ + AccessControlFlag.userPresence, // require Face ID/Touch ID or passcode + ], + ), + mOptions: MacOsOptions( + useSecureEnclave: true, + accessControlFlags: const [AccessControlFlag.userPresence], + ), +); +``` + +Notes: +- If Secure Enclave is unavailable (simulator or devices without Enclave), the plugin gracefully falls back to storing the value using standard Keychain with your configured access control flags. +- `synchronizable` is ignored for Enclave-backed flows (items are device-bound). +- On macOS, `kSecUseDataProtectionKeychain` remains enabled when available. You also need to add Keychain Sharing as capability to your macOS runner. To achieve this, please add the following in *both* your `macos/Runner/DebugProfile.entitlements` *and* `macos/Runner/Release.entitlements` for macOS or for iOS `ios/Runner/DebugProfile.entitlements` *and* `ios/Runner/Release.entitlements`. diff --git a/flutter_secure_storage/example/integration_test/app_test.dart b/flutter_secure_storage/example/integration_test/app_test.dart index bc98ff68..94414be2 100644 --- a/flutter_secure_storage/example/integration_test/app_test.dart +++ b/flutter_secure_storage/example/integration_test/app_test.dart @@ -1,4 +1,7 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage_example/main.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -75,6 +78,127 @@ void main() { ..verifyRowDoesNotExist(0) ..verifyRowDoesNotExist(1); }); + + testWidgets('Enclave requested on iOS Simulator falls back gracefully', + skip: !(Platform.isIOS && + Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')), + (WidgetTester tester) async { + const storage = FlutterSecureStorage(); + const key = 'it_enclave_sim_fallback_key'; + const value = 'sim_fallback_secret'; + + // Write with enclave requested + // ignore: undefined_named_parameter + await storage.write( + key: key, + value: value, + iOptions: const IOSOptions(useSecureEnclave: true), + ); + + // Read should succeed due to fallback + // ignore: undefined_named_parameter + final readBack = await storage.read( + key: key, + iOptions: const IOSOptions(useSecureEnclave: true), + ); + expect(readBack, value); + + // Delete should also succeed + // ignore: undefined_named_parameter + await storage.delete( + key: key, + iOptions: const IOSOptions(useSecureEnclave: true), + ); + final afterDelete = await storage.read( + key: key, + iOptions: const IOSOptions(useSecureEnclave: true), + ); + expect(afterDelete, isNull); + }); + + testWidgets( + 'iOS device: baseline (useSecureEnclave=false) write/read/delete', + (WidgetTester tester) async { + if (!(Platform.isIOS && + !Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'))) { + return; // Skip when not running on a physical iOS device + } + + const storage = FlutterSecureStorage(); + const key = 'it_enclave_device_baseline_key'; + const value = 'device_baseline_secret'; + + await storage.write( + key: key, + value: value, + iOptions: IOSOptions.defaultOptions, + ); + + final readBack = await storage.read( + key: key, + iOptions: IOSOptions.defaultOptions, + ); + expect(readBack, value); + + await storage.delete( + key: key, + iOptions: IOSOptions.defaultOptions, + ); + final afterDelete = await storage.read( + key: key, + iOptions: IOSOptions.defaultOptions, + ); + expect(afterDelete, isNull); + }); + + testWidgets( + 'iOS device: useSecureEnclave=true with non-prompting access control (applicationPassword) write/read/delete', + (WidgetTester tester) async { + if (!(Platform.isIOS && + !Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'))) { + return; // Skip when not running on a physical iOS device + } + + const storage = FlutterSecureStorage(); + const key = 'it_enclave_device_enabled_key'; + const value = 'device_enclave_secret'; + + await storage.write( + key: key, + value: value, + // Use a non-prompting flag to make test automation stable. + // ignore: undefined_named_parameter + iOptions: const IOSOptions( + useSecureEnclave: true, + accessControlFlags: [AccessControlFlag.applicationPassword], + ), + ); + + final readBack = await storage.read( + key: key, + iOptions: const IOSOptions( + useSecureEnclave: true, + accessControlFlags: [AccessControlFlag.applicationPassword], + ), + ); + expect(readBack, value); + + await storage.delete( + key: key, + iOptions: const IOSOptions( + useSecureEnclave: true, + accessControlFlags: [AccessControlFlag.applicationPassword], + ), + ); + final afterDelete = await storage.read( + key: key, + iOptions: const IOSOptions( + useSecureEnclave: true, + accessControlFlags: [AccessControlFlag.applicationPassword], + ), + ); + expect(afterDelete, isNull); + }); }); } diff --git a/flutter_secure_storage/lib/options/apple_options.dart b/flutter_secure_storage/lib/options/apple_options.dart index 05cabcf0..389c6fb1 100644 --- a/flutter_secure_storage/lib/options/apple_options.dart +++ b/flutter_secure_storage/lib/options/apple_options.dart @@ -82,6 +82,7 @@ abstract class AppleOptions extends Options { this.shouldReturnPersistentReference, this.authenticationUIBehavior, this.accessControlFlags = const [], + this.useSecureEnclave = false, }); /// The default account name associated with the keychain items. @@ -186,6 +187,19 @@ abstract class AppleOptions extends Options { /// final List accessControlFlags; + /// When true, opts into Secure Enclave–backed protection on iOS/macOS. + /// + /// Behavior: + /// - Data is encrypted with a per-item AES key that is wrapped by an + /// Enclave-backed private key. Access is gated by [accessControlFlags] + /// (e.g. Face ID/Touch ID/passcode via `userPresence`). + /// - If the device or simulator does not support Secure Enclave or unwrap + /// fails, the plugin gracefully falls back to standard Keychain storage + /// using your configured [accessControlFlags]. + /// - iCloud Keychain sync (synchronizable) is ignored when using Enclave + /// since keys are device-bound. + final bool useSecureEnclave; + @override Map toMap() => { if (accountName != null) 'accountName': accountName!, @@ -209,5 +223,6 @@ abstract class AppleOptions extends Options { if (accessControlFlags.isNotEmpty) 'accessControlFlags': accessControlFlags.map((e) => e.name).toList().toString(), + 'useSecureEnclave': '$useSecureEnclave', }; } diff --git a/flutter_secure_storage/lib/options/ios_options.dart b/flutter_secure_storage/lib/options/ios_options.dart index 61a10ea7..b1a7fb13 100644 --- a/flutter_secure_storage/lib/options/ios_options.dart +++ b/flutter_secure_storage/lib/options/ios_options.dart @@ -22,6 +22,7 @@ class IOSOptions extends AppleOptions { super.shouldReturnPersistentReference, super.authenticationUIBehavior, super.accessControlFlags, + super.useSecureEnclave, }); /// A predefined `IosOptions` instance with default settings. diff --git a/flutter_secure_storage/lib/options/macos_options.dart b/flutter_secure_storage/lib/options/macos_options.dart index b855504c..65622294 100644 --- a/flutter_secure_storage/lib/options/macos_options.dart +++ b/flutter_secure_storage/lib/options/macos_options.dart @@ -22,6 +22,7 @@ class MacOsOptions extends AppleOptions { super.authenticationUIBehavior, super.accessControlFlags, this.usesDataProtectionKeychain = true, + super.useSecureEnclave = false, }); /// `kSecUseDataProtectionKeychain` (macOS only): **Shared**. diff --git a/flutter_secure_storage/test/flutter_secure_storage_test.dart b/flutter_secure_storage/test/flutter_secure_storage_test.dart index b2488a1b..cb73b75c 100644 --- a/flutter_secure_storage/test/flutter_secure_storage_test.dart +++ b/flutter_secure_storage/test/flutter_secure_storage_test.dart @@ -196,6 +196,11 @@ void main() { ).called(1); }); + test('IOSOptions.toMap includes useSecureEnclave flag when enabled', () { + const options = IOSOptions(useSecureEnclave: true); + expect(options.toMap()['useSecureEnclave'], 'true'); + }); + test('read should return correct value', () async { when( () => mockPlatform.read( @@ -557,6 +562,7 @@ void main() { 'accountName': 'flutter_secure_storage_service', 'accessibility': 'unlocked', 'synchronizable': 'false', + 'useSecureEnclave': 'false', }); }); @@ -596,6 +602,7 @@ void main() { 'authenticationUIBehavior': 'require_auth', 'accessControlFlags': [AccessControlFlag.biometryCurrentSet.name].toString(), + 'useSecureEnclave': 'false', }); }); @@ -619,6 +626,7 @@ void main() { 'accountName': 'flutter_secure_storage_service', 'accessibility': 'unlocked', 'synchronizable': 'false', + 'useSecureEnclave': 'false', 'usesDataProtectionKeychain': 'true', }); }); @@ -637,6 +645,7 @@ void main() { 'groupId': 'group.mac.example', 'accessibility': 'first_unlock', 'synchronizable': 'true', + 'useSecureEnclave': 'false', 'usesDataProtectionKeychain': 'false', }); }); diff --git a/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorage.swift b/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorage.swift index 3b18e937..a2eb3d7b 100644 --- a/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorage.swift +++ b/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorage.swift @@ -6,6 +6,8 @@ // import Foundation +import Security +import CryptoKit /// Represents the parameters for keychain queries. struct KeychainQueryParameters { @@ -62,6 +64,9 @@ struct KeychainQueryParameters { /// `accessControlFlags` (iOS/macOS): Specifies access control settings (e.g., biometrics, passcode). var accessControlFlags: String? + + /// `useSecureEnclave` (iOS/macOS): Indicates whether the Secure Enclave for cryptographic key operations when available is used. + var useSecureEnclave: Bool? } /// Represents the response from a keychain operation. @@ -190,13 +195,21 @@ class FlutterSecureStorage { query[kSecUseAuthenticationUI] = authenticationUIBehavior } - if let accessControl = createAccessControl(params: params) { + // If Secure Enclave style gating requested but no flags provided, + // default to requiring user presence (biometry or passcode). + var effectiveParams = params + if (params.useSecureEnclave ?? false) && (params.accessControlFlags == nil || params.accessControlFlags?.isEmpty == true) { + effectiveParams.accessControlFlags = "userPresence" + } + + if let accessControl = createAccessControl(params: effectiveParams) { query[kSecAttrAccessControl] = accessControl } else { - if let accessibilityLevel = params.accessibilityLevel { + if let accessibilityLevel = effectiveParams.accessibilityLevel { query[kSecAttrAccessible] = parseAccessibleAttr(accessibilityLevel) } - if let isSynchronizable = params.isSynchronizable { + // Avoid synchronizable when device-bound enforcement is desired. + if let isSynchronizable = effectiveParams.isSynchronizable, !(effectiveParams.useSecureEnclave ?? false) { query[kSecAttrSynchronizable] = isSynchronizable } } @@ -215,6 +228,95 @@ class FlutterSecureStorage { return query } + + // MARK: - Secure Enclave Helpers + + /// Constructs a stable tag for the Secure Enclave private key for a given service. + private func enclaveKeyTag(for service: String?) -> Data { + let serviceLabel = service ?? "flutter_secure_storage_service" + return ("fss.enclave." + serviceLabel).data(using: .utf8)! + } + + /// Ensures a Secure Enclave EC private key exists for the provided service, creating it if needed. + @available(iOS 11.3, macOS 10.15, *) + private func ensureEnclavePrivateKey(service: String?, accessControl: SecAccessControl?) throws -> SecKey { + let tag = enclaveKeyTag(for: service) as CFData + + let query: [CFString: Any] = [ + kSecClass: kSecClassKey, + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrApplicationTag: tag, + kSecReturnRef: true + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let item = item { + return (item as! SecKey) + } + + var attributes: [CFString: Any] = [ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits: 256, + kSecAttrTokenID: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs: [ + kSecAttrIsPermanent: true, + kSecAttrApplicationTag: tag + ] + ] + if let ac = accessControl { + var privateAttrs = attributes[kSecPrivateKeyAttrs] as! [CFString: Any] + privateAttrs[kSecAttrAccessControl] = ac + attributes[kSecPrivateKeyAttrs] = privateAttrs + } + + var error: Unmanaged? + guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw OSSecError(status: errSecParam, message: error?.takeRetainedValue().localizedDescription) + } + return privateKey + } + + /// Wraps a symmetric key using ECIES with the provided public key. + @available(iOS 11.3, macOS 10.15, *) + private func wrapSymmetricKey(_ keyData: Data, using publicKey: SecKey) throws -> Data { + let algorithm = SecKeyAlgorithm.eciesEncryptionCofactorX963SHA256AESGCM + guard SecKeyIsAlgorithmSupported(publicKey, .encrypt, algorithm) else { + throw OSSecError(status: errSecUnimplemented, message: "ECIES not supported for encryption") + } + var error: Unmanaged? + guard let encrypted = SecKeyCreateEncryptedData(publicKey, algorithm, keyData as CFData, &error) as Data? else { + throw OSSecError(status: errSecParam, message: error?.takeRetainedValue().localizedDescription) + } + return encrypted + } + + /// Unwraps a symmetric key using ECIES with the provided private key. + @available(iOS 11.3, macOS 10.15, *) + private func unwrapSymmetricKey(_ wrappedData: Data, using privateKey: SecKey) throws -> Data { + let algorithm = SecKeyAlgorithm.eciesEncryptionCofactorX963SHA256AESGCM + guard SecKeyIsAlgorithmSupported(privateKey, .decrypt, algorithm) else { + throw OSSecError(status: errSecUnimplemented, message: "ECIES not supported for decryption") + } + var error: Unmanaged? + guard let decrypted = SecKeyCreateDecryptedData(privateKey, algorithm, wrappedData as CFData, &error) as Data? else { + throw OSSecError(status: errSecAuthFailed, message: error?.takeRetainedValue().localizedDescription) + } + return decrypted + } + + /// Composes the companion key name used to store the wrapped AES key for a data item key. + private func wrappedKeyName(for account: String) -> String { "fss.wrapped." + account } + + /// Builds a keychain query for the wrapped AES key item. + private func wrappedKeyQuery(from params: KeychainQueryParameters, account: String, returnData: Bool) -> [CFString: Any] { + var baseParams = params + baseParams.shouldReturnData = returnData + baseParams.isSynchronizable = false + baseParams.accessControlFlags = params.accessControlFlags // prompts apply on unwrap + var query = baseQuery(from: baseParams) + query[kSecAttrAccount] = wrappedKeyName(for: account) + return query + } private func validateQueryParameters(params: KeychainQueryParameters) throws { // Match limit @@ -298,53 +400,218 @@ class FlutterSecureStorage { /// Reads a single item from the keychain. internal func read(params: KeychainQueryParameters) -> FlutterSecureStorageResponse { - let query = baseQuery(from: params) - var ref: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &ref) - - // Return nil if nothing is found - if (status == errSecItemNotFound) { + // If Secure Enclave flow is not requested, do the standard lookup + if !(params.useSecureEnclave ?? false) { + let query = baseQuery(from: params) + var ref: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &ref) + + if (status == errSecItemNotFound) { + return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) + } + guard status == errSecSuccess, let data = ref as? Data else { + return FlutterSecureStorageResponse(status: status, value: nil) + } + let value = String(data: data, encoding: .utf8) + return FlutterSecureStorageResponse(status: status, value: value) + } + + // Secure Enclave path: fetch wrapped AES key, unwrap, then decrypt payload + guard let account = params.key else { + return FlutterSecureStorageResponse(status: errSecParam, value: nil) + } + var keyQuery = wrappedKeyQuery(from: params, account: account, returnData: true) + var keyRef: AnyObject? + var keyStatus = SecItemCopyMatching(keyQuery as CFDictionary, &keyRef) + if keyStatus == errSecItemNotFound { + // No wrapped key or value return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) } + guard keyStatus == errSecSuccess, let wrappedKeyData = keyRef as? Data else { + return FlutterSecureStorageResponse(status: keyStatus, value: nil) + } - guard status == errSecSuccess, let data = ref as? Data else { - return FlutterSecureStorageResponse(status: status, value: nil) + // Read encrypted data payload + var dataParams = params + dataParams.shouldReturnData = true + var dataQuery = baseQuery(from: dataParams) + var dataRef: AnyObject? + let dataStatus = SecItemCopyMatching(dataQuery as CFDictionary, &dataRef) + if dataStatus == errSecItemNotFound { + return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) + } + guard dataStatus == errSecSuccess, let encryptedData = dataRef as? Data else { + return FlutterSecureStorageResponse(status: dataStatus, value: nil) } - let value = String(data: data, encoding: .utf8) - return FlutterSecureStorageResponse(status: status, value: value) + // Unwrap AES key via Secure Enclave + if #available(iOS 11.3, macOS 10.15, *) { + let ac = createAccessControl(params: params) + do { + let privateKey = try ensureEnclavePrivateKey(service: params.service, accessControl: ac) + let aesKeyData = try unwrapSymmetricKey(wrappedKeyData, using: privateKey) + let key = SymmetricKey(data: aesKeyData) + // Encrypted blob format: nonce(12) + ciphertext+tag + guard encryptedData.count > 12 else { + return FlutterSecureStorageResponse(status: errSecDecode, value: nil) + } + let nonceData = encryptedData.prefix(12) + let ctData = encryptedData.suffix(encryptedData.count - 12) + let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: nonceData), ciphertext: ctData.dropLast(16), tag: ctData.suffix(16)) + let plaintext = try AES.GCM.open(sealedBox, using: key) + let value = String(data: plaintext, encoding: .utf8) + return FlutterSecureStorageResponse(status: errSecSuccess, value: value) + } catch { + // If unwrapping fails (e.g., no enclave), gracefully fall back to standard read + var fallbackParams = params + fallbackParams.useSecureEnclave = false + let query = baseQuery(from: fallbackParams) + var ref: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &ref) + if (status == errSecItemNotFound) { + return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) + } + guard status == errSecSuccess, let data = ref as? Data else { + return FlutterSecureStorageResponse(status: status, value: nil) + } + let value = String(data: data, encoding: .utf8) + return FlutterSecureStorageResponse(status: status, value: value) + } + } else { + // Fallback for OS versions without required APIs: standard read with access control + var fallbackParams = params + fallbackParams.useSecureEnclave = false + let query = baseQuery(from: fallbackParams) + var ref: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &ref) + if (status == errSecItemNotFound) { + return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) + } + guard status == errSecSuccess, let data = ref as? Data else { + return FlutterSecureStorageResponse(status: status, value: nil) + } + let value = String(data: data, encoding: .utf8) + return FlutterSecureStorageResponse(status: status, value: value) + } } /// Writes an item to the keychain. Updates if the key already exists. internal func write(params: KeychainQueryParameters, value: String) -> FlutterSecureStorageResponse { - let keyExists = (containsKey(params: params).getOrElse(false)) - var query = baseQuery(from: params) + if !(params.useSecureEnclave ?? false) { + let keyExists = (containsKey(params: params).getOrElse(false)) + var query = baseQuery(from: params) + + if keyExists { + let update: [CFString: Any] = [kSecValueData: value.data(using: .utf8) as Any] + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + + if status == errSecSuccess { + return FlutterSecureStorageResponse(status: status, value: nil) + } else { + _ = delete(params: params) + } + } + + query[kSecValueData] = value.data(using: .utf8) + let status = SecItemAdd(query as CFDictionary, nil) + return FlutterSecureStorageResponse(status: status, value: nil) + } - if keyExists { - let update: [CFString: Any] = [kSecValueData: value.data(using: .utf8) as Any] - let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + // Secure Enclave-backed: encrypt with per-item AES key wrapped by enclave key + guard let account = params.key else { + return FlutterSecureStorageResponse(status: errSecParam, value: nil) + } + + // Ensure enclave private key exists (with provided access control) + if #available(iOS 11.3, macOS 10.15, *) { + let ac = createAccessControl(params: params) + do { + let privateKey = try ensureEnclavePrivateKey(service: params.service, accessControl: ac) + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + return FlutterSecureStorageResponse(status: errSecParam, value: nil) + } + + // Generate random AES key and encrypt value + let aesKey = SymmetricKey(size: .bits256) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal(Data(value.utf8), using: aesKey, nonce: nonce) + let nonceBytes = Data(nonce) + let blob = nonceBytes + sealed.ciphertext + sealed.tag + + // Wrap AES key with Enclave public key + let wrappedKey = try wrapSymmetricKey(Data(aesKey.withUnsafeBytes { Data($0) }), using: publicKey) + + // Store wrapped key under companion account + var keyParams = params + keyParams.key = wrappedKeyName(for: account) + keyParams.shouldReturnData = false + keyParams.isSynchronizable = false + var keyQuery = baseQuery(from: keyParams) + keyQuery[kSecValueData] = wrappedKey + // Upsert wrapped key item + let keyExists = (containsKey(params: keyParams).getOrElse(false)) + var keyStatus: OSStatus + if keyExists { + keyStatus = SecItemUpdate(keyQuery as CFDictionary, [kSecValueData: wrappedKey] as CFDictionary) + } else { + keyStatus = SecItemAdd(keyQuery as CFDictionary, nil) + } + guard keyStatus == errSecSuccess else { + return FlutterSecureStorageResponse(status: keyStatus, value: nil) + } - if status == errSecSuccess { + // Store encrypted payload under original account + var dataParams = params + dataParams.shouldReturnData = false + var dataQuery = baseQuery(from: dataParams) + dataQuery[kSecValueData] = blob + let dataExists = (containsKey(params: params).getOrElse(false)) + var dataStatus: OSStatus + if dataExists { + dataStatus = SecItemUpdate(dataQuery as CFDictionary, [kSecValueData: blob] as CFDictionary) + } else { + dataStatus = SecItemAdd(dataQuery as CFDictionary, nil) + } + return FlutterSecureStorageResponse(status: dataStatus, value: nil) + } catch { + return FlutterSecureStorageResponse(status: errSecParam, value: nil) + } + } else { + // Fallback for OS versions without required APIs: store using standard Keychain with access control + var fallbackParams = params + fallbackParams.useSecureEnclave = false + let keyExists = (containsKey(params: fallbackParams).getOrElse(false)) + var query = baseQuery(from: fallbackParams) + if keyExists { + let update: [CFString: Any] = [kSecValueData: value.data(using: .utf8) as Any] + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) return FlutterSecureStorageResponse(status: status, value: nil) } else { - _ = delete(params: params) + query[kSecValueData] = value.data(using: .utf8) + let status = SecItemAdd(query as CFDictionary, nil) + return FlutterSecureStorageResponse(status: status, value: nil) } } - - query[kSecValueData] = value.data(using: .utf8) - let status = SecItemAdd(query as CFDictionary, nil) - return FlutterSecureStorageResponse(status: status, value: nil) } /// Deletes an item from the keychain. internal func delete(params: KeychainQueryParameters) -> FlutterSecureStorageResponse { + // Delete the primary value let query = baseQuery(from: params) let status = SecItemDelete(query as CFDictionary) - // Return nil if nothing is found - if (status == errSecItemNotFound) { - return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) + if status != errSecSuccess && status != errSecItemNotFound { + return FlutterSecureStorageResponse(status: status, value: nil) } - return FlutterSecureStorageResponse(status: status, value: nil) + + // If Secure Enclave flow is used, also remove the wrapped AES key companion item + if params.useSecureEnclave == true, let account = params.key { + var keyParams = params + keyParams.key = wrappedKeyName(for: account) + let wrappedQuery = baseQuery(from: keyParams) + _ = SecItemDelete(wrappedQuery as CFDictionary) + } + + return FlutterSecureStorageResponse(status: errSecSuccess, value: nil) } /// Deletes all items matching the query parameters. diff --git a/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorageDarwinPlugin.swift b/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorageDarwinPlugin.swift index 07db4fcb..c100b962 100644 --- a/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorageDarwinPlugin.swift +++ b/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorageDarwinPlugin.swift @@ -165,7 +165,8 @@ public class FlutterSecureStorageDarwinPlugin: NSObject, FlutterPlugin, FlutterS isPlaceholder: (options["isPlaceholder"] as? String).flatMap { Bool($0) }, shouldReturnPersistentReference: (options["persistentReference"] as? String).flatMap { Bool($0) }, authenticationUIBehavior: options["authenticationUIBehavior"] as? String, - accessControlFlags: options["accessControlFlags"] as? String + accessControlFlags: options["accessControlFlags"] as? String, + useSecureEnclave: (options["useSecureEnclave"] as? String).flatMap { Bool($0) } ) return (parameters, value)