Skip to content

Commit 5ef1914

Browse files
Ahmedwestracer
authored andcommitted
Add Secure Enclave Support to iOS/macOS juliansteenbakker#989
1 parent 1644984 commit 5ef1914

File tree

8 files changed

+521
-32
lines changed

8 files changed

+521
-32
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,35 @@ final storage = FlutterSecureStorage(
235235
```
236236

237237
### macOS & iOS
238+
#### Secure Enclave (iOS/macOS)
239+
240+
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`.
241+
242+
Example:
243+
244+
```dart
245+
final storage = FlutterSecureStorage();
246+
247+
await storage.write(
248+
key: 'token',
249+
value: 'secret',
250+
iOptions: IOSOptions(
251+
useSecureEnclave: true,
252+
accessControlFlags: const [
253+
AccessControlFlag.userPresence, // require Face ID/Touch ID or passcode
254+
],
255+
),
256+
mOptions: MacOsOptions(
257+
useSecureEnclave: true,
258+
accessControlFlags: const [AccessControlFlag.userPresence],
259+
),
260+
);
261+
```
262+
263+
Notes:
264+
- 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.
265+
- `synchronizable` is ignored for Enclave-backed flows (items are device-bound).
266+
- On macOS, `kSecUseDataProtectionKeychain` remains enabled when available.
238267

239268
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`.
240269

flutter_secure_storage/example/integration_test/app_test.dart

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import 'dart:io' show Platform;
2+
13
import 'package:flutter/material.dart';
4+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
25
import 'package:flutter_secure_storage_example/main.dart';
36
import 'package:flutter_test/flutter_test.dart';
47
import 'package:integration_test/integration_test.dart';
@@ -78,6 +81,121 @@ void main() {
7881
..verifyRowDoesNotExist(0)
7982
..verifyRowDoesNotExist(1);
8083
});
84+
85+
testWidgets('Enclave requested on iOS Simulator falls back gracefully',
86+
skip: !(Platform.isIOS &&
87+
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')),
88+
(WidgetTester tester) async {
89+
const storage = FlutterSecureStorage();
90+
const key = 'it_enclave_sim_fallback_key';
91+
const value = 'sim_fallback_secret';
92+
93+
// Write with enclave requested
94+
// ignore: undefined_named_parameter
95+
await storage.write(
96+
key: key,
97+
value: value,
98+
iOptions: const IOSOptions(useSecureEnclave: true),
99+
);
100+
101+
// Read should succeed due to fallback
102+
// ignore: undefined_named_parameter
103+
final readBack = await storage.read(
104+
key: key,
105+
iOptions: const IOSOptions(useSecureEnclave: true),
106+
);
107+
expect(readBack, value);
108+
109+
// Delete should also succeed
110+
// ignore: undefined_named_parameter
111+
await storage.delete(
112+
key: key,
113+
iOptions: const IOSOptions(useSecureEnclave: true),
114+
);
115+
final afterDelete = await storage.read(
116+
key: key,
117+
iOptions: const IOSOptions(useSecureEnclave: true),
118+
);
119+
expect(afterDelete, isNull);
120+
});
121+
122+
testWidgets(
123+
'iOS device: baseline (useSecureEnclave=false) write/read/delete',
124+
skip: !(Platform.isIOS &&
125+
!Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')),
126+
(WidgetTester tester) async {
127+
const storage = FlutterSecureStorage();
128+
const key = 'it_enclave_device_baseline_key';
129+
const value = 'device_baseline_secret';
130+
131+
await storage.write(
132+
key: key,
133+
value: value,
134+
iOptions: IOSOptions.defaultOptions,
135+
);
136+
137+
final readBack = await storage.read(
138+
key: key,
139+
iOptions: IOSOptions.defaultOptions,
140+
);
141+
expect(readBack, value);
142+
143+
await storage.delete(
144+
key: key,
145+
iOptions: IOSOptions.defaultOptions,
146+
);
147+
final afterDelete = await storage.read(
148+
key: key,
149+
iOptions: IOSOptions.defaultOptions,
150+
);
151+
expect(afterDelete, isNull);
152+
});
153+
154+
testWidgets(
155+
'iOS device: useSecureEnclave=true with non-prompting access control (applicationPassword) write/read/delete',
156+
skip: !(Platform.isIOS &&
157+
!Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')),
158+
(WidgetTester tester) async {
159+
const storage = FlutterSecureStorage();
160+
const key = 'it_enclave_device_enabled_key';
161+
const value = 'device_enclave_secret';
162+
163+
await storage.write(
164+
key: key,
165+
value: value,
166+
// Use a non-prompting flag to make test automation stable.
167+
// ignore: undefined_named_parameter
168+
iOptions: const IOSOptions(
169+
useSecureEnclave: true,
170+
accessControlFlags: [AccessControlFlag.applicationPassword],
171+
),
172+
);
173+
174+
final readBack = await storage.read(
175+
key: key,
176+
iOptions: const IOSOptions(
177+
useSecureEnclave: true,
178+
accessControlFlags: [AccessControlFlag.applicationPassword],
179+
),
180+
);
181+
expect(readBack, value);
182+
183+
await storage.delete(
184+
key: key,
185+
iOptions: const IOSOptions(
186+
useSecureEnclave: true,
187+
accessControlFlags: [AccessControlFlag.applicationPassword],
188+
),
189+
);
190+
final afterDelete = await storage.read(
191+
key: key,
192+
iOptions: const IOSOptions(
193+
useSecureEnclave: true,
194+
accessControlFlags: [AccessControlFlag.applicationPassword],
195+
),
196+
);
197+
expect(afterDelete, isNull);
198+
});
81199
});
82200
}
83201

flutter_secure_storage/lib/options/apple_options.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ abstract class AppleOptions extends Options {
8282
this.shouldReturnPersistentReference,
8383
this.authenticationUIBehavior,
8484
this.accessControlFlags = const [],
85+
this.useSecureEnclave = false,
8586
});
8687

8788
/// The default account name associated with the keychain items.
@@ -186,6 +187,19 @@ abstract class AppleOptions extends Options {
186187
///
187188
final List<AccessControlFlag> accessControlFlags;
188189

190+
/// When true, opts into Secure Enclave–backed protection on iOS/macOS.
191+
///
192+
/// Behavior:
193+
/// - Data is encrypted with a per-item AES key that is wrapped by an
194+
/// Enclave-backed private key. Access is gated by [accessControlFlags]
195+
/// (e.g. Face ID/Touch ID/passcode via `userPresence`).
196+
/// - If the device or simulator does not support Secure Enclave or unwrap
197+
/// fails, the plugin gracefully falls back to standard Keychain storage
198+
/// using your configured [accessControlFlags].
199+
/// - iCloud Keychain sync (synchronizable) is ignored when using Enclave
200+
/// since keys are device-bound.
201+
final bool useSecureEnclave;
202+
189203
@override
190204
Map<String, String> toMap() => <String, String>{
191205
if (accountName != null) 'accountName': accountName!,
@@ -209,5 +223,6 @@ abstract class AppleOptions extends Options {
209223
if (accessControlFlags.isNotEmpty)
210224
'accessControlFlags':
211225
accessControlFlags.map((e) => e.name).toList().toString(),
226+
'useSecureEnclave': '$useSecureEnclave',
212227
};
213228
}

flutter_secure_storage/lib/options/ios_options.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class IOSOptions extends AppleOptions {
2222
super.shouldReturnPersistentReference,
2323
super.authenticationUIBehavior,
2424
super.accessControlFlags,
25+
super.useSecureEnclave,
2526
});
2627

2728
/// A predefined `IosOptions` instance with default settings.

flutter_secure_storage/lib/options/macos_options.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class MacOsOptions extends AppleOptions {
2222
super.authenticationUIBehavior,
2323
super.accessControlFlags,
2424
this.usesDataProtectionKeychain = true,
25+
super.useSecureEnclave = false,
2526
});
2627

2728
/// `kSecUseDataProtectionKeychain` (macOS only): **Shared**.

flutter_secure_storage/test/flutter_secure_storage_test.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ void main() {
196196
).called(1);
197197
});
198198

199+
test('IOSOptions.toMap includes useSecureEnclave flag when enabled', () {
200+
const options = IOSOptions(useSecureEnclave: true);
201+
expect(options.toMap()['useSecureEnclave'], 'true');
202+
});
203+
199204
test('read should return correct value', () async {
200205
when(
201206
() => mockPlatform.read(
@@ -758,6 +763,7 @@ void main() {
758763
'accountName': 'flutter_secure_storage_service',
759764
'accessibility': 'unlocked',
760765
'synchronizable': 'false',
766+
'useSecureEnclave': 'false',
761767
});
762768
});
763769

@@ -797,6 +803,7 @@ void main() {
797803
'authenticationUIBehavior': 'require_auth',
798804
'accessControlFlags':
799805
[AccessControlFlag.biometryCurrentSet.name].toString(),
806+
'useSecureEnclave': 'false',
800807
});
801808
});
802809

@@ -820,6 +827,7 @@ void main() {
820827
'accountName': 'flutter_secure_storage_service',
821828
'accessibility': 'unlocked',
822829
'synchronizable': 'false',
830+
'useSecureEnclave': 'false',
823831
'usesDataProtectionKeychain': 'true',
824832
});
825833
});
@@ -838,6 +846,7 @@ void main() {
838846
'groupId': 'group.mac.example',
839847
'accessibility': 'first_unlock',
840848
'synchronizable': 'true',
849+
'useSecureEnclave': 'false',
841850
'usesDataProtectionKeychain': 'false',
842851
});
843852
});

0 commit comments

Comments
 (0)