alexruperez/SecurePropertyStorage

View on GitHub
Sources/Keychain/KeychainStorage.swift

Summary

Maintainability
A
0 mins
Test Coverage
B
86%
import CryptoKit
import Foundation
import Storage

/// `KeychainStorage` subclass of `DelegatedStorage` that uses a `KeychainStorageDelegate`.
open class KeychainStorage: DelegatedStorage {
    /// `KeychainStorage` shared instance.
    open class var standard: KeychainStorage { shared }
    private static let shared = KeychainStorage()
    private var keychainDelegate: KeychainStorageDelegate? { delegate as? KeychainStorageDelegate }

    /// Access group where `StorageData` is in.
    open var accessGroup: String? {
        get { keychainDelegate?.accessGroup }
        set { keychainDelegate?.accessGroup = newValue }
    }

    /// Whether the `StorageData` can be synchronized.
    open var synchronizable: Bool {
        get { keychainDelegate?.synchronizable ?? false }
        set { keychainDelegate?.synchronizable = newValue }
    }

    /// When `StorageData` can be accessed in the keychain.
    open var accessible: CFString {
        get { keychainDelegate?.accessible ?? kSecAttrAccessibleWhenUnlocked }
        set { keychainDelegate?.accessible = newValue }
    }

    /**
     Create a `KeychainStorage`.

     - Parameter delegate: `StorageDelegate`, defaults `KeychainStorageDelegate`.
     - Parameter authenticationTag: Custom additional `Data` to be authenticated.
     - Parameter errorClosure: Closure to handle `KeychainStorageDelegate` errors.
     */
    public convenience init(_ delegate: StorageDelegate = KeychainStorageDelegate(),
                            authenticationTag: Data? = nil,
                            errorClosure: StorageErrorClosure? = nil) {
        self.init(delegate,
                  symmetricKey: SymmetricKey.generate(),
                  authenticationTag: authenticationTag,
                  errorClosure: errorClosure)
    }
}

/// `KeychainStorageDelegate` conforming `StorageDelegate` protocol.
open class KeychainStorageDelegate: StorageDelegate {
    /// Access group where `StorageData` is in.
    open var accessGroup: String?
    /// Whether the `StorageData` can be synchronized.
    open var synchronizable = false
    /// When `StorageData` can be accessed in the keychain.
    open var accessible: CFString = kSecAttrAccessibleWhenUnlocked
    /// Specifies item class used.
    open var secClass: CFString = kSecClassGenericPassword

    /// Create a `KeychainStorageDelegate`.
    public init() {}

    /**
     Get `StorageData` for `StoreKey` from the keychain.

     - Parameter key: `StoreKey` to store the `StorageData`.

     - Throws: `KeychainError.error`.

     - Returns: `StorageData` for `StoreKey`.
     */
    open func data<D: StorageData>(forKey key: StoreKey) throws -> D? {
        try read(key: key,
                 accessGroup: accessGroup,
                 synchronizable: synchronizable,
                 secClass: secClass,
                 returnAttributes: nil,
                 returnData: true)
    }

    /**
     Set `StorageData` for `StoreKey` in the keychain.

     - Parameter data: `StorageData` to store.
     - Parameter key: `StoreKey` to store the `StorageData`.

     - Throws: `KeychainError.error`.
     */
    open func set(_ data: (some StorageData)?, forKey key: StoreKey) throws {
        try? remove(forKey: key)
        if let data {
            try store(data,
                      key: key,
                      accessible: accessible,
                      accessGroup: accessGroup,
                      synchronizable: synchronizable,
                      secClass: secClass)
        }
    }

    /**
     Remove `StorageData` for `StoreKey` from the keychain.

     - Parameter key: `StoreKey` to remove.

     - Throws: `KeychainError.error`.
     */
    open func remove(forKey key: StoreKey) throws {
        try delete(key: key,
                   accessGroup: accessGroup,
                   synchronizable: synchronizable,
                   secClass: secClass)
    }

    /**
     Store `StorageData` for account in the keychain.

     - Parameter value: `StorageData` to store.
     - Parameter account: Item's account name.
     - Parameter accessible: When the keychain item is accessible.
     - Parameter accessGroup: Access group where `StorageData` is in.
     - Parameter synchronizable: Whether the `StorageData` can be synchronized.
     - Parameter secClass: Specifies item class used.

     - Throws: `KeychainError.error`.
     */
    open func store(_ value: some StorageData,
                    key: String,
                    accessible: CFString = kSecAttrAccessibleWhenUnlocked,
                    accessGroup: String? = nil,
                    synchronizable: Bool = false,
                    secClass: CFString = kSecClassGenericPassword) throws {
        var query = [kSecClass: secClass,
                     kSecAttrAccount: key,
                     kSecAttrAccessible: accessible,
                     kSecUseDataProtectionKeychain: true,
                     kSecValueData: value.data] as [CFString: Any]
        if let accessGroup {
            query[kSecAttrAccessGroup] = accessGroup
        }
        if synchronizable {
            query[kSecAttrSynchronizable] = kCFBooleanTrue
        }

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.error("Unable to store item: \(status.message)")
        }
    }

    /**
     Read `StorageData` for account from the keychain.

     - Parameter account: Item's account name.
     - Parameter accessGroup: Access group where `StorageData` is in.
     - Parameter synchronizable: Whether the `StorageData` can be synchronized.
     - Parameter secClass: Specifies item class used.
     - Parameter returnAttributes: Return item attributes.
     - Parameter returnData: Return item data.

     - Throws: `KeychainError.error`.

     - Returns: `StorageData` for account.
     */
    open func read<D: StorageData>(key: String,
                                   accessGroup: String? = nil,
                                   synchronizable: Bool = false,
                                   secClass: CFString = kSecClassGenericPassword,
                                   returnAttributes: Bool? = nil,
                                   returnData: Bool = true) throws -> D? {
        var query = [kSecClass: secClass,
                     kSecAttrAccount: key,
                     kSecUseDataProtectionKeychain: true,
                     kSecReturnData: returnData] as [CFString: Any]
        if let accessGroup {
            query[kSecAttrAccessGroup] = accessGroup
        }
        if synchronizable {
            query[kSecAttrSynchronizable] = kCFBooleanTrue
        }
        if let returnAttributes {
            query[kSecReturnAttributes] = returnAttributes
        }

        var item: CFTypeRef?
        switch SecItemCopyMatching(query as CFDictionary, &item) {
        case errSecSuccess:
            guard let bytes = item as? Data else {
                return nil
            }
            return try D(bytes: bytes)
        case errSecItemNotFound:
            return nil
        case let status:
            throw KeychainError.error("Keychain read failed: \(status.message)")
        }
    }

    /**
     Delete item for account from the keychain.

     - Parameter account: Item's account name.
     - Parameter accessGroup: Access group where `StorageData` is in.
     - Parameter synchronizable: Whether the `StorageData` can be synchronized.
     - Parameter secClass: Specifies item class used.

     - Throws: `KeychainError.error`.
     */
    open func delete(key: String,
                     accessGroup: String? = nil,
                     synchronizable: Bool = false,
                     secClass: CFString = kSecClassGenericPassword) throws {
        var query = [kSecClass: secClass,
                     kSecUseDataProtectionKeychain: true,
                     kSecAttrAccount: key] as [CFString: Any]
        if let accessGroup {
            query[kSecAttrAccessGroup] = accessGroup
        }
        if synchronizable {
            query[kSecAttrSynchronizable] = kCFBooleanTrue
        }

        switch SecItemDelete(query as CFDictionary) {
        case errSecItemNotFound, errSecSuccess:
            break
        case let status:
            throw KeychainError.error("Unexpected deletion error: \(status.message)")
        }
    }
}