diff --git a/yobble/CoreData/PersistenceController.swift b/yobble/CoreData/PersistenceController.swift new file mode 100644 index 0000000..f9dd221 --- /dev/null +++ b/yobble/CoreData/PersistenceController.swift @@ -0,0 +1,101 @@ +import CoreData + +enum PersistenceControllerError: Error { + case encryptionKeyUnavailable + case persistentStoreMissing + case rekeyingUnavailable +} + +final class PersistenceController { + static let shared: PersistenceController = { + do { + return try PersistenceController() + } catch { + fatalError("Failed to initialize PersistenceController: \(error)") + } + }() + + let container: NSPersistentContainer + private let keyManager: DatabaseEncryptionKeyManager + + var viewContext: NSManagedObjectContext { + container.viewContext + } + + init( + inMemory: Bool = false, + keyManager: DatabaseEncryptionKeyManager = .shared, + fileManager: FileManager = .default + ) throws { + self.keyManager = keyManager + + let modelName = "YobbleDataModel" + guard let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd"), + let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else { + fatalError("Unable to load Core Data model \(modelName)") + } + + container = NSPersistentContainer(name: modelName, managedObjectModel: managedObjectModel) + + let description = container.persistentStoreDescriptions.first ?? NSPersistentStoreDescription() + description.type = NSSQLiteStoreType + + if inMemory { + description.url = URL(fileURLWithPath: "/dev/null") + } else { + description.url = try Self.makeStoreURL(fileManager: fileManager, fileName: "\(modelName).sqlite") + } + + description.shouldInferMappingModelAutomatically = true + description.shouldMigrateStoreAutomatically = true + + let key: String + do { + key = try keyManager.currentKey() + } catch { + throw PersistenceControllerError.encryptionKeyUnavailable + } + + let pragmas: [String: String] = [ + "journal_mode": "WAL", + "cipher_page_size": "4096", + "key": key + ] + description.setOption(pragmas as NSDictionary, forKey: NSSQLitePragmasOption) + description.setOption(FileProtectionType.completeUntilFirstUserAuthentication as NSObject, forKey: NSPersistentStoreFileProtectionKey) + + container.persistentStoreDescriptions = [description] + + container.loadPersistentStores { _, error in + if let error { + fatalError("Unresolved error \(error)") + } + } + + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + container.viewContext.automaticallyMergesChangesFromParent = true + } + + func newBackgroundContext() -> NSManagedObjectContext { + container.newBackgroundContext() + } + + /// Placeholder for a future rekey flow once a password-based key is available. + /// + /// On iOS 16 with SQLCipher you typically re-encrypt by running `PRAGMA rekey` via a raw + /// SQLite handle and then persisting the new key to the key manager. This helper keeps + /// the signature in place for when that flow is implemented. + func rekeyStore(to newKey: String) throws { + throw PersistenceControllerError.rekeyingUnavailable + } + + private static func makeStoreURL(fileManager: FileManager, fileName: String) throws -> URL { + guard let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + fatalError("Unable to resolve Application Support directory") + } + if !fileManager.fileExists(atPath: baseURL.path) { + try fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true) + } + return baseURL.appendingPathComponent(fileName) + } +} diff --git a/yobble/Services/DatabaseEncryptionKeyManager.swift b/yobble/Services/DatabaseEncryptionKeyManager.swift new file mode 100644 index 0000000..79ec37b --- /dev/null +++ b/yobble/Services/DatabaseEncryptionKeyManager.swift @@ -0,0 +1,45 @@ +import Foundation + +enum DatabaseEncryptionKeyError: Error { + case keyNotAvailable +} + +final class DatabaseEncryptionKeyManager { + static let shared = DatabaseEncryptionKeyManager() + + private let keychainService: KeychainService + private let serviceName = "yobble.database.encryption" + private let accountName = "sqlcipher_key" + /// Hardcoded dev key used until the user saves their own password-derived key. + private let fallbackKey: String + + init( + keychainService: KeychainService = .shared, + fallbackKey: String = AppConfig.DEFAULT_DATABASE_ENCRYPTION_KEY + ) { + self.keychainService = keychainService + self.fallbackKey = fallbackKey + } + + func currentKey() throws -> String { + if let key = keychainService.get(forKey: accountName, service: serviceName), !key.isEmpty { + return key + } + guard !fallbackKey.isEmpty else { + throw DatabaseEncryptionKeyError.keyNotAvailable + } + return fallbackKey + } + + func persistKey(_ key: String) { + keychainService.save(key, forKey: accountName, service: serviceName) + } + + func clearPersistedKey() { + keychainService.delete(forKey: accountName, service: serviceName) + } + + func hasPersistedKey() -> Bool { + keychainService.get(forKey: accountName, service: serviceName) != nil + } +} diff --git a/yobble/config.swift b/yobble/config.swift index 7e1338d..801b633 100644 --- a/yobble/config.swift +++ b/yobble/config.swift @@ -11,6 +11,10 @@ struct AppConfig { static let USER_AGENT = "yobble ios" static let APP_BUILD = "appstore" // appstore / freestore static let APP_VERSION = "0.1" + + static let DISABLE_DB = false + /// Fallback SQLCipher key used until the user sets an application password. + static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me" } struct AppInfo { diff --git a/yobble/yobbleApp.swift b/yobble/yobbleApp.swift index a72abf1..bd6f3a9 100644 --- a/yobble/yobbleApp.swift +++ b/yobble/yobbleApp.swift @@ -6,11 +6,13 @@ // import SwiftUI +import CoreData @main struct yobbleApp: App { @StateObject private var themeManager = ThemeManager() @StateObject private var viewModel = LoginViewModel() + private let persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { @@ -25,6 +27,7 @@ struct yobbleApp: App { } .environmentObject(themeManager) .preferredColorScheme(themeManager.theme.colorScheme) + .environment(\.managedObjectContext, persistenceController.viewContext) } } }