Files
2022-12-28 12:30:18 -05:00

261 lines
10 KiB
Swift

//
// Keychain.swift
// OneTimePassword
//
// Copyright (c) 2014-2018 Matt Rubin and the OneTimePassword authors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import Foundation
/// The `Keychain`'s shared instance is a singleton which represents the iOS system keychain used
/// to securely store tokens.
public final class Keychain {
/// The singleton `Keychain` instance.
public static let sharedInstance = Keychain()
// MARK: Read
/// Finds the persistent token with the given identifer, if one exists.
///
/// - parameter identifier: The persistent identifier for the desired token.
///
/// - throws: A `Keychain.Error` if an error occurred.
/// - returns: The persistent token, or `nil` if no token matched the given identifier.
public func persistentToken(withIdentifier identifier: Data) throws -> PersistentToken? {
return try keychainItem(forPersistentRef: identifier).map(PersistentToken.init(keychainDictionary:))
}
/// Returns the set of all persistent tokens found in the keychain.
///
/// - throws: A `Keychain.Error` if an error occurred.
public func allPersistentTokens() throws -> Set<PersistentToken> {
let allItems = try allKeychainItems()
// This code intentionally ignores items which fail deserialization, instead opting to return as many readable
// tokens as possible.
// TODO: Restore deserialization error handling, in a way that provides info on the failure reason and allows
// the caller to choose whether to fail completely or recover some data.
return Set(allItems.compactMap({ try? PersistentToken(keychainDictionary: $0) }))
}
// MARK: Write
/// Adds the given token to the keychain and returns the persistent token which contains it.
///
/// - parameter token: The token to save to the keychain.
///
/// - throws: A `Keychain.Error` if the token was not added successfully.
/// - returns: The new persistent token.
public func add(_ token: Token) throws -> PersistentToken {
let attributes = try token.keychainAttributes()
let persistentRef = try addKeychainItem(withAttributes: attributes)
return PersistentToken(token: token, identifier: persistentRef)
}
/// Updates the given persistent token with a new token value.
///
/// - parameter persistentToken: The persistent token to update.
/// - parameter token: The new token value.
///
/// - throws: A `Keychain.Error` if the update did not succeed.
/// - returns: The updated persistent token.
public func update(_ persistentToken: PersistentToken, with token: Token) throws -> PersistentToken {
let attributes = try token.keychainAttributes()
try updateKeychainItem(forPersistentRef: persistentToken.identifier,
withAttributes: attributes)
return PersistentToken(token: token, identifier: persistentToken.identifier)
}
/// Deletes the given persistent token from the keychain.
///
/// - note: After calling `deletePersistentToken(_:)`, the persistent token's `identifier` is no
/// longer valid, and the token should be discarded.
///
/// - parameter persistentToken: The persistent token to delete.
///
/// - throws: A `Keychain.Error` if the deletion did not succeed.
public func delete(_ persistentToken: PersistentToken) throws {
try deleteKeychainItem(forPersistentRef: persistentToken.identifier)
}
// MARK: Errors
/// An error type enum representing the various errors a `Keychain` operation can throw.
public enum Error: Swift.Error {
/// The keychain operation returned a system error code.
case systemError(OSStatus)
/// The keychain operation returned an unexpected type of data.
case incorrectReturnType
/// The given token could not be serialized to keychain data.
case tokenSerializationFailure
}
}
// MARK: - Private
private let kOTPService = "me.mattrubin.onetimepassword.token"
private let urlStringEncoding = String.Encoding.utf8
private extension Token {
func keychainAttributes() throws -> [String: AnyObject] {
let url = try self.toURL()
guard let data = url.absoluteString.data(using: urlStringEncoding) else {
throw Keychain.Error.tokenSerializationFailure
}
return [
kSecAttrGeneric as String: data as NSData,
kSecValueData as String: generator.secret as NSData,
kSecAttrService as String: kOTPService as NSString,
]
}
}
private extension PersistentToken {
enum DeserializationError: Error {
case missingData
case missingSecret
case missingPersistentRef
case unreadableData
}
init(keychainDictionary: NSDictionary) throws {
guard let urlData = keychainDictionary[kSecAttrGeneric as String] as? Data else {
throw DeserializationError.missingData
}
guard let secret = keychainDictionary[kSecValueData as String] as? Data else {
throw DeserializationError.missingSecret
}
guard let keychainItemRef = keychainDictionary[kSecValuePersistentRef as String] as? Data else {
throw DeserializationError.missingPersistentRef
}
guard let urlString = String(data: urlData, encoding: urlStringEncoding),
let url = URL(string: urlString) else {
throw DeserializationError.unreadableData
}
let token = try Token(url: url, secret: secret)
self.init(token: token, identifier: keychainItemRef)
}
}
private func addKeychainItem(withAttributes attributes: [String: AnyObject]) throws -> Data {
var mutableAttributes = attributes
mutableAttributes[kSecClass as String] = kSecClassGenericPassword
mutableAttributes[kSecReturnPersistentRef as String] = kCFBooleanTrue
// Set a random string for the account name.
// We never query by or display this value, but the keychain requires it to be unique.
if mutableAttributes[kSecAttrAccount as String] == nil {
mutableAttributes[kSecAttrAccount as String] = UUID().uuidString as NSString
}
var result: AnyObject?
let resultCode: OSStatus = withUnsafeMutablePointer(to: &result) {
SecItemAdd(mutableAttributes as CFDictionary, $0)
}
guard resultCode == errSecSuccess else {
throw Keychain.Error.systemError(resultCode)
}
guard let persistentRef = result as? Data else {
throw Keychain.Error.incorrectReturnType
}
return persistentRef
}
private func updateKeychainItem(forPersistentRef persistentRef: Data,
withAttributes attributesToUpdate: [String: AnyObject]) throws {
let queryDict: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecValuePersistentRef as String: persistentRef as NSData,
]
let resultCode = SecItemUpdate(queryDict as CFDictionary, attributesToUpdate as CFDictionary)
guard resultCode == errSecSuccess else {
throw Keychain.Error.systemError(resultCode)
}
}
private func deleteKeychainItem(forPersistentRef persistentRef: Data) throws {
let queryDict: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecValuePersistentRef as String: persistentRef as NSData,
]
let resultCode = SecItemDelete(queryDict as CFDictionary)
guard resultCode == errSecSuccess else {
throw Keychain.Error.systemError(resultCode)
}
}
private func keychainItem(forPersistentRef persistentRef: Data) throws -> NSDictionary? {
let queryDict: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecValuePersistentRef as String: persistentRef as NSData,
kSecReturnPersistentRef as String: kCFBooleanTrue,
kSecReturnAttributes as String: kCFBooleanTrue,
kSecReturnData as String: kCFBooleanTrue,
]
var result: AnyObject?
let resultCode = withUnsafeMutablePointer(to: &result) {
SecItemCopyMatching(queryDict as CFDictionary, $0)
}
if resultCode == errSecItemNotFound {
// Not finding any keychain items is not an error in this case. Return nil.
return nil
}
guard resultCode == errSecSuccess else {
throw Keychain.Error.systemError(resultCode)
}
guard let keychainItem = result as? NSDictionary else {
throw Keychain.Error.incorrectReturnType
}
return keychainItem
}
private func allKeychainItems() throws -> [NSDictionary] {
let queryDict: [String: AnyObject] = [
kSecClass as String: kSecClassGenericPassword,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnPersistentRef as String: kCFBooleanTrue,
kSecReturnAttributes as String: kCFBooleanTrue,
kSecReturnData as String: kCFBooleanTrue,
]
var result: AnyObject?
let resultCode = withUnsafeMutablePointer(to: &result) {
SecItemCopyMatching(queryDict as CFDictionary, $0)
}
if resultCode == errSecItemNotFound {
// Not finding any keychain items is not an error in this case. Return an empty array.
return []
}
guard resultCode == errSecSuccess else {
throw Keychain.Error.systemError(resultCode)
}
guard let keychainItems = result as? [NSDictionary] else {
throw Keychain.Error.incorrectReturnType
}
return keychainItems
}