Essential Guidelines for Safeguarding Sensitive Information in iOS Keychain
Best practices for storing sensitive data in Keychain
Storing sensitive data securely is crucial for any iOS application. The iOS Keychain is a secure storage service provided by Apple, designed specifically for storing small pieces of sensitive data, such as passwords, tokens, and other credentials. In this article, we’ll explore the best practices for securely storing sensitive data in the iOS Keychain, and I’ll walk you through a code example that demonstrates these practices with scalability and maintainability in mind.
Why Use Keychain?
The Keychain provides a secure way to store sensitive information because it is encrypted and access-controlled. Unlike UserDefaults
, which is not encrypted, the Keychain ensures that your data is safe even if an attacker gains physical access to the device.
Best Practices for Using Keychain
1. Use Access Control
When storing items in the Keychain, it is essential to specify the access control settings. These settings define when the item can be accessed. For example, kSecAttrAccessibleWhenUnlockedThisDeviceOnly
ensures that the data is only accessible when the device is unlocked and is never backed up, making it more secure.
2. Encrypt Data Before Storing
Although the Keychain encrypts data, it’s a good practice to encrypt sensitive data before storing it. This adds an extra layer of security, making it harder for attackers to access the data.
3. Handle Key Management Securely
Ensure that the encryption keys are managed securely. Hardcoding keys in your application is a bad practice. Instead, derive keys from secure sources or use the Keychain itself to store encryption keys.
4. Handle Errors Gracefully
Always handle errors gracefully. This includes logging errors appropriately and ensuring that sensitive information is not exposed in logs or commenting out log lines unless you're debugging keychain data.
5. Regularly Review and Update Security Practices
Security is an ongoing process. Regularly review and update your security practices to protect against new threats.
Example Implementation
Below is an implementation of a KeychainService
protocol and a UserInfoKeychainService
protocol that follows these best practices. The code includes methods for storing, retrieving, and deleting user information securely, with an emphasis on scalability and maintainability.
KeychainService
Protocol
import Foundation
import Security
import CryptoKit
protocol KeychainService: DataDecoder {
func saveKey(_ data: Data, forKey key: String)
func loadKey(forKey key: String) -> Data?
func deleteKey(forKey key: String)
func saveData(data: Data, identifier: ItemIdentifier)
func loadData<T: DecodableCodingKeys>(identifier: ItemIdentifier) -> Result<T, DecodeError>
func deleteData(identifier: ItemIdentifier)
func removeAllFromKeychain()
}
// MARK: Internal Implementations
extension KeychainService {
private func update(query: [String: Any], data: Data) {
let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary)
guard updateStatus == errSecSuccess else {
///Logger.log("Failed to update data in Keychain: \(updateStatus.description)")
return
}
///Logger.log("Data updated successfully in Keychain")
}
private func save(identifier: ItemIdentifier, data: Data) {
let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly, /// for highest security
.userPresence, /// require authentication (Touch ID/Face ID/passcode)
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: identifier.service,
kSecAttrAccount as String: identifier.description,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
///Logger.log("Failed to add data to Keychain: \(status.description)")
update(query: query, data: data)
return
}
///Logger.log("Data saved successfully in Keychain")
}
private func load(identifier: ItemIdentifier) -> Any? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: identifier.service,
kSecAttrAccount as String: identifier.description,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
var dataTypeRef: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
guard status == errSecSuccess else {
///Logger.log("Failed to load data from Keychain: \(status.description)")
return nil
}
return dataTypeRef
}
private func encrypt(data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
return sealedBox.combined!
}
private func decrypt(data: Data, key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(sealedBox, using: key)
}
func saveData(data: Data, identifier: ItemIdentifier) {
do {
let encryptedItem = try encrypt(data: data, key: identifier.key)
save(identifier: identifier, data: encryptedItem)
} catch {
///Logger.log("Encryption error: \(error)")
}
}
func loadData<T: DecodableCodingKeys>(identifier: ItemIdentifier) -> Result<T, DecodeError> {
guard let encryptedData = load(identifier: identifier) as? Data else {
///Logger.log("Failed to load data from Keychain")
return .failure(DecodeError.dataNotFound)
}
do {
let data = try decrypt(data: encryptedData, key: identifier.key)
return decode(data)
} catch {
///Logger.log("Decryption error: \(error)")
return .failure(DecodeError.dataNotFound)
}
}
func deleteData(identifier: ItemIdentifier) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: identifier.service,
kSecAttrAccount as String: identifier.description
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
///Logger.log("Failed to delete data from Keychain: \(status)")
return
}
///Logger.log("Data deleted successfully from Keychain")
}
func removeAllFromKeychain() {
for item in ItemIdentifier.allCases {
deleteKey(forKey: item.description.unique)
deleteData(identifier: item)
}
}
}
// MARK: Key Management
extension KeychainService {
func saveKey(_ data: Data, forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
///Logger.log("Failed to save key in Keychain: \(status)")
return
}
///Logger.log("Key successfully saved in Keychain")
}
func loadKey(forKey key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: key,
kSecReturnData as String: kCFBooleanTrue!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
var dataTypeRef: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
guard status == errSecSuccess else {
return nil
}
return dataTypeRef as? Data
}
func deleteKey(forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
///Logger.log("Failed to delete key from Keychain: \(status)")
return
}
///Logger.log("Key deleted successfully from Keychain")
}
}
UserInfoKeychainService
Protocol extending KeychainService
Protocol
import Foundation
protocol UserInfoKeychainService: KeychainService {
func getUserInfo() -> Result<UserInfo, DecodeError>
func saveUserInfo(data: Data)
func removeUserInfo()
}
extension UserInfoKeychainService {
func getUserInfo() -> Result<UserInfo, DecodeError> {
return loadData(identifier: .userInfo)
}
func saveUserInfo(data: Data) {
saveData(data: data, identifier: .userInfo)
}
func removeUserInfo() {
deleteData(identifier: .userInfo)
}
}
Implementing UserInfoKeychainService
in Your ViewModel
To leverage the UserInfoKeychainService
protocol, you can have any struct or class conform to it. This is particularly useful for dependency injection in your ViewModels, ensuring a clean and scalable architecture. Here’s how you can implement and use the UserInfoKeychainService
protocol in a struct or a ViewModel.
First, let’s define a struct that conforms to UserInfoKeychainService
:
struct KeychainServiceProvider: UserInfoKeychainService {}
With this implementation, KeychainServiceProvider
now has all the methods needed to securely store, load, and delete user information using the iOS Keychain.
Using KeychainServiceProvider
in a ViewModel
Next, let’s create a ViewModel and inject KeychainServiceProvider into it. This allows the ViewModel to handle user data securely.
import Foundation
class UserViewModel: ObservableObject {
private let keychainService: UserInfoKeychainService
init(keychainService: UserInfoKeychainService = KeychainServiceProvider()) {
self.keychainService = keychainService
}
func loadUserInfos() {
keychainService.loadUserInfos()
}
func storeUserInfos(data: Data) {
keychainService.storeUserInfos(data: data)
}
func removeUserInfos() {
keychainService.removeUserInfos()
}
}
Here’s how you can instantiate the UserViewModel
with the KeychainServiceProvider
:
let serviceProvider = KeychainServiceProvider()
let viewModel = UserViewModel(keychainService: serviceProvider)
Alternative Approach: Direct Conformance
Alternatively, you can make your ViewModel directly conform to UserInfoKeychainService. This approach embeds all the protocol’s methods directly into the ViewModel, making the methods readily available without needing to inject a separate service.
import Foundation
class UserViewModel: ObservableObject, UserInfoKeychainService {
// All methods from UserInfoKeychainService are now available directly in this class
}
This approach simplifies the ViewModel’s design by removing the need for an injected service, while still following best practices for secure data handling.
ItemIdentifier
internal enum ItemIdentifier: String, CustomStringConvertible, CaseIterable, KeychainService {
case userInfo
var description: String { rawValue }
var service: String {
switch self {
case .userInfo:
return Config.keychain.userInfoService
}
}
var key: SymmetricKey {
switch self {
case .userInfo:
return getKey() ?? generateAndStoreKey()
}
}
private func generateAndStoreKey() -> SymmetricKey {
let newKey = SymmetricKey(size: .bits256) /// Using 256 bits for AES
let newKeyData = newKey.withUnsafeBytes { Data(Array($0)) }
saveKey(newKeyData, forKey: self.description.unique)
return newKey
}
private func getKey() -> SymmetricKey? {
guard let keyData = loadKey(forKey: self.description.unique) else {
///Logger.log("Failed to load key from Keychain")
return nil
}
return SymmetricKey(data: keyData)
}
}
The ItemIdentifier enum plays a crucial role in the design and implementation of the secure storage system using Keychain in the provided code. Here’s a detailed summary of its purpose, how it works, and the benefits it provides:
Conforming to KeychainService
- Instead of creating and managing a separate concrete object to interact with the Keychain, the ItemIdentifier enum itself handles saving, loading, and deleting items from the Keychain. This design allows for a more cohesive and streamlined implementation. This makes the code cleaner and more efficient by reducing redundancy and enhancing readability.
Dynamic Key Management
- The key property in ItemIdentifier ensures that an encryption key is dynamically generated and stored if it doesn’t already exist. This dynamic management ensures that encryption keys are securely generated, stored, and retrieved. By securely handling keys, it minimizes the risk of exposing sensitive data, reducing the chances of errors and ensuring consistency in key handling.
Explanation of Key Attributes in Queries
1. kSecClass
The kSecClass attribute defines what type of item you are dealing with in the Keychain. The value kSecClassGenericPassword indicates that the item is a generic password, which is a common use case for Keychain storage. Other possible values include kSecClassInternetPassword, kSecClassCertificate, kSecClassKey, and kSecClassIdentity.
2. kSecAttrService
The kSecAttrService
attribute is used to specify a service name for the Keychain item. This helps in organizing and distinguishing items within the Keychain. The value for this attribute is typically a string that represents the application or service to which the item belongs. By using identifier.service, the code dynamically assigns the appropriate service name based on the item being handled.
3. kSecAttrAccount
The kSecAttrAccount
attribute defines the account name that is associated with the Keychain item. This attribute is useful for storing items that are associated with user accounts. The identifier.description value dynamically provides the appropriate account name based on the item.
4. kSecReturnData
The kSecReturnData attribute specifies whether the actual data should be returned when querying the Keychain. Setting this attribute to kCFBooleanTrue! means that the data will be returned if the query finds a matching item. This is essential when you need to retrieve and use the stored data.
5. kSecMatchLimit
The kSecMatchLimit attribute determines how many results should be returned by the query. The value kSecMatchLimitOne restricts the query to return only the first matching item. This is useful when you expect or want to retrieve only a single item from the Keychain.
6. kSecAttrApplicationTag
This is a constant defined by the Keychain Services API. It represents an attribute key used to identify the tag associated with the Keychain item.
Conclusion
Using the UserInfoKeychainService protocol and implementing it in a struct or class like KeychainServiceProvider offers flexible integration, enhanced security, robust error handling, and modular, maintainable code. This approach allows easy addition of new data types, scales with application growth, and follows security best practices like encryption and key management. It ensures graceful error handling, encapsulates key management logic, and promotes a protocol-oriented design, resulting in a secure, scalable, and maintainable solution for managing sensitive data in iOS applications.