iOS数据持久化——KeyChain

语言: CN / TW / HK

在我们开发iOS应用的时候,很多时候,我们都需要将敏感数据(password, accessToken, secretKey等)存储到本地。对于初级程序员来讲,首先映入脑海的可能是使用UserDefaults。然而,众所周知,使用UserDefaults来存储敏感信息简直是low的不能再low的主意了。因为我们一般存储到UserDefaults中的数据都是未经过编码处理的,这样是非常不安全的。

为了能安全的在本地存储敏感信息,我们应当使用苹果提供的KeyChain服务。这个framework已经相当老了,所以,我们在后面阅读的时候,会觉得它提供的API并不像当下的framework那么快捷。

在本文中,将为你展示如何创建一个通用的同时适用于iOS、MacOS的keyChain辅助类,对数据进行增删改查操作。开始吧!!!

保存数据到KeyChain

final class KeyChainHelper { static let standard = KeyChainHelper() private init(){} }

我们必须巧妙使用SecItemAdd(_:_:)方法,这个方法会接收一个CFDictionary类型的query对象。

这个主意是为了创建一个query对象,这个对象包含了我们想要存储最主要的数据键值对。然后,将query对象传入SecItemAdd(_:_:)方法中来执行保存操作。

``` func save(_ data: Data, service: String, account: String) {

// Create query
let query = [
    kSecValueData: data,
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: service,
    kSecAttrAccount: account,
] as CFDictionary

// Add data in query to keychain
let status = SecItemAdd(query, nil)

if status != errSecSuccess {
    // Print out the error
    print("Error: (status)")
}

} ```

回看上述代码片段,query对象由4个键值对组成: - kSecValueData: 这个键代表着数据已经被存储到了keyChain中 - kSecClass: 这个键代表着数据已经被存储到了keyChain中。我们将它的值设为了kSecClassGenericPassword,这代表着我们所保存的数据是一个通用的密码项 - kSecAttrServicekSecAttrAccount: 当kSecClass被设置为kSecClassGenericPassword的时候,kSecAttrServicekSecAttrAccount这两个键是必须要有的。这两个键所对应的值将作为所保存数据的关键key,换句话说,我们将使用他们从keyChain中读取所保存的值。

对于kSecAttrServicekSecAttrAccount所对应的值的定义并没有什么难的。推荐使用字符串。例如:如果我们想存储Facebook的accesToken,我们需要将kSecAttrService设置成”access-token“,将kSecAttrAccount设置成”facebook“

创建完query对象之后,我们可以调用SecItemAdd(_:_:)方法来保存数据到keyChain。SecItemAdd(_:_:)方法会返回一个OSStatus来代表存储状态。如果我们得到的是errSecSuccess状态,则意味着数据已经被成功保存到keyChain中

下面是save(_:service:account:)方法的使用

let accessToken = "dummy-access-token" let data = Data(accessToken.utf8) KeychainHelper.standard.save(data, service: "access-token", account: "facebook")

keyChain不能在playground中使用,所以,上述代码必须写在Controller中。

更新KeyChain中已有的数据

现在我们有了save(_:service:account:)方法,让我们用相同的kSecAttrServicekSecAttrAccount所对应的值来存储其他token

let accessToken = "another-dummy-access-token" let data = Data(accessToken.utf8) KeychainHelper.standard.save(data, service: "access-token", account: "facebook")

这时候,我们就无法将accessToken保存到keyChain中了。同时,我们会得到一个Error: -25299的报错。该错误码代表的是存储失败。因为我们所使用的keys已经存在于keyChain当中了。

为了解决这个问题,我们需要检查这个错误码(相当于errSecDuplicateItem),然后使用SecItemUpdate(_:_:)方法来更新keyChain。一起看看并更新我们前述的save(_:service:account:)方法吧:

``` func save(_ data: Data, service: String, account: String) {

// ... ...
// ... ...

if status == errSecDuplicateItem {
    // Item already exist, thus update it.
    let query = [
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecClass: kSecClassGenericPassword,
    ] as CFDictionary

    let attributesToUpdate = [kSecValueData: data] as CFDictionary

    // Update existing item
    SecItemUpdate(query, attributesToUpdate)
}

} ```

跟保存操作相似的是,我们需要先创建一个query对象,这个对象包含kSecAttrServicekSecAttrAccount。但是这次,我们将会创建另外一个包含kSecValueData的字典,并将它传给SecItemUpdate(_:_:)方法。

这样的话,我们就可以让save(_:service:account:)方法来更新keyChain中已有的数据了。

从KeyChain中读取数据

从keyChain中读取数据的方式和保存的方式非常相似。我们首先要做的是创建一个query对象,然后调用一个keyChain方法:

``` func read(service: String, account: String) -> Data? {

let query = [
    kSecAttrService: service,
    kSecAttrAccount: account,
    kSecClass: kSecClassGenericPassword,
    kSecReturnData: true
] as CFDictionary

var result: AnyObject?
SecItemCopyMatching(query, &result)

return (result as? Data)

} ```

跟之前一样,我们需要设置query对象的kSecAttrService and kSecAttrAccount的值。在这之前,我们需要为query对象添加一个新的键kSecReturnData,其值为true,代表的是我们希望query返回对应项的数据。

之后,我们将利用 SecItemCopyMatching(_:_:) 方法并通过引用传入 AnyObject 类型的result对象。SecItemCopyMatching(_:_:)方法同样返回一个OSStatus类型的值,代表读取操作状态。但是如果读取失败了,这里我们不做任何校验,并返回nil

让keyChain支持读取的操作就这么多了,看一下他是怎么工作的吧

let data = KeychainHelper.standard.read(service: "access-token", account: "facebook")! let accessToken = String(data: data, encoding: .utf8)! print(accessToken)

从KeyChain中删除数据

如果没有删除操作,我们的KeyChainHelper类并不算完成。一起看看下面的代码片段吧

``` func delete(service: String, account: String) {

let query = [
    kSecAttrService: service,
    kSecAttrAccount: account,
    kSecClass: kSecClassGenericPassword,
    ] as CFDictionary

// Delete item from keychain
SecItemDelete(query)

} ```

如果你全程都在看的话,上述代码可能对你来说非常熟悉,那是相当的”自解释“了,需要注意的是,这里我们使用了SecItemDelete(_:)方法来删除KeyChain中的数据了。

创建一个通用的KeyChainHelper 类

存储 ``` func save(_ item: T, service: String, account: String) where T : Codable {

do {
    // Encode as JSON data and save in keychain
    let data = try JSONEncoder().encode(item)
    save(data, service: service, account: account)

} catch {
    assertionFailure("Fail to encode item for keychain: (error)")
}

} 读取 func read(service: String, account: String, type: T.Type) -> T? where T : Codable {

// Read item data from keychain
guard let data = read(service: service, account: account) else {
    return nil
}

// Decode JSON data to object
do {
    let item = try JSONDecoder().decode(type, from: data)
    return item
} catch {
    assertionFailure("Fail to decode item for keychain: \(error)")
    return nil
}

} 使用 struct Auth: Codable { let accessToken: String let refreshToken: String }

// Create an object to save let auth = Auth(accessToken: "dummy-access-token", refreshToken: "dummy-refresh-token")

let account = "domain.com" let service = "token"

// Save auth to keychain KeychainHelper.standard.save(auth, service: service, account: account)

// Read auth from keychain let result = KeychainHelper.standard.read(service: service, account: account, type: Auth.self)!

print(result.accessToken) // Output: "dummy-access-token" print(result.refreshToken) // Output: "dummy-refresh-token" ```