Skip to content

Commit

Permalink
Merge pull request #873 from juliansteenbakker/feat/access-control
Browse files Browse the repository at this point in the history
feature: add access control parameter for apple
  • Loading branch information
juliansteenbakker authored Feb 4, 2025
2 parents c6e147e + ee37560 commit c518e59
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 28 deletions.
67 changes: 59 additions & 8 deletions flutter_secure_storage/lib/options/apple_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,38 @@ enum KeychainAccessibility {
first_unlock_this_device,
}

/// Keychain access control flags that define security conditions for accessing
/// items. These flags can be combined to create complex access control
/// policies.
enum AccessControlFlag {
/// Constraint to access an item with a passcode.
devicePasscode,

/// Constraint to access an item with biometrics (Touch ID/Face ID).
biometryAny,

/// Constraint to access an item with the currently enrolled biometrics.
biometryCurrentSet,

/// Constraint to access an item with either biometry or passcode.
userPresence,

/// Constraint to access an item with a paired watch.
watch,

/// Combine multiple constraints with an OR operation.
or,

/// Combine multiple constraints with an AND operation.
and,

/// Use an application-provided password for encryption.
applicationPassword,

/// Enable private key usage for signing operations.
privateKeyUsage,
}

/// Specific options for Apple platform.
abstract class AppleOptions extends Options {
/// Creates an instance of `AppleOptions` with configurable parameters
Expand All @@ -49,7 +81,7 @@ abstract class AppleOptions extends Options {
this.resultLimit,
this.shouldReturnPersistentReference,
this.authenticationUIBehavior,
this.accessControlSettings,
this.accessControlFlags = const [],
});

/// The default account name associated with the keychain items.
Expand Down Expand Up @@ -130,11 +162,29 @@ abstract class AppleOptions extends Options {
/// Determines whether authentication prompts are displayed to the user.
final String? authenticationUIBehavior;

/// `kSecAttrAccessControl`: **Shared or Unique**.
/// Specifies access control settings for the item
/// (e.g., biometrics, passcode).
/// Shared if multiple items use the same access control.
final String? accessControlSettings;
/// Keychain access control flags define security conditions for accessing
/// items. These flags can be combined to create custom security policies.
///
/// ### Using Logical Operators:
/// - Use `AccessControlFlag.or` to allow access if **any** of the specified
/// conditions are met.
/// - Use `AccessControlFlag.and` to require that **all** specified conditions
/// are met.
///
/// **Rules for Combining Flags:**
/// - Only one logical operator (`or` or `and`) can be used per combination.
/// - Logical operators should be placed after the security constraints.
///
/// **Supported Flags:**
/// - `userPresence`: Requires user authentication via biometrics or passcode.
/// - `biometryAny`: Allows access with any enrolled biometrics.
/// - `biometryCurrentSet`: Requires currently enrolled biometrics.
/// - `devicePasscode`: Requires device passcode authentication.
/// - `watch`: Allows access with a paired Apple Watch.
/// - `privateKeyUsage`: Enables use of a private key for signing operations.
/// - `applicationPassword`: Uses an app-defined password for encryption.
///
final List<AccessControlFlag> accessControlFlags;

@override
Map<String, String> toMap() => <String, String>{
Expand All @@ -156,7 +206,8 @@ abstract class AppleOptions extends Options {
'shouldReturnPersistentReference': '$shouldReturnPersistentReference',
if (authenticationUIBehavior != null)
'authenticationUIBehavior': authenticationUIBehavior!,
if (accessControlSettings != null)
'accessControlSettings': accessControlSettings!,
if (accessControlFlags.isNotEmpty)
'accessControlFlags':
accessControlFlags.map((e) => e.name).toList().toString(),
};
}
2 changes: 1 addition & 1 deletion flutter_secure_storage/lib/options/ios_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class IOSOptions extends AppleOptions {
super.resultLimit,
super.shouldReturnPersistentReference,
super.authenticationUIBehavior,
super.accessControlSettings,
super.accessControlFlags,
});

/// A predefined `IosOptions` instance with default settings.
Expand Down
2 changes: 1 addition & 1 deletion flutter_secure_storage/lib/options/macos_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class MacOsOptions extends AppleOptions {
super.resultLimit,
super.shouldReturnPersistentReference,
super.authenticationUIBehavior,
super.accessControlSettings,
super.accessControlFlags,
this.usesDataProtectionKeychain = true,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ struct KeychainQueryParameters {
/// `kSecUseAuthenticationUI` (iOS/macOS): Controls how authentication UI is presented during secure operations.
var authenticationUIBehavior: String?

/// `kSecAttrAccessControl` (iOS/macOS): Specifies access control settings (e.g., biometrics, passcode).
var accessControlSettings: SecAccessControl?
/// `accessControlFlags` (iOS/macOS): Specifies access control settings (e.g., biometrics, passcode).
var accessControlFlags: String?
}

/// Represents the response from a keychain operation.
Expand All @@ -88,6 +88,52 @@ class FlutterSecureStorage {
default: return kSecAttrAccessibleWhenUnlocked
}
}

/// Parses a string of comma-separated access control flags into SecAccessControlCreateFlags.
private func parseAccessControlFlags(_ flagString: String?) -> SecAccessControlCreateFlags {
guard let flagString = flagString else { return [] }
var flags: SecAccessControlCreateFlags = []
let flagList = flagString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
for dirtyFlag in flagList {
let flag = dirtyFlag.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))

switch flag {
case "userPresence":
flags.insert(.userPresence)
case "biometryAny":
flags.insert(.biometryAny)
case "biometryCurrentSet":
flags.insert(.biometryCurrentSet)
case "devicePasscode":
flags.insert(.devicePasscode)
case "or":
flags.insert(.or)
case "and":
flags.insert(.and)
case "privateKeyUsage":
flags.insert(.privateKeyUsage)
case "applicationPassword":
flags.insert(.applicationPassword)
default:
continue
}
}
return flags
}

/// Creates an access control object based on the provided parameters.
private func createAccessControl(params: KeychainQueryParameters) -> SecAccessControl? {
guard let accessibilityLevel = params.accessibilityLevel else { return nil }
let protection = parseAccessibleAttr(accessibilityLevel)
let flags = parseAccessControlFlags(params.accessControlFlags)
var error: Unmanaged<CFError>?
let accessControl = SecAccessControlCreateWithFlags(nil, protection, flags, &error)
if let error = error?.takeRetainedValue() {
print("Error creating access control: \(error.localizedDescription)")
return nil
}
return accessControl
}

/// Constructs a keychain query dictionary from the given parameters.
private func baseQuery(from params: KeychainQueryParameters) -> [CFString: Any] {
Expand All @@ -108,14 +154,6 @@ class FlutterSecureStorage {
query[kSecAttrService] = service
}

if let isSynchronizable = params.isSynchronizable {
query[kSecAttrSynchronizable] = isSynchronizable
}

if let accessibilityLevel = params.accessibilityLevel {
query[kSecAttrAccessible] = parseAccessibleAttr(accessibilityLevel)
}

if let shouldReturnData = params.shouldReturnData {
query[kSecReturnData] = shouldReturnData
}
Expand Down Expand Up @@ -152,8 +190,15 @@ class FlutterSecureStorage {
query[kSecUseAuthenticationUI] = authenticationUIBehavior
}

if let accessControlSettings = params.accessControlSettings {
query[kSecAttrAccessControl] = accessControlSettings
if let accessControl = createAccessControl(params: params) {
query[kSecAttrAccessControl] = accessControl
} else {
if let accessibilityLevel = params.accessibilityLevel {
query[kSecAttrAccessible] = parseAccessibleAttr(accessibilityLevel)
}
if let isSynchronizable = params.isSynchronizable {
query[kSecAttrSynchronizable] = isSynchronizable
}
}

#if os(macOS)
Expand All @@ -172,11 +217,6 @@ class FlutterSecureStorage {
}

private func validateQueryParameters(params: KeychainQueryParameters) throws {
// Accessibility and access control
if params.accessibilityLevel != nil, params.accessControlSettings != nil {
throw OSSecError(status: errSecParam, message: "Cannot use kSecAttrAccessible and kSecAttrAccessControl together.")
}

// Match limit
if params.resultLimit == 1, params.shouldReturnData == true {
throw OSSecError(status: errSecParam, message: "Cannot use kSecMatchLimitAll when expecting a single result with kSecReturnData.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ public class FlutterSecureStorageDarwinPlugin: NSObject, FlutterPlugin, FlutterS
isHidden: (options["isHidden"] as? String).flatMap { Bool($0) },
isPlaceholder: (options["isPlaceholder"] as? String).flatMap { Bool($0) },
shouldReturnPersistentReference: (options["persistentReference"] as? String).flatMap { Bool($0) },
authenticationUIBehavior: options["authenticationUIBehavior"] as? String
authenticationUIBehavior: options["authenticationUIBehavior"] as? String,
accessControlFlags: options["accessControlFlags"] as? String
)

return (parameters, value)
Expand Down

0 comments on commit c518e59

Please sign in to comment.