From c030400b7724a1525936ac54a0c2589aed10e38f Mon Sep 17 00:00:00 2001 From: Goncalo-FradeIOHK Date: Mon, 14 Nov 2022 16:21:39 +0000 Subject: [PATCH] feat(pluto): add core data and keychain foundation code --- Core/Sources/Base64Utils.swift | 20 ++ .../CoreData/CoreDataDAO+Combine.swift | 97 +++++++ .../Helpers/CoreData/CoreDataDAO.swift | 69 +++++ .../Helpers/CoreData/CoreDataManager.swift | 155 +++++++++++ .../NSFetchedResultsControllerPublisher.swift | 121 +++++++++ .../NSManagedObjectContext+Combine.swift | 77 ++++++ .../NSManagedObjectContext+Save.swift | 19 ++ .../Helpers/Keychain/KeychainStorage.swift | 37 +++ .../Keychain/KeychainStorageImpl.swift | 252 ++++++++++++++++++ Pluto/Sources/Pluto.swift | 37 ++- 10 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 Core/Sources/Base64Utils.swift create mode 100644 Pluto/Sources/Helpers/CoreData/CoreDataDAO+Combine.swift create mode 100644 Pluto/Sources/Helpers/CoreData/CoreDataDAO.swift create mode 100644 Pluto/Sources/Helpers/CoreData/CoreDataManager.swift create mode 100644 Pluto/Sources/Helpers/CoreData/NSFetchedResultsControllerPublisher.swift create mode 100644 Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Combine.swift create mode 100644 Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Save.swift create mode 100644 Pluto/Sources/Helpers/Keychain/KeychainStorage.swift create mode 100644 Pluto/Sources/Helpers/Keychain/KeychainStorageImpl.swift diff --git a/Core/Sources/Base64Utils.swift b/Core/Sources/Base64Utils.swift new file mode 100644 index 00000000..4ed9d72e --- /dev/null +++ b/Core/Sources/Base64Utils.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct Base64Utils { + public init() {} + + public func encode(_ data: Data) -> String { + String(data.base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + .dropLast(1)) + } + + public func decode(_ src: String) -> Data? { + let base64Encoded = src + .replacingOccurrences(of: "_", with: "/") + .replacingOccurrences(of: "-", with: "+") + + "=" + return Data(base64Encoded: base64Encoded) + } +} diff --git a/Pluto/Sources/Helpers/CoreData/CoreDataDAO+Combine.swift b/Pluto/Sources/Helpers/CoreData/CoreDataDAO+Combine.swift new file mode 100644 index 00000000..87a758b5 --- /dev/null +++ b/Pluto/Sources/Helpers/CoreData/CoreDataDAO+Combine.swift @@ -0,0 +1,97 @@ +import Combine +import CoreData +import Foundation + +extension CoreDataDAO { + func fetchController( + predicate: NSPredicate? = nil, + sorting: NSSortDescriptor? = nil, + fetchLimit: Int? = nil, + context: NSManagedObjectContext + ) -> AnyPublisher<[CoreDataObject], Error> { + let request = CoreDataObject.fetchRequest() + request.predicate = predicate + request.sortDescriptors = sorting.map { [$0] } ?? [NSSortDescriptor(key: identifierKey, ascending: true)] + fetchLimit.map { request.fetchLimit = $0 } + + return context + .fetchPublisher(request: request) + .map { $0 as? [CoreDataObject] ?? [] } + .map { + let unique = Set($0) + return Array(unique) + } + .eraseToAnyPublisher() + } + + func fetchByKeyValuePublisher( + key: String, + value: CustomStringConvertible, + context: NSManagedObjectContext + ) -> AnyPublisher<[CoreDataObject], Error> { + return fetchController( + predicate: NSPredicate(format: "%K == %@", key, "\(value)"), + context: context + ) + .eraseToAnyPublisher() + } + + func deletePublisher(predicate: NSPredicate? = nil, context: NSManagedObjectContext) -> AnyPublisher { + return context.write { + try delete(predicate: predicate, context: $0) + } + .eraseToAnyPublisher() + } + + func deleteAllPublisher(context: NSManagedObjectContext) -> AnyPublisher { + return context.write { + try deleteAll(context: $0) + } + .eraseToAnyPublisher() + } +} + +extension CoreDataDAO where CoreDataObject: Identifiable { + func updateOrCreate( + _ id: CoreDataObject.ID, + context: NSManagedObjectContext, + modify: @escaping (CoreDataObject, NSManagedObjectContext) -> Void + ) -> AnyPublisher { + context.write { context in + modify(self.fetchByID(id, context: context) ?? self.newEntity(context: context), context) + return id + } + .eraseToAnyPublisher() + } + + func fetchByIDsPublisher( + _ identifier: CoreDataObject.ID, + context: NSManagedObjectContext + ) -> AnyPublisher { + guard let key = identifierKey else { + assertionFailure(""" + The identityKey is nil, please set up the identityKey when you have an Identifiable object + """) + return Just(nil).tryMap { $0 }.eraseToAnyPublisher() + } + return fetchController( + predicate: NSPredicate(format: "%K == %@", key, "\(identifier)"), + context: context + ) + .map { $0.first } + .eraseToAnyPublisher() + } + + func deleteByIDsPublisher( + _ identifiers: [CoreDataObject.ID], + context: NSManagedObjectContext + ) -> AnyPublisher { + guard let key = identifierKey else { + assertionFailure(""" + The identityKey is nil, please set up the identityKey when you have an Identifiable object + """) + return Just(()).tryMap { $0 }.eraseToAnyPublisher() + } + return deletePublisher(predicate: NSPredicate(format: "\(key) IN %@", identifiers), context: context) + } +} diff --git a/Pluto/Sources/Helpers/CoreData/CoreDataDAO.swift b/Pluto/Sources/Helpers/CoreData/CoreDataDAO.swift new file mode 100644 index 00000000..02aaa4ee --- /dev/null +++ b/Pluto/Sources/Helpers/CoreData/CoreDataDAO.swift @@ -0,0 +1,69 @@ +import CoreData + +protocol CoreDataDAO { + associatedtype CoreDataObject: NSManagedObject + var identifierKey: String? { get } +} + +extension CoreDataDAO { + func newEntity(context: NSManagedObjectContext) -> CoreDataObject { + CoreDataObject(entity: CoreDataObject.entity(), insertInto: context) + } + + func fetch( + predicate: NSPredicate? = nil, + sorting: NSSortDescriptor? = nil, + fetchLimit: Int? = nil, + context: NSManagedObjectContext + ) -> Set { + let request = CoreDataObject.fetchRequest() + request.predicate = predicate + fetchLimit.map { request.fetchLimit = $0 } + sorting.map { request.sortDescriptors = [$0] } + let items = (try? context.fetch(request) as? [CoreDataObject]) ?? [] + return Set(items) + } + + func delete(predicate: NSPredicate? = nil, context: NSManagedObjectContext) throws { + let fetchRequest = CoreDataObject.fetchRequest() + fetchRequest.predicate = predicate + let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + batchDeleteRequest.resultType = .resultTypeObjectIDs + guard + let result = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult, + let objects = result.result as? [NSManagedObjectID] + else { + return + } + let changes: [AnyHashable: Any] = [ + NSDeletedObjectsKey: objects + ] + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) + } + + func deleteAll(context: NSManagedObjectContext) throws { + try delete(predicate: nil, context: context) + } +} + +extension CoreDataDAO where CoreDataObject: Identifiable { + func fetchByID(_ identifier: CoreDataObject.ID, context: NSManagedObjectContext) -> CoreDataObject? { + guard let key = identifierKey else { + assertionFailure(""" + The identityKey is nil, please set up the identityKey when you have an Identifiable object + """) + return nil + } + return fetch(predicate: NSPredicate(format: "%K == %@", key, "\(identifier)"), context: context).first + } + + func deleteByID(_ identifier: CoreDataObject.ID, context: NSManagedObjectContext) throws { + guard let key = identifierKey else { + assertionFailure(""" + The identityKey is nil, please set up the identityKey when you have an Identifiable object + """) + return + } + try delete(predicate: NSPredicate(format: "%K == %@", key, "\(identifier)"), context: context) + } +} diff --git a/Pluto/Sources/Helpers/CoreData/CoreDataManager.swift b/Pluto/Sources/Helpers/CoreData/CoreDataManager.swift new file mode 100644 index 00000000..ef325013 --- /dev/null +++ b/Pluto/Sources/Helpers/CoreData/CoreDataManager.swift @@ -0,0 +1,155 @@ +import CoreData +import Foundation + +public final class CoreDataManager { + lazy var editContext: NSManagedObjectContext = persistentContainer.newBackgroundContext() + + lazy var mainContext: NSManagedObjectContext = { + func mergeChanges(context: NSManagedObjectContext, notification: Notification) { + guard + let contextSaved = notification.object as? NSManagedObjectContext, + self.editContext == contextSaved + else { return } + context.perform { + context.mergeChanges(fromContextDidSave: notification) + } + } + + let context = persistentContainer.viewContext + context.automaticallyMergesChangesFromParent = true + + NotificationCenter.default.addObserver( + forName: .NSManagedObjectContextDidMergeChangesObjectIDs, + object: nil, + queue: nil + ) { [weak context] notification in + guard let context = context else { return } + mergeChanges(context: context, notification: notification) + } + + return context + }() + + private let setup: CoreDataSetup + + private static var _model: NSManagedObjectModel? + + private(set) lazy var model: NSManagedObjectModel = { + if let mdl = CoreDataManager._model { return mdl } + let modelPath: URL + switch setup.modelPath { + case let .storeName(name): + guard let modelURL = Bundle(for: type(of: self)).url(forResource: name, withExtension: "momd") else { + fatalError("Unable to Find Data Model") + } + modelPath = modelURL + case let .storeURL(modelURL): + modelPath = modelURL + } + + guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelPath) else { + fatalError("Unable to Load Data Model") + } + CoreDataManager._model = managedObjectModel + return managedObjectModel + }() + + private(set) lazy var persistentContainer: NSPersistentContainer = { + let persistentContainer = NSPersistentContainer(name: self.setup.modelName, managedObjectModel: model) + if case CoreDataSetup.StoreType.memory = setup.storeType { + let persistentStoreDescription = NSPersistentStoreDescription() + persistentStoreDescription.type = NSInMemoryStoreType + persistentContainer.persistentStoreDescriptions = [persistentStoreDescription] + } + persistentContainer.loadPersistentStores { [weak persistentContainer] _, _ in + persistentContainer?.viewContext.automaticallyMergesChangesFromParent = true + } + return persistentContainer + }() + + private(set) lazy var defaultManagedObjectContext: NSManagedObjectContext = self.persistentContainer.viewContext + + public init(setup: CoreDataSetup) { + self.setup = setup + } + + public func start() { + persistentContainer.loadPersistentStores { _, error in + error.map { assertionFailure($0.localizedDescription) } + } + } + + public func delete() throws { + try persistentContainer + .persistentStoreCoordinator + .destroyPersistentStore( + at: persistentStoreUrl(), + ofType: "sqlite", + options: nil + ) + start() + } + + private func makeNewContext( + concurrencyType: NSManagedObjectContextConcurrencyType + ) -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: concurrencyType) + context.parent = defaultManagedObjectContext + context.automaticallyMergesChangesFromParent = true + return context + } + + private func persistentStoreUrl() -> URL { + let modelName: String + switch setup.modelPath { + case let .storeName(value): + modelName = value + case let .storeURL(value): + modelName = value.deletingPathExtension().lastPathComponent + } + let url = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("\(modelName).sqlite") + + assert(FileManager.default.fileExists(atPath: url.path)) + + return url + } +} + +public extension CoreDataManager { + struct CoreDataSetup { + public enum StoreType { + case memory + case persistent + } + + public enum ModelPath { + case storeName(String) + case storeURL(URL) + } + + // swiftlint:disable lower_acl_than_parent + + public let modelPath: ModelPath + public let storeType: StoreType + + public init(modelPath: ModelPath, storeType: StoreType) { + self.modelPath = modelPath + self.storeType = storeType + } + + // swiftlint:enable lower_acl_than_parent + } +} + +private extension CoreDataManager.CoreDataSetup { + var modelName: String { + switch modelPath { + case let .storeName(name): + return name + case let .storeURL(url): + return url.deletingPathExtension().lastPathComponent + } + } +} diff --git a/Pluto/Sources/Helpers/CoreData/NSFetchedResultsControllerPublisher.swift b/Pluto/Sources/Helpers/CoreData/NSFetchedResultsControllerPublisher.swift new file mode 100644 index 00000000..da5a9446 --- /dev/null +++ b/Pluto/Sources/Helpers/CoreData/NSFetchedResultsControllerPublisher.swift @@ -0,0 +1,121 @@ +import Combine +import CoreData +import Foundation + +private class FetchedResultsSubscriber: + NSObject, Subscription, NSFetchedResultsControllerDelegate + where S.Input == [NSFetchRequestResult], S.Failure == Error +{ + private let context: NSManagedObjectContext + private let request: NSFetchRequest + private let controller: NSFetchedResultsController + private var cache: [NSFetchRequestResult] = [] + private var didFetch = false + private var subscriber: S? + private var error: Error? + + init(context: NSManagedObjectContext, request: NSFetchRequest, subscriber: S?) { + self.context = context + self.request = request + self.subscriber = subscriber + controller = NSFetchedResultsController( + fetchRequest: self.request, + managedObjectContext: self.context, + sectionNameKeyPath: nil, + cacheName: nil + ) + super.init() + controller.delegate = self + } + + func request(_ demand: Subscribers.Demand) { + if let subscriber = subscriber, !didFetch { + fetch() + didFetch = true + if let error = error { + subscriber.receive(completion: .failure(error)) + } else { + let cache = self.cache + context.performAndWait { + _ = subscriber.receive(cache) + } + } + } + } + + func cancel() { + subscriber = nil + } + + // TODO: Look why the FetchResultController fetch and the ManageObjectContext save is sometimes blocks. + private func fetch() { + context.performAndWait { [weak self] in + guard let self = self else { return } + do { + try self.controller.performFetch() + + self.cache = self.controller.fetchedObjects ?? [] + } catch { + self.error = error + } + } + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let objects = controller.fetchedObjects as? [NSManagedObject] else { return } + cache = objects + if let error = error { + subscriber?.receive(completion: .failure(error)) + } else { + _ = subscriber?.receive(cache) + } + } + + func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { + guard let objects = controller.fetchedObjects as? [NSManagedObject] else { return } + cache = objects + if let error = error { + subscriber?.receive(completion: .failure(error)) + } else { + _ = subscriber?.receive(cache) + } + } +} + +extension Publishers { + struct FetchedResultsPublisher: Publisher { + typealias Output = [NSFetchRequestResult] + typealias Failure = Error + + private let context: NSManagedObjectContext + private let request: NSFetchRequest + + init(context: NSManagedObjectContext, request: NSFetchRequest) { + self.request = request + self.context = context + } + + func receive(subscriber: S) where + FetchedResultsPublisher.Failure == S.Failure, FetchedResultsPublisher.Output == S.Input + { + let subscription = FetchedResultsSubscriber( + context: context, + request: request, + subscriber: subscriber + ) + subscriber.receive(subscription: subscription) + } + } +} + +extension NSManagedObjectContext { + func fetchPublisher(request: NSFetchRequest) -> Publishers.FetchedResultsPublisher { + return Publishers.FetchedResultsPublisher(context: self, request: request) + } +} diff --git a/Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Combine.swift b/Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Combine.swift new file mode 100644 index 00000000..d151bc44 --- /dev/null +++ b/Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Combine.swift @@ -0,0 +1,77 @@ +import Combine +import CoreData +import Foundation + +private class WriteContextSubscriber: NSObject, Subscription where S.Input == T, S.Failure == Error { + private let context: NSManagedObjectContext + private let saveBlock: (NSManagedObjectContext) throws -> T + private var subscriber: S? + private var isRunning = false + + init( + context: NSManagedObjectContext, + subscriber: S?, + saveBlock: @escaping (NSManagedObjectContext) throws -> T + ) { + self.saveBlock = saveBlock + self.context = context + self.subscriber = subscriber + super.init() + } + + func request(_ demand: Subscribers.Demand) { + while let subscriber = subscriber, demand > 0, isRunning == false { + perform(subscriber) + } + } + + func cancel() { + subscriber = nil + } + + private func perform(_ subscriber: S) { + isRunning = true + do { + var result: T? + try context.saveWithBlock { + result = try self.saveBlock($0) + } + result.map { _ = subscriber.receive($0) } + subscriber.receive(completion: .finished) + } catch { + subscriber.receive(completion: .failure(error)) + } + } +} + +extension Publishers { + struct WriteContextPublisher: Publisher { + typealias Output = T + typealias Failure = Error + + private let context: NSManagedObjectContext + private let saveBlock: (NSManagedObjectContext) throws -> T + + init(context: NSManagedObjectContext, saveBlock: @escaping (NSManagedObjectContext) throws -> T) { + self.saveBlock = saveBlock + self.context = context + } + + func receive( + subscriber: S + ) where WriteContextPublisher.Failure == S.Failure, WriteContextPublisher.Output == S.Input { + let subscription = WriteContextSubscriber( + context: context, + subscriber: subscriber, + saveBlock: saveBlock + ) + subscriber.receive(subscription: subscription) + } + } +} + +extension NSManagedObjectContext { + func write(_ saveBlock: @escaping (NSManagedObjectContext) throws -> T) -> Publishers.WriteContextPublisher { + return Publishers.WriteContextPublisher(context: self, saveBlock: saveBlock) + } +} diff --git a/Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Save.swift b/Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Save.swift new file mode 100644 index 00000000..c3a08460 --- /dev/null +++ b/Pluto/Sources/Helpers/CoreData/NSManagedObjectContext+Save.swift @@ -0,0 +1,19 @@ +import CoreData + +extension NSManagedObjectContext { + func saveWithBlock( + block: @escaping ((_ localContext: NSManagedObjectContext) throws -> Void) + ) throws { + var lastError: Error? + performAndWait { [weak self] in + do { + guard let self = self else { return } + try block(self) + try self.save() + } catch { + lastError = error + } + } + if let error = lastError { throw error } + } +} diff --git a/Pluto/Sources/Helpers/Keychain/KeychainStorage.swift b/Pluto/Sources/Helpers/Keychain/KeychainStorage.swift new file mode 100644 index 00000000..3efeb469 --- /dev/null +++ b/Pluto/Sources/Helpers/Keychain/KeychainStorage.swift @@ -0,0 +1,37 @@ +import Foundation + +enum KeychainWrapperError: Error { + case serviceError(status: OSStatus) + case wrongType + case itemNotFound + case itemDuplicated + case pwAccessCreationError + case authFailed +} + +enum SecurityTypeDomain { + case none + case password(String) + case biometric +} + +protocol KeychainStorage { + func set(_ value: Data, forKey key: String) throws + func set( + _ value: Data, + forKey key: String, + security: SecurityTypeDomain + ) throws + + func get(key: String) throws -> Data + func get( + key: String, + security: SecurityTypeDomain + ) throws -> Data + + func delete(key: String) throws + func delete( + key: String, + security: SecurityTypeDomain + ) throws +} diff --git a/Pluto/Sources/Helpers/Keychain/KeychainStorageImpl.swift b/Pluto/Sources/Helpers/Keychain/KeychainStorageImpl.swift new file mode 100644 index 00000000..164629bf --- /dev/null +++ b/Pluto/Sources/Helpers/Keychain/KeychainStorageImpl.swift @@ -0,0 +1,252 @@ +import Foundation +import LocalAuthentication + +struct KeychainStorageImpl: KeychainStorage { + let service: String + let accessGroup: String? + + func set(_ value: Data, forKey key: String) throws { + guard + let encodedIdentifier = key.data(using: String.Encoding.utf8) + else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrGeneric as String: encodedIdentifier, + kSecAttrAccount as String: encodedIdentifier, + kSecAttrService as String: service + ] + + let attributes: [String: Any] = [ + kSecValueData as String: value + ] + + do { + try createItem(query: query.merging(attributes, uniquingKeysWith: { _, new in + new + })) + } catch KeychainWrapperError.itemDuplicated { + try updateItem(query: query, attributes: attributes) + } catch { + throw error + } + } + + func set( + _ value: Data, + forKey key: String, + security: SecurityTypeDomain + ) throws { + guard + let encodedIdentifier = key.data(using: String.Encoding.utf8) + else { return } + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrGeneric as String: encodedIdentifier, + kSecAttrAccount as String: encodedIdentifier, + kSecAttrService as String: service + ] + + switch security { + case .none: + break + case let .password(password): + let context = LAContext() + context.setCredential(password.data(using: .utf8), type: .applicationPassword) + query[kSecAttrAccessControl as String] = try getPwSecAccessControl() + query[kSecUseAuthenticationContext as String] = context + case .biometric: + query[kSecAttrAccessControl as String] = try getBiometricsSecAccessControl() + } + + let attributes: [String: Any] = [ + kSecValueData as String: value + ] + + do { + try createItem(query: query.merging(attributes, uniquingKeysWith: { _, new in + new + })) + } catch KeychainWrapperError.itemDuplicated { + try updateItem(query: query, attributes: attributes) + } catch { + print(error.localizedDescription) + throw error + } + } + + func get(key: String) throws -> Data { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + switch status { + case errSecSuccess: + guard + let existingItem = item as? [String: Any], + let value = existingItem[kSecValueData as String] as? Data + else { throw KeychainWrapperError.wrongType } + + return value + case errSecItemNotFound: + throw KeychainWrapperError.itemNotFound + case errSecAuthFailed: + throw KeychainWrapperError.authFailed + default: + throw KeychainWrapperError.serviceError(status: status) + } + } + + func get( + key: String, + security: SecurityTypeDomain + ) throws -> Data { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + + switch security { + case .none: + break + case .biometric: + query[kSecAttrAccessControl as String] = try getBiometricsSecAccessControl() + case let .password(password): + let context = LAContext() + context.setCredential(password.data(using: .utf8), type: .applicationPassword) + query[kSecAttrAccessControl as String] = try getPwSecAccessControl() + query[kSecUseAuthenticationContext as String] = context + } + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + switch status { + case errSecSuccess: + guard + let existingItem = item as? [String: Any], + let value = existingItem[kSecValueData as String] as? Data + else { throw KeychainWrapperError.wrongType } + + return value + case errSecItemNotFound: + throw KeychainWrapperError.itemNotFound + case errSecAuthFailed: + throw KeychainWrapperError.authFailed + default: + throw KeychainWrapperError.serviceError(status: status) + } + } + + func delete(key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainWrapperError.serviceError(status: status) + } + } + + func delete(key: String, security: SecurityTypeDomain) throws { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecAttrService as String: service + ] + + switch security { + case .none: + break + case let .password(password): + let context = LAContext() + context.setCredential(password.data(using: .utf8), type: .applicationPassword) + query[kSecAttrAccessControl as String] = try getPwSecAccessControl() + query[kSecUseAuthenticationContext as String] = context + case .biometric: + query[kSecAttrAccessControl as String] = try getBiometricsSecAccessControl() + } + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainWrapperError.serviceError(status: status) + } + } + + private func createItem(query: [String: Any]) throws { + let status = SecItemAdd(query as CFDictionary, nil) + switch status { + case errSecSuccess: + break + case errSecDuplicateItem: + throw KeychainWrapperError.itemDuplicated + case errSecAuthFailed: + throw KeychainWrapperError.authFailed + case errSecParam: + throw KeychainWrapperError.serviceError(status: status) + default: + throw KeychainWrapperError.serviceError(status: status) + } + } + + private func updateItem(query: [String: Any], attributes: [String: Any]) throws { + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + switch status { + case errSecSuccess: + return + case errSecItemNotFound: + throw KeychainWrapperError.itemNotFound + case errSecAuthFailed: + throw KeychainWrapperError.authFailed + default: + throw KeychainWrapperError.serviceError(status: status) + } + } + + private func getPwSecAccessControl() throws -> SecAccessControl { + var error: Unmanaged? + guard + let access = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + [.applicationPassword], + &error + ) + else { + throw KeychainWrapperError.pwAccessCreationError + } + + return access + } + + private func getBiometricsSecAccessControl() throws -> SecAccessControl { + var error: Unmanaged? + guard + let access = SecAccessControlCreateWithFlags( + nil, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + [.userPresence], + &error + ) + else { + throw KeychainWrapperError.pwAccessCreationError + } + + return access + } +} diff --git a/Pluto/Sources/Pluto.swift b/Pluto/Sources/Pluto.swift index 1a0a45df..0681fd0f 100644 --- a/Pluto/Sources/Pluto.swift +++ b/Pluto/Sources/Pluto.swift @@ -1 +1,36 @@ -// This file is just here as a foolproof/template since for Swift Packages a module source always have to have at least one Swift file +public struct PlutoImpl { + public struct PlutoSetup { + public struct KeychainSetup { + public let service: String + public let accessGroup: String? + + public init( + service: String = "com.atala.prism.session", + accessGroup: String? = nil + ) { + self.service = service + self.accessGroup = accessGroup + } + } + + public let keychainSetup: KeychainSetup + public let coreDataSetup: CoreDataManager.CoreDataSetup + + public init( + keychainSetup: KeychainSetup, + coreDataSetup: CoreDataManager.CoreDataSetup = .init( + modelPath: .storeName("com.atala.prism.storage"), + storeType: .persistent + ) + ) { + self.keychainSetup = keychainSetup + self.coreDataSetup = coreDataSetup + } + } + + let setup: PlutoSetup + + public init(setup: PlutoSetup = .init(keychainSetup: .init())) { + self.setup = setup + } +}