Pull to refresh

Swift. KeyChain. Property wrapper

Reading time5 min
Views3.8K
Xcode
Xcode

Некоторое время назад была опубликована статья на тему хранения данных пользователя. В этой статье было представлено решение, которое подходит для хранения общих данных, таких как настройки уведомлений, рассылки, возраста, имени и т.п. Но если требуется хранить важные или персональные данные, такие как ключ авторизации, пароль аккаунта, логин аккаунта и т.п., то для этих целей потребуется своего рода безопасное и защищенное хранилище. Далее речь пойдет о хранилище данных Keychain и способах его программирования.

Реализация

Сформируем минимальные необходимые требования к защищенному хранилищу.

  1. Возможность сохранить данные.

  2. Возможность загрузить данные.

  3. Возможность удалить данные.

  4. Данными могут быть как пароли или токены, так и аккаунты, т.е. комбинации логин-пароль.

Ядром для работы с хранилищем будет SecureStorage, который удовлетворяет первым 3 требованиям.

import Foundation
import Security

public final class SecureStorage {
	enum KeychainError: Error {
		case itemAlreadyExist
		case itemNotFound
		case errorStatus(String?)
		
		init(status: OSStatus) {
			switch status {
			case errSecDuplicateItem:
				self = .itemAlreadyExist
			case errSecItemNotFound:
				self = .itemNotFound
			default:
				let message = SecCopyErrorMessageString(status, nil) as String?
				self = .errorStatus(message)
			}
		}
	}
	
	func addItem(query: [CFString: Any]) throws {
		let status = SecItemAdd(query as CFDictionary, nil)
		
		if status != errSecSuccess {
			throw KeychainError(status: status)
		}
	}
	
	func findItem(query: [CFString: Any]) throws -> [CFString: Any]? {
		var query = query
		query[kSecReturnAttributes] = kCFBooleanTrue
		query[kSecReturnData] = kCFBooleanTrue
		
		var searchResult: AnyObject?
		
		let status = withUnsafeMutablePointer(to: &searchResult) {
			SecItemCopyMatching(query as CFDictionary, $0)
		}
		
		if status != errSecSuccess {
			throw KeychainError(status: status)
		} else {
			return searchResult as? [CFString: Any]
		}
	}
	
	func updateItem(query: [CFString: Any], attributesToUpdate: [CFString: Any]) throws {
		let status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
		
		if status != errSecSuccess {
			throw KeychainError(status: status)
		}
	}
	
	func deleteItem(query: [CFString: Any]) throws {
		let status = SecItemDelete(query as CFDictionary)
		
		if status != errSecSuccess {
			throw KeychainError(status: status)
		}
	}
}

Далее, аккаунты. Как сказано в документации, хранилище используется для безопасного хранения небольших объемов данных. Поэтому для аккаунтов требуется некоторая модель Credentials, с которой впоследствии будет происходить работа.

Естественно, для того чтобы использовать Credentials модель при вызове, потребуется набор новых методов, принимающих в качестве аргумента эту самую модель.

import Foundation

public extension SecureStorage {
	struct Credentials {
		public var login: String
		public var password: String
		
		public init(login: String, password: String) {
			self.login = login
			self.password = password
		}
	}
	
	func addCredentials(_ credentials: Credentials, with label: String) {
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrLabel] = label
		query[kSecAttrAccount] = credentials.login
		query[kSecValueData] = credentials.password.data(using: .utf8)
		
		do {
			try addItem(query: query)
		} catch {
			return
		}
	}
	
	func updateCredentials(_ credentials: Credentials, with label: String) {
		deleteCredentials(with: label)
		addCredentials(credentials, with: label)
	}
	
	func getCredentials(with label: String) -> Credentials? {
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrLabel] = label
		
		var result: [CFString: Any]?
		
		do {
			result = try findItem(query: query)
		} catch {
			return nil
		}
		
		if let account = result?[kSecAttrAccount] as? String,
		   let data = result?[kSecValueData] as? Data,
		   let password = String(data: data, encoding: .utf8) {
			return Credentials(login: account, password: password)
		} else {
			return nil
		}
	}
	
	func deleteCredentials(with label: String) {
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrLabel] = label
		
		do {
			try deleteItem(query: query)
		} catch {
			return
		}
	}
}

Работа с паролями несколько легче программируется, поскольку в этом случае хранится только пароль, а не комбинация логин-пароль, и в качестве типа данных используется String.

import Foundation

public extension SecureStorage {
	func addPassword(_ password: String, for account: String) {
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrAccount] = account
		query[kSecValueData] = password.data(using: .utf8)
		
		do {
			try addItem(query: query)
		} catch {
			return
		}
	}
	
	func updatePassword(_ password: String, for account: String) {
		guard let _ = getPassword(for: account) else {
			addPassword(password, for: account)
			return
		}
		
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrAccount] = account
		
		var attributesToUpdate: [CFString: Any] = [:]
		attributesToUpdate[kSecValueData] = password.data(using: .utf8)
		
		do {
			try updateItem(query: query, attributesToUpdate: attributesToUpdate)
		} catch {
			return
		}
	}
	
	func getPassword(for account: String) -> String? {
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrAccount] = account
		
		var result: [CFString: Any]?
		
		do {
			result = try findItem(query: query)
		} catch {
			return nil
		}
		
		if let data = result?[kSecValueData] as? Data {
			return String(data: data, encoding: .utf8)
		} else {
			return nil
		}
	}
	
	func deletePassword(for account: String) {
		var query: [CFString: Any] = [:]
		query[kSecClass] = kSecClassGenericPassword
		query[kSecAttrAccount] = account
		
		do {
			try deleteItem(query: query)
		} catch {
			return
		}
	}
}

Таким образом, для использования такого хранилища можно обновить computed property с get и set методами, но предлагается поступить иначе и вместо computed property использовать @propertyWrapper.

import SwiftUI

@propertyWrapper
public struct Credentials: DynamicProperty {
	private let label: String
	private let storage = SecureStorage()
	
	public init(_ label: String) {
		self.label = label
	}
	
	public var wrappedValue: SecureStorage.Credentials? {
		get { storage.getCredentials(with: label) }
		nonmutating set {
			if let newValue = newValue {
				storage.updateCredentials(newValue, with: label)
			} else {
				storage.deleteCredentials(with: label)
			}
		}
	}
}

@propertyWrapper
public struct Password: DynamicProperty {
	private let key: String
	private let storage = SecureStorage()
	
	public init(_ key: String) {
		self.key = key
	}
	
	public var wrappedValue: String? {
		get { storage.getPassword(for: key) }
		nonmutating set {
			if let newValue {
				storage.updatePassword(newValue, for: key)
			} else {
				storage.deletePassword(for: key)
			}
		}
	}
}

Обратить внимание нужно на ключевое слово nonmutating в сетере каждой структуры. Без этого при использовании предложенных property wrapper в SwiftUI компилятор бы выдавал ошибку "Cannot assign to property: 'self' is immutable". Также в угоду совместимости со SwiftUI была добавлена реализация протокола DynamicProperty.

В результате, класс, содержащий защищенные данные выглядит максимально просто, в то время как удовлетворяет поставленным требованиям.

final class SecureSettings {
	@Credentials("account")
	var account: SecureStorage.Credentials?
	
	@Credentials("subAccount")
	var subAccount: SecureStorage.Credentials?
	
	@Password("authToken")
	var authToken: String?
	
	@Password("refreshToken")
	var refreshToken: String?
	
	func clear() {
		account = nil
		subAccount = nil
		accessToken = nil
		refreshToken = nil
	}
}

Причем можно повторить подход, который используется в Apple - объявить общий экземпляр (singleton), либо передать объект в окружение (environmentObject), либо, создать экземпляр по необходимости.

Заключение

Хранение авторизационных данных - довольно частая задача, которая решается разработчиками, поскольку токен авторизации через определенное время утрачивает актуальность. Для того, чтобы пользовательский опыт был максимально положительным, нужно выполнять повторную авторизацию в "тихом" режиме, а не выбрасывать пользователя на экран ввода логина и пароля.

В любом случае, это один из наиболее распространенных вариантов использования защищенного хранилища. В качестве другого примера можно привести хранение комбинации authToken-refreshToken. Но об этом как-нибудь в другой раз.

  1. Оригинал статьи

  2. Keychain Services

  3. Security

Tags:
Hubs:
+1
Comments1

Articles