Skip to content
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,35 @@ android:allowBackup="false"
</application>

### 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`.

Expand Down
124 changes: 124 additions & 0 deletions flutter_secure_storage/example/integration_test/app_test.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
}

Expand Down
15 changes: 15 additions & 0 deletions flutter_secure_storage/lib/options/apple_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -186,6 +187,19 @@ abstract class AppleOptions extends Options {
///
final List<AccessControlFlag> 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<String, String> toMap() => <String, String>{
if (accountName != null) 'accountName': accountName!,
Expand All @@ -209,5 +223,6 @@ abstract class AppleOptions extends Options {
if (accessControlFlags.isNotEmpty)
'accessControlFlags':
accessControlFlags.map((e) => e.name).toList().toString(),
'useSecureEnclave': '$useSecureEnclave',
};
}
1 change: 1 addition & 0 deletions flutter_secure_storage/lib/options/ios_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class IOSOptions extends AppleOptions {
super.shouldReturnPersistentReference,
super.authenticationUIBehavior,
super.accessControlFlags,
super.useSecureEnclave,
});

/// A predefined `IosOptions` instance with default settings.
Expand Down
1 change: 1 addition & 0 deletions flutter_secure_storage/lib/options/macos_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class MacOsOptions extends AppleOptions {
super.authenticationUIBehavior,
super.accessControlFlags,
this.usesDataProtectionKeychain = true,
super.useSecureEnclave = false,
});

/// `kSecUseDataProtectionKeychain` (macOS only): **Shared**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -557,6 +562,7 @@ void main() {
'accountName': 'flutter_secure_storage_service',
'accessibility': 'unlocked',
'synchronizable': 'false',
'useSecureEnclave': 'false',
});
});

Expand Down Expand Up @@ -596,6 +602,7 @@ void main() {
'authenticationUIBehavior': 'require_auth',
'accessControlFlags':
[AccessControlFlag.biometryCurrentSet.name].toString(),
'useSecureEnclave': 'false',
});
});

Expand All @@ -619,6 +626,7 @@ void main() {
'accountName': 'flutter_secure_storage_service',
'accessibility': 'unlocked',
'synchronizable': 'false',
'useSecureEnclave': 'false',
'usesDataProtectionKeychain': 'true',
});
});
Expand All @@ -637,6 +645,7 @@ void main() {
'groupId': 'group.mac.example',
'accessibility': 'first_unlock',
'synchronizable': 'true',
'useSecureEnclave': 'false',
'usesDataProtectionKeychain': 'false',
});
});
Expand Down
Loading