Skip to content

Commit

Permalink
[secure-store][ios] Add synchronous functions and change keychain han…
Browse files Browse the repository at this point in the history
…dling (expo#23841)

# Why

Synchronous read and write functions will be added to Android in this PR
expo#23804, we need to add them to iOS too.
The Android PR also changes the keychain handling to allow users to save
authenticated and unauthenticated values under the same keychain, this
PR adds similar changes to iOS.

# How

Added synchronous functions, keychainService will now add a "auth" or
"no-auth" suffix to it's name to allow saving authenticated and
unauthenticated values into the same keychain from the JS perspective.
This behaviour is a more intuitive for the users and makes Android and
iOS versions of the module work exactly the same, but adds some
complexity on the native side.

# Test Plan

Tested on a physical iOS 16 device.

## Do NOT merge before expo#23804 as this
PR relies on some code changes from that PR
  • Loading branch information
behenate authored Dec 7, 2023
1 parent 0b71f31 commit db91184
Show file tree
Hide file tree
Showing 2 changed files with 57 additions and 20 deletions.
3 changes: 3 additions & 0 deletions packages/expo-secure-store/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

### 🎉 New features

- [iOS] Added possibility to store values that require authentication and ones that don't under the same `keychainService`. ([#23841](https://github.com/expo/expo/pull/23841) by [@behenate](https://github.com/behenate))
- [iOS] Added synchronous functions for storing and retrieving values from the store. ([#23841](https://github.com/expo/expo/pull/23841) by [@behenate](https://github.com/behenate))

### 🐛 Bug fixes

### 💡 Others
Expand Down
74 changes: 54 additions & 20 deletions packages/expo-secure-store/ios/SecureStoreModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,22 @@ public final class SecureStoreModule: Module {
])

AsyncFunction("getValueWithKeyAsync") { (key: String, options: SecureStoreOptions) -> String? in
guard let key = validate(for: key) else {
throw InvalidKeyException()
}
return try get(with: key, options: options)
}

let data = try searchKeyChain(with: key, options: options)
Function("getValueWithKeySync") { (key: String, options: SecureStoreOptions) -> String? in
return try get(with: key, options: options)
}

guard let data = data else {
return nil
AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in
guard let key = validate(for: key) else {
throw InvalidKeyException()
}

return String(data: data, encoding: .utf8)
return try set(value: value, with: key, options: options)
}

AsyncFunction("setValueWithKeyAsync") { (value: String, key: String, options: SecureStoreOptions) -> Bool in
Function("setValueWithKeySync") {(value: String, key: String, options: SecureStoreOptions) -> Bool in
guard let key = validate(for: key) else {
throw InvalidKeyException()
}
Expand All @@ -39,33 +41,61 @@ public final class SecureStoreModule: Module {
}

AsyncFunction("deleteValueWithKeyAsync") { (key: String, options: SecureStoreOptions) in
let searchDictionary = query(with: key, options: options)
SecItemDelete(searchDictionary as CFDictionary)
let noAuthSearchDictionary = query(with: key, options: options, requireAuthentication: false)
let authSearchDictionary = query(with: key, options: options, requireAuthentication: true)
let legacySearchDictionary = query(with: key, options: options)

SecItemDelete(legacySearchDictionary as CFDictionary)
SecItemDelete(authSearchDictionary as CFDictionary)
SecItemDelete(noAuthSearchDictionary as CFDictionary)
}
}

private func get(with key: String, options: SecureStoreOptions) throws -> String? {
guard let key = validate(for: key) else {
throw InvalidKeyException()
}

if let unauthenticatedItem = try searchKeyChain(with: key, options: options, requireAuthentication: false) {
return String(data: unauthenticatedItem, encoding: .utf8)
}

if let authenticatedItem = try searchKeyChain(with: key, options: options, requireAuthentication: true) {
return String(data: authenticatedItem, encoding: .utf8)
}

if let legacyItem = try searchKeyChain(with: key, options: options) {
return String(data: legacyItem, encoding: .utf8)
}

return nil
}

private func set(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
var query = query(with: key, options: options)
var setItemQuery = query(with: key, options: options, requireAuthentication: options.requireAuthentication)

let valueData = value.data(using: .utf8)
query[kSecValueData as String] = valueData
setItemQuery[kSecValueData as String] = valueData

let accessibility = attributeWith(options: options)

if !options.requireAuthentication {
query[kSecAttrAccessible as String] = accessibility
setItemQuery[kSecAttrAccessible as String] = accessibility
} else {
guard let _ = Bundle.main.infoDictionary?["NSFaceIDUsageDescription"] as? String else {
throw MissingPlistKeyException()
}
let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, SecAccessControlCreateFlags.biometryCurrentSet, nil)
query[kSecAttrAccessControl as String] = accessOptions
setItemQuery[kSecAttrAccessControl as String] = accessOptions
}

let status = SecItemAdd(query as CFDictionary, nil)
let status = SecItemAdd(setItemQuery as CFDictionary, nil)

switch status {
case errSecSuccess:
// On success we want to remove the other key alias and legacy key (if they exist) to avoid conflicts during reads
SecItemDelete(query(with: key, options: options) as CFDictionary)
SecItemDelete(query(with: key, options: options, requireAuthentication: !options.requireAuthentication) as CFDictionary)
return true
case errSecDuplicateItem:
return try update(value: value, with: key, options: options)
Expand All @@ -75,7 +105,7 @@ public final class SecureStoreModule: Module {
}

private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool {
var query = query(with: key, options: options)
var query = query(with: key, options: options, requireAuthentication: options.requireAuthentication)

let valueData = value.data(using: .utf8)
let updateDictionary = [kSecValueData as String: valueData]
Expand All @@ -93,8 +123,8 @@ public final class SecureStoreModule: Module {
}
}

private func searchKeyChain(with key: String, options: SecureStoreOptions) throws -> Data? {
var query = query(with: key, options: options)
private func searchKeyChain(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) throws -> Data? {
var query = query(with: key, options: options, requireAuthentication: requireAuthentication)

query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnData as String] = kCFBooleanTrue
Expand All @@ -119,8 +149,12 @@ public final class SecureStoreModule: Module {
}
}

private func query(with key: String, options: SecureStoreOptions) -> [String: Any] {
let service = options.keychainService ?? "app"
private func query(with key: String, options: SecureStoreOptions, requireAuthentication: Bool? = nil) -> [String: Any] {
var service = options.keychainService ?? "app"
if let requireAuthentication {
service.append(":\(requireAuthentication ? "auth" : "no-auth")")
}

let encodedKey = Data(key.utf8)

return [
Expand Down

0 comments on commit db91184

Please sign in to comment.