Essential Guidelines for Safeguarding Sensitive Information in iOS Keychain

Nurul IslamNurul Islam
9 min read

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.

KeychainServiceProtocol

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")
    }
}

UserInfoKeychainServiceProtocol extending KeychainServiceProtocol

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.

0
Subscribe to my newsletter

Read articles from Nurul Islam directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nurul Islam
Nurul Islam

I'm an experienced mobile application developer with over 10 years of expertise in iOS development using Swift, Objective-C, and Flutter. Throughout my career, I've had the opportunity to work for multiple renowned companies where I developed and architected robust mobile applications used by millions of users worldwide. In addition to my extensive iOS experience, I also have experience working with ASP.Net Core, Entity Framework, and LINQ for backend development as well as Machine Learning and Deep Learning using Python and TensorFlow which has allowed me to collaborate closely with different teams to develop different types of products, apps or services.