diff --git a/apps/mobile-app/app/sync.tsx b/apps/mobile-app/app/sync.tsx index 4ed4e6844..592df29c5 100644 --- a/apps/mobile-app/app/sync.tsx +++ b/apps/mobile-app/app/sync.tsx @@ -53,8 +53,8 @@ export default function SyncScreen() : React.ReactNode { // Try to unlock with FaceID try { - const hasStoredVault = await NativeVaultManager.hasStoredVault(); - if (hasStoredVault) { + const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); + if (hasEncryptedDatabase) { const isFaceIDEnabled = enabledAuthMethods.includes('faceid'); if (!isFaceIDEnabled) { router.replace('/unlock'); diff --git a/apps/mobile-app/context/DbContext.tsx b/apps/mobile-app/context/DbContext.tsx index 21394ad71..83ae63e3f 100644 --- a/apps/mobile-app/context/DbContext.tsx +++ b/apps/mobile-app/context/DbContext.tsx @@ -79,8 +79,8 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } const checkStoredVault = useCallback(async () => { try { - const hasStoredVault = await NativeVaultManager.hasStoredVault(); - if (hasStoredVault) { + const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase(); + if (hasEncryptedDatabase) { // Get metadata from SQLite client const metadata = await sqliteClient.getVaultMetadata(); if (metadata) { diff --git a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj index 64aed13dd..b78acf552 100644 --- a/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj +++ b/apps/mobile-app/ios/AliasVault.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -190,7 +190,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -200,11 +200,62 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKit; sourceTree = ""; }; - CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = ""; }; - CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = ""; }; - CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = ""; }; - CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = ""; }; + CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultStoreKit; + sourceTree = ""; + }; + CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultStoreKitTests; + sourceTree = ""; + }; + CEE4816B2DBE8AC800F4A367 /* VaultUI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultUI; + sourceTree = ""; + }; + CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = VaultModels; + sourceTree = ""; + }; + CEE909812DA548C7008D568F /* Autofill */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Autofill; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1130,7 +1181,10 @@ LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -1185,7 +1239,10 @@ ); LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift index 0e9c36360..b1d6ab953 100644 --- a/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift +++ b/apps/mobile-app/ios/Autofill/CredentialProviderViewController.swift @@ -20,17 +20,17 @@ import VaultModels * us to provide credentials to native system operations that request credentials (e.g. suggesting * logins in the keyboard). */ -class CredentialProviderViewController: ASCredentialProviderViewController { +public class CredentialProviderViewController: ASCredentialProviderViewController { private var viewModel: CredentialProviderViewModel? private var isChoosingTextToInsert = false - override func viewDidLoad() { + override public func viewDidLoad() { super.viewDidLoad() // Check if there is a stored vault. If not, it means the user has not logged in yet and we // should redirect to the main app login screen automatically. let vaultStore = VaultStore() - if !vaultStore.hasStoredVault() { + if !vaultStore.hasEncryptedDatabase { let alert = UIAlertController( title: "Login Required", message: "To use Autofill, please login to your AliasVault account in the AliasVault app.", @@ -135,7 +135,7 @@ class CredentialProviderViewController: ASCredentialProviderViewController { hostingController.didMove(toParent: self) } - override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { + override public func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { guard let viewModel = self.viewModel else { return } let matchedDomains = serviceIdentifiers.map { $0.identifier.lowercased() } @@ -151,12 +151,12 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } } - override func prepareInterfaceForUserChoosingTextToInsert() { + override public func prepareInterfaceForUserChoosingTextToInsert() { isChoosingTextToInsert = true viewModel?.isChoosingTextToInsert = true } - override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { + override public func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { do { let vaultStore = VaultStore() let credentials = try vaultStore.getAllCredentials() diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index e8c70ea9d..9cd152572 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -80,8 +80,8 @@ [vaultManager getVaultMetadata:resolve rejecter:reject]; } -- (void)hasStoredVault:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - [vaultManager hasStoredVault:resolve rejecter:reject]; +- (void)hasEncryptedDatabase:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager hasEncryptedDatabase:resolve rejecter:reject]; } - (void)isVaultUnlocked:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index f8d039f3c..8e884a591 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -194,10 +194,10 @@ public class VaultManager: NSObject { } @objc - func hasStoredVault(_ resolve: @escaping RCTPromiseResolveBlock, + func hasEncryptedDatabase(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { do { - let isInitialized = try vaultStore.hasStoredVault() + let isInitialized = try vaultStore.hasEncryptedDatabase resolve(isInitialized) } catch { reject("VAULT_ERROR", "Failed to check vault initialization: \(error.localizedDescription)", error) @@ -207,7 +207,7 @@ public class VaultManager: NSObject { @objc func isVaultUnlocked(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { - let isUnlocked = try vaultStore.isVaultUnlocked() + let isUnlocked = try vaultStore.isVaultUnlocked resolve(isUnlocked) } diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultConstants.swift b/apps/mobile-app/ios/VaultStoreKit/VaultConstants.swift new file mode 100644 index 000000000..edadf37ae --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultConstants.swift @@ -0,0 +1,18 @@ +import Foundation +import VaultModels + +struct VaultConstants { + static let keychainService = "net.aliasvault.autofill" + static let keychainAccessGroup = "group.net.aliasvault.autofill" + static let userDefaultsSuite = "group.net.aliasvault.autofill" + + static let vaultMetadataKey = "aliasvault_vault_metadata" + static let encryptionKeyKey = "aliasvault_encryption_key" + static let encryptedDbFileName = "encrypted_db.sqlite" + static let authMethodsKey = "aliasvault_auth_methods" + static let autoLockTimeoutKey = "aliasvault_auto_lock_timeout" + + static let defaultAutoLockTimeout: Int = 3600 // 1 hour in seconds + static let defaultAuthMethods: AuthMethods = [.password, .faceID] +} + diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift new file mode 100644 index 000000000..96c4f34fe --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift @@ -0,0 +1,46 @@ +import Foundation +import LocalAuthentication +import KeychainAccess +import VaultModels + +/// Extension for the VaultStore class to handle authentication methods +extension VaultStore { + /// Set the enabled authentication methods for the vault + public func setAuthMethods(_ methods: AuthMethods) throws { + enabledAuthMethods = methods + userDefaults.set(methods.rawValue, forKey: VaultConstants.authMethodsKey) + userDefaults.synchronize() + + if !enabledAuthMethods.contains(.faceID) { + print("Face ID is now disabled, removing key from keychain immediately") + do { + try keychain + .authenticationPrompt("Authenticate to remove your vault decryption key") + .remove(VaultConstants.encryptionKeyKey) + print("Successfully removed encryption key from keychain") + } catch { + print("Failed to remove encryption key from keychain: \(error)") + throw error + } + } else { + print("Face ID is now enabled, next time user logs in the key will be persisted in keychain") + } + } + + /// Get the enabled authentication methods for the vault + public func getAuthMethods() -> AuthMethods { + return enabledAuthMethods + } + + /// Get the enabled authentication methods for the vault as strings + public func getAuthMethodsAsStrings() -> [String] { + var methods: [String] = [] + if enabledAuthMethods.contains(.faceID) { + methods.append("faceid") + } + if enabledAuthMethods.contains(.password) { + methods.append("password") + } + return methods + } +} diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift new file mode 100644 index 000000000..b560fe4b6 --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Extension for the VaultStore class to handle cache management +extension VaultStore { + /// Clear the memory - remove the encryption key and decrypted database from memory + public func clearCache() { + print("Clearing cache - removing encryption key and decrypted database from memory") + encryptionKey = nil + dbConnection = nil + } + + /// Clear the vault storage - remove the encryption key and encrypted database from the device + public func clearVault() { + print("Clearing vault - removing all stored data") + + do { + try keychain + .authenticationPrompt("Authenticate to remove your vault decryption key") + .remove(VaultConstants.encryptionKeyKey) + print("Successfully removed encryption key from keychain") + } catch { + print("Failed to remove encryption key from keychain: \(error)") + } + + do { + try removeEncryptedDatabase() + print("Successfully removed encrypted database file") + } catch { + print("Failed to remove encrypted database file: \(error)") + } + + userDefaults.removeObject(forKey: VaultConstants.vaultMetadataKey) + userDefaults.removeObject(forKey: VaultConstants.authMethodsKey) + userDefaults.removeObject(forKey: VaultConstants.autoLockTimeoutKey) + userDefaults.synchronize() + print("Cleared UserDefaults") + + clearCache() + } + + /// Set the auto-lock timeout - the number of seconds after which the vault will be locked automatically + public func setAutoLockTimeout(_ timeout: Int) { + print("Setting auto-lock timeout to \(timeout) seconds") + autoLockTimeout = timeout + userDefaults.set(timeout, forKey: VaultConstants.autoLockTimeoutKey) + userDefaults.synchronize() + } + + /// Get the auto-lock timeout - the number of seconds after which the vault will be locked automatically + public func getAutoLockTimeout() -> Int { + return autoLockTimeout + } +} \ No newline at end of file diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift new file mode 100644 index 000000000..8dc91bda2 --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift @@ -0,0 +1,99 @@ +import Foundation +import CryptoKit +import LocalAuthentication +import KeychainAccess + +/// Extension for the VaultStore class to handle encryption/decryption +extension VaultStore { + /// Store the encryption key - the key used to encrypt and decrypt the vault + public func storeEncryptionKey(base64Key: String) throws { + guard let keyData = Data(base64Encoded: base64Key) else { + throw NSError(domain: "VaultStore", code: 6, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 key"]) + } + + guard keyData.count == 32 else { + throw NSError(domain: "VaultStore", code: 7, userInfo: [NSLocalizedDescriptionKey: "Invalid key length. Expected 32 bytes"]) + } + + encryptionKey = keyData + print("Stored key in memory") + + if enabledAuthMethods.contains(.faceID) { + print("Face ID is enabled, storing key in keychain") + do { + try keychain + .authenticationPrompt("Authenticate to save your vault decryption key in the iOS keychain") + .set(keyData, key: VaultConstants.encryptionKeyKey) + print("Encryption key saved successfully to keychain") + } catch { + print("Failed to save encryption key to keychain: \(error)") + } + } else { + print("Face ID is disabled, not storing encryption key in keychain") + } + } + + /// Encrypt the data using the encryption key + internal func encrypt(data: Data) throws -> Data { + let localEncryptionKey = try getEncryptionKey() + + let key = SymmetricKey(data: localEncryptionKey) + let sealedBox = try AES.GCM.seal(data, using: key) + return sealedBox.combined! + } + + /// Decrypt the data using the encryption key + internal func decrypt(data: Data) throws -> Data { + let localEncryptionKey = try getEncryptionKey() + + let key = SymmetricKey(data: localEncryptionKey) + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } + + /// Get the encryption key - the key used to encrypt and decrypt the vault. + /// This method is meant to only be used internally by the VaultStore class and not + /// be exposed to the public API or React Native for security reasons. + internal func getEncryptionKey() throws -> Data { + if let key = encryptionKey { + return key + } + + if enabledAuthMethods.contains(.faceID) { + let context = LAContext() + var error: NSError? + + #if targetEnvironment(simulator) + print("Simulator detected, skipping biometric policy evaluation check and continuing with key retrieval from keychain") + #else + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + throw NSError(domain: "VaultStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "Face ID not available: \(error?.localizedDescription ?? "Unknown error")"]) + } + #endif + + print("Attempting to get encryption key from keychain as Face ID is enabled as an option") + do { + guard let keyData = try keychain + .authenticationPrompt("Authenticate to unlock your vault") + .getData(VaultConstants.encryptionKeyKey) else { + throw NSError(domain: "VaultStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"]) + } + encryptionKey = keyData + return keyData + } catch let keychainError as KeychainAccess.Status { + switch keychainError { + case .itemNotFound: + throw NSError(domain: "VaultStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"]) + case .authFailed: + throw NSError(domain: "VaultStore", code: 8, userInfo: [NSLocalizedDescriptionKey: "Authentication failed"]) + default: + throw NSError(domain: "VaultStore", code: 9, userInfo: [NSLocalizedDescriptionKey: "Keychain access error: \(keychainError.localizedDescription)"]) + } + } catch { + throw NSError(domain: "VaultStore", code: 9, userInfo: [NSLocalizedDescriptionKey: "Unexpected error accessing keychain: \(error.localizedDescription)"]) + } + } + + throw NSError(domain: "VaultStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "No encryption key found in memory"]) + } +} \ No newline at end of file diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift new file mode 100644 index 000000000..07cc601a6 --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift @@ -0,0 +1,96 @@ +import Foundation +import SQLite + +/// Extension for the VaultStore class to handle database management +extension VaultStore { + /// Whether the vault has been stored on the device + public var hasEncryptedDatabase: Bool { + return FileManager.default.fileExists(atPath: getEncryptedDbPath().path) + } + + /// Store the encrypted database + public func storeEncryptedDatabase(_ base64EncryptedDb: String) throws { + try base64EncryptedDb.write(to: getEncryptedDbPath(), atomically: true, encoding: .utf8) + } + + /// Get the encrypted database + public func getEncryptedDatabase() -> String? { + do { + return try String(contentsOf: getEncryptedDbPath(), encoding: .utf8) + } catch { + return nil + } + } + + /// Unlock the vault - decrypt the database and setup the database with the decrypted data + public func unlockVault() throws { + guard let encryptedDbBase64 = getEncryptedDatabase() else { + throw NSError(domain: "VaultStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "No encrypted database found"]) + } + + let encryptedDbData = Data(base64Encoded: encryptedDbBase64)! + + do { + let decryptedDbBase64 = try decrypt(data: encryptedDbData) + try setupDatabaseWithDecryptedData(decryptedDbBase64) + } catch { + print("First decryption attempt failed: \(error)") + + encryptionKey = nil + + do { + let decryptedDbBase64 = try decrypt(data: encryptedDbData) + try setupDatabaseWithDecryptedData(decryptedDbBase64) + } catch { + print("Second decryption attempt failed: \(error)") + throw NSError(domain: "VaultStore", code: 5, userInfo: [NSLocalizedDescriptionKey: "Failed to decrypt database after retry: \(error.localizedDescription)"]) + } + } + } + + /// Remove the encrypted database from the local filesystem + internal func removeEncryptedDatabase() { + try? FileManager.default.removeItem(at: getEncryptedDbPath()) + } + + /// Get the path to the encrypted database file + private func getEncryptedDbPath() -> URL { + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: VaultConstants.keychainAccessGroup) else { + fatalError("Failed to get shared container URL") + } + return containerURL.appendingPathComponent(VaultConstants.encryptedDbFileName) + } + + /// Setup the database with the decrypted data + private func setupDatabaseWithDecryptedData(_ decryptedDbBase64: Data) throws { + guard let decryptedDbData = Data(base64Encoded: decryptedDbBase64) else { + throw NSError(domain: "VaultStore", code: 10, userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 data after decryption"]) + } + + let tempDbPath = FileManager.default.temporaryDirectory.appendingPathComponent("temp_db.sqlite") + try decryptedDbData.write(to: tempDbPath) + + dbConnection = try Connection(":memory:") + + try dbConnection?.attach(.uri(tempDbPath.path, parameters: [.mode(.readOnly)]), as: "source") + try dbConnection?.execute("BEGIN TRANSACTION") + + let tables = try dbConnection?.prepare("SELECT name FROM source.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + for table in tables! { + guard let tableName = table[0] as? String else { + print("Warning: Unexpected value in table name column") + continue + } + try dbConnection?.execute("CREATE TABLE \(tableName) AS SELECT * FROM source.\(tableName)") + } + + try dbConnection?.execute("COMMIT") + try dbConnection?.execute("DETACH DATABASE source") + + try? FileManager.default.removeItem(at: tempDbPath) + + try dbConnection?.execute("PRAGMA journal_mode = WAL") + try dbConnection?.execute("PRAGMA synchronous = NORMAL") + try dbConnection?.execute("PRAGMA foreign_keys = ON") + } +} diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift new file mode 100644 index 000000000..7ef073625 --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift @@ -0,0 +1,56 @@ +import Foundation +import VaultModels + +/// Extension for the VaultStore class to handle metadata management +extension VaultStore { + /// Store the metadata - the metadata for the vault + public func storeMetadata(_ metadata: String) throws { + userDefaults.set(metadata, forKey: VaultConstants.vaultMetadataKey) + userDefaults.synchronize() + } + + /// Get the metadata - the metadata for the vault + public func getVaultMetadata() -> String? { + return userDefaults.string(forKey: VaultConstants.vaultMetadataKey) + } + + /// Get the metadata object - the metadata for the vault + private func getVaultMetadataObject() -> VaultMetadata? { + guard let jsonString = getVaultMetadata(), + let data = jsonString.data(using: .utf8), + let metadata = try? JSONDecoder().decode(VaultMetadata.self, from: data) else { + return nil + } + return metadata + } + + /// Get the current vault revision number - the revision number of the vault + public func getCurrentVaultRevisionNumber() -> Int { + guard let metadata = getVaultMetadataObject() else { + return 0 + } + return metadata.vaultRevisionNumber + } + + /// Set the current vault revision number - the revision number of the vault + public func setCurrentVaultRevisionNumber(_ revisionNumber: Int) { + var metadata: VaultMetadata + + if let existingMetadata = getVaultMetadataObject() { + metadata = existingMetadata + } else { + metadata = VaultMetadata( + publicEmailDomains: [], + privateEmailDomains: [], + vaultRevisionNumber: revisionNumber + ) + } + + metadata.vaultRevisionNumber = revisionNumber + if let data = try? JSONEncoder().encode(metadata), + let jsonString = String(data: data, encoding: .utf8) { + userDefaults.set(jsonString, forKey: VaultConstants.vaultMetadataKey) + userDefaults.synchronize() + } + } +} \ No newline at end of file diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift new file mode 100644 index 000000000..c1d20bb1e --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift @@ -0,0 +1,350 @@ +import Foundation +import SQLite +import VaultModels + +/// Extension for the VaultStore class to handle query management +extension VaultStore { + /// Execute a SELECT query on the database + public func executeQuery(_ query: String, params: [Binding?]) throws -> [[String: Any]] { + guard let dbConnection = dbConnection else { + throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) + } + + var params = params + for (index, param) in params.enumerated() { + if let base64String = param as? String { + if base64String.hasPrefix("av-base64-to-blob:") { + let base64 = String(base64String.dropFirst("av-base64-to-blob:".count)) + if let data = Data(base64Encoded: base64) { + params[index] = Blob(bytes: [UInt8](data)) + } + } + } + } + + let statement = try dbConnection.prepare(query) + var results: [[String: Any]] = [] + + for row in try statement.run(params) { + var rowDict: [String: Any] = [:] + for (index, column) in statement.columnNames.enumerated() { + let value = row[index] + switch value { + case let data as SQLite.Blob: + let binaryData = Data(data.bytes) + rowDict[column] = binaryData.base64EncodedString() + case let number as Int64: + rowDict[column] = number + case let number as Double: + rowDict[column] = number + case let text as String: + rowDict[column] = text + case .none: + rowDict[column] = NSNull() + default: + rowDict[column] = value + } + } + results.append(rowDict) + } + + return results + } + + /// Execute an UPDATE, INSERT, or DELETE query on the database (which will modify the database). + public func executeUpdate(_ query: String, params: [Binding?]) throws -> Int { + guard let dbConnection = dbConnection else { + throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) + } + + var params = params + for (index, param) in params.enumerated() { + if let base64String = param as? String { + if base64String.hasPrefix("av-base64-to-blob:") { + let base64 = String(base64String.dropFirst("av-base64-to-blob:".count)) + if let data = Data(base64Encoded: base64) { + params[index] = Blob(bytes: [UInt8](data)) + } + } + } + } + + let statement = try dbConnection.prepare(query) + try statement.run(params) + return dbConnection.changes + } + + /// Begin a transaction on the database. This is required for all database operations that modify the database. + public func beginTransaction() throws { + guard let dbConnection = dbConnection else { + throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) + } + try dbConnection.execute("BEGIN TRANSACTION") + } + + /// Commit a transaction on the database. This is required for all database operations that modify the database. + /// Committing a transaction will also trigger a persist from the in-memory database to the encrypted database file. + public func commitTransaction() throws { + guard let dbConnection = dbConnection else { + throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) + } + + try dbConnection.execute("COMMIT") + + let tempDbPath = FileManager.default.temporaryDirectory.appendingPathComponent("temp_db.sqlite") + try Data().write(to: tempDbPath) + + try dbConnection.attach(.uri(tempDbPath.path, parameters: [.mode(.readWrite)]), as: "target") + + let tables = try dbConnection.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + try dbConnection.execute("BEGIN TRANSACTION") + for table in tables { + guard let tableName = table[0] as? String else { + print("Warning: Unexpected value in table name column") + continue + } + try dbConnection.execute("CREATE TABLE target.\(tableName) AS SELECT * FROM main.\(tableName)") + } + try dbConnection.execute("COMMIT") + try dbConnection.execute("DETACH DATABASE target") + + let rawData = try Data(contentsOf: tempDbPath) + let base64String = rawData.base64EncodedString() + let encryptedBase64Data = try encrypt(data: Data(base64String.utf8)) + let encryptedBase64String = encryptedBase64Data.base64EncodedString() + + try storeEncryptedDatabase(encryptedBase64String) + try storeMetadata(getVaultMetadata()!) + + try FileManager.default.removeItem(at: tempDbPath) + } + + /// Rollback a transaction on the database on error. + public func rollbackTransaction() throws { + guard let dbConnection = dbConnection else { + throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) + } + try dbConnection.execute("ROLLBACK") + } + + // swiftlint:disable function_body_length + /// Get all credentials from the database. + public func getAllCredentials() throws -> [Credential] { + guard let dbConnection = dbConnection else { + throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) + } + + print("Executing get all credentials query..") + + let query = """ + WITH LatestPasswords AS ( + SELECT + p.Id as password_id, + p.CredentialId, + p.Value, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted, + ROW_NUMBER() OVER (PARTITION BY p.CredentialId ORDER BY p.CreatedAt DESC) as rn + FROM Passwords p + WHERE p.IsDeleted = 0 + ) + SELECT + c.Id, + c.AliasId, + c.Username, + c.Notes, + c.CreatedAt, + c.UpdatedAt, + c.IsDeleted, + s.Id as service_id, + s.Name as service_name, + s.Url as service_url, + s.Logo as service_logo, + s.CreatedAt as service_created_at, + s.UpdatedAt as service_updated_at, + s.IsDeleted as service_is_deleted, + lp.password_id, + lp.Value as password_value, + lp.CreatedAt as password_created_at, + lp.UpdatedAt as password_updated_at, + lp.IsDeleted as password_is_deleted, + a.Id as alias_id, + a.Gender as alias_gender, + a.FirstName as alias_first_name, + a.LastName as alias_last_name, + a.NickName as alias_nick_name, + a.BirthDate as alias_birth_date, + a.Email as alias_email, + a.CreatedAt as alias_created_at, + a.UpdatedAt as alias_updated_at, + a.IsDeleted as alias_is_deleted + FROM Credentials c + LEFT JOIN Services s ON s.Id = c.ServiceId AND s.IsDeleted = 0 + LEFT JOIN LatestPasswords lp ON lp.CredentialId = c.Id AND lp.rn = 1 + LEFT JOIN Aliases a ON a.Id = c.AliasId AND a.IsDeleted = 0 + WHERE c.IsDeleted = 0 + ORDER BY c.CreatedAt DESC + """ + + var result: [Credential] = [] + for row in try dbConnection.prepare(query) { + guard let idString = row[0] as? String else { + continue + } + + let createdAtString = row[4] as? String + let updatedAtString = row[5] as? String + + guard let createdAtString = createdAtString, + let updatedAtString = updatedAtString else { + continue + } + + guard let createdAt = parseDateString(createdAtString), + let updatedAt = parseDateString(updatedAtString) else { + continue + } + + guard let isDeletedInt64 = row[6] as? Int64 else { continue } + let isDeleted = isDeletedInt64 == 1 + + guard let serviceId = row[7] as? String, + let serviceCreatedAtString = row[11] as? String, + let serviceUpdatedAtString = row[12] as? String, + let serviceIsDeletedInt64 = row[13] as? Int64, + let serviceCreatedAt = parseDateString(serviceCreatedAtString), + let serviceUpdatedAt = parseDateString(serviceUpdatedAtString) else { + continue + } + + let serviceIsDeleted = serviceIsDeletedInt64 == 1 + + let service = Service( + id: UUID(uuidString: serviceId)!, + name: row[8] as? String, + url: row[9] as? String, + logo: (row[10] as? SQLite.Blob).map { Data($0.bytes) }, + createdAt: serviceCreatedAt, + updatedAt: serviceUpdatedAt, + isDeleted: serviceIsDeleted + ) + + var alias: Alias? = nil + if let aliasIdString = row[19] as? String, + let aliasCreatedAtString = row[26] as? String, + let aliasUpdatedAtString = row[27] as? String, + let aliasIsDeletedInt64 = row[28] as? Int64, + let aliasCreatedAt = parseDateString(aliasCreatedAtString), + let aliasUpdatedAt = parseDateString(aliasUpdatedAtString), + let aliasBirthDateString = row[24] as? String, + let aliasBirthDate = parseDateString(aliasBirthDateString) { + + let aliasIsDeleted = aliasIsDeletedInt64 == 1 + + alias = Alias( + id: UUID(uuidString: aliasIdString)!, + gender: row[20] as? String, + firstName: row[21] as? String, + lastName: row[22] as? String, + nickName: row[23] as? String, + birthDate: aliasBirthDate, + email: row[25] as? String, + createdAt: aliasCreatedAt, + updatedAt: aliasUpdatedAt, + isDeleted: aliasIsDeleted + ) + } + + var password: Password? + if let passwordIdString = row[14] as? String, + let passwordValue = row[15] as? String, + let passwordCreatedAtString = row[16] as? String, + let passwordUpdatedAtString = row[17] as? String, + let passwordIsDeletedInt64 = row[18] as? Int64, + let passwordCreatedAt = parseDateString(passwordCreatedAtString), + let passwordUpdatedAt = parseDateString(passwordUpdatedAtString) { + + let passwordIsDeleted = passwordIsDeletedInt64 == 1 + + password = Password( + id: UUID(uuidString: passwordIdString)!, + credentialId: UUID(uuidString: idString)!, + value: passwordValue, + createdAt: passwordCreatedAt, + updatedAt: passwordUpdatedAt, + isDeleted: passwordIsDeleted + ) + } + + let credential = Credential( + id: UUID(uuidString: idString)!, + alias: alias, + service: service, + username: row[2] as? String, + notes: row[3] as? String, + password: password, + createdAt: createdAt, + updatedAt: updatedAt, + isDeleted: isDeleted + ) + result.append(credential) + } + + print("Found \(result.count) credentials") + + return result + } + // swiftlint:enable function_body_length + + /// Parse a date string to a Date object for use in queries. + private func parseDateString(_ dateString: String) -> Date? { + // Static date formatters for performance + struct StaticFormatters { + static let formatterWithMillis: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static let formatterWithoutMillis: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + } + + let cleanedDateString = dateString.trimmingCharacters(in: .whitespacesAndNewlines) + + // If ends with 'Z' or contains timezone, attempt ISO8601 parsing + if cleanedDateString.contains("Z") || cleanedDateString.contains("+") || cleanedDateString.contains("-") { + if let isoDate = StaticFormatters.isoFormatter.date(from: cleanedDateString) { + return isoDate + } + } + + // Try parsing with milliseconds + if let dateWithMillis = StaticFormatters.formatterWithMillis.date(from: cleanedDateString) { + return dateWithMillis + } + + // Try parsing without milliseconds + if let dateWithoutMillis = StaticFormatters.formatterWithoutMillis.date(from: cleanedDateString) { + return dateWithoutMillis + } + + // If parsing still fails, return nil + return nil + } +} diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift new file mode 100644 index 000000000..094538941 --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore.swift @@ -0,0 +1,109 @@ +import Foundation +import KeychainAccess +import SQLite +import LocalAuthentication +import CryptoKit +import CommonCrypto +import VaultModels + +/// This class is used to store and retrieve the encrypted AliasVault database and encryption key. +/// It also handles executing queries against the SQLite database and biometric authentication. +/// +/// This class is used by both the iOS Autofill extension and the React Native app and is the lowest +/// level where all important data is stored and retrieved from. +public class VaultStore { + /// A shared instance of the VaultStore class that can be used to access the vault which does + /// require re-authentication every time the vault is accessed. + public static let shared = VaultStore() + + /// The keychain to access the vault's encryption key. + internal let keychain = Keychain(service: VaultConstants.keychainService, + accessGroup: VaultConstants.keychainAccessGroup) + .accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: .biometryAny) + + /// The user defaults using the shared container which is accessible by both the React Native + /// app and the iOS Autofill extension. + internal let userDefaults = UserDefaults(suiteName: VaultConstants.userDefaultsSuite)! + + /// The enabled authentication methods for the vault. + internal var enabledAuthMethods: AuthMethods = VaultConstants.defaultAuthMethods + + /// The auto-lock timeout for the vault. + internal var autoLockTimeout: Int = VaultConstants.defaultAutoLockTimeout + + /// The database connection for the decrypted in-memory vault. + internal var dbConnection: Connection? + + /// The encryption key for the vault. + internal var encryptionKey: Data? + + /// The timer for the auto-lock timeout. + private var clearCacheTimer: Timer? + + /// Initialize the VaultStore + public init() { + loadSavedSettings() + setupNotificationObservers() + } + + /// Deinitialize the VaultStore + deinit { + NotificationCenter.default.removeObserver(self) + clearCacheTimer?.invalidate() + } + + /// Whether the vault is currently unlocked + public var isVaultUnlocked: Bool { + return encryptionKey != nil + } + + private func loadSavedSettings() { + if userDefaults.object(forKey: VaultConstants.authMethodsKey) != nil { + let savedRawValue = userDefaults.integer(forKey: VaultConstants.authMethodsKey) + enabledAuthMethods = AuthMethods(rawValue: savedRawValue) + } + + if userDefaults.object(forKey: VaultConstants.autoLockTimeoutKey) != nil { + autoLockTimeout = userDefaults.integer(forKey: VaultConstants.autoLockTimeoutKey) + } + } + + private func setupNotificationObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + // MARK: - Background/Foreground Handling + @objc private func appDidEnterBackground() { + print("App entered background, starting auto-lock timer with \(autoLockTimeout) seconds") + if autoLockTimeout > 0 { + clearCacheTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(autoLockTimeout), repeats: false) { [weak self] _ in + print("Auto-lock timer fired, clearing cache") + self?.clearCache() + } + } + } + + @objc private func appWillEnterForeground() { + print("App will enter foreground, canceling clear cache timer") + + if let timer = clearCacheTimer, timer.fireDate < Date() { + print("Timer has elapsed, cache should have been cleared already when app was in background, but clearing it again to be sure") + clearCache() + } + + clearCacheTimer?.invalidate() + clearCacheTimer = nil + } +} diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift deleted file mode 100644 index 51029ec2b..000000000 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStoreKit.swift +++ /dev/null @@ -1,766 +0,0 @@ -import Foundation -import KeychainAccess -import SQLite -import LocalAuthentication -import CryptoKit -import CommonCrypto -import VaultModels - -/// This class is used to store and retrieve the encrypted AliasVault database and encryption key. -/// It also handles executing queries against the SQLite database and biometric authentication. -/// -/// This class is used by both the iOS Autofill extension and the React Native app and is the lowest -/// level where all important data is stored and retrieved from. -public class VaultStore { - public static let shared = VaultStore() - private let keychain = Keychain(service: "net.aliasvault.autofill", accessGroup: "group.net.aliasvault.autofill") - .accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: .biometryAny) - - private let userDefaults = UserDefaults(suiteName: "group.net.aliasvault.autofill")! - - private var dbConnection: Connection? - private var encryptionKey: Data? - private var clearCacheTimer: Timer? - - private let vaultMetadataKey = "aliasvault_vault_metadata" - private let encryptionKeyKey = "aliasvault_encryption_key" - private let encryptedDbFileName = "encrypted_db.sqlite" - private let authMethodsKey = "aliasvault_auth_methods" - private let autoLockTimeoutKey = "aliasvault_auto_lock_timeout" - - // User config with default values - private var enabledAuthMethods: AuthMethods = [.password, .faceID] // Default to Face ID and password - private var autoLockTimeout: Int = 3600 // Default to 1 hour (3600 seconds) - - public init() { - // Load saved auth methods from UserDefaults if they exist - if userDefaults.object(forKey: authMethodsKey) != nil { - let savedRawValue = userDefaults.integer(forKey: authMethodsKey) - enabledAuthMethods = AuthMethods(rawValue: savedRawValue) - } - - // Load auto-lock timeout from UserDefaults if it exists - if userDefaults.object(forKey: autoLockTimeoutKey) != nil { - autoLockTimeout = userDefaults.integer(forKey: autoLockTimeoutKey) - } - - // Add notification observers - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - } - - deinit { - NotificationCenter.default.removeObserver(self) - clearCacheTimer?.invalidate() - } - - @objc private func appDidEnterBackground() { - print("App entered background, starting auto-lock timer with \(autoLockTimeout) seconds") - // Start timer to clear cache after auto-lock timeout - if autoLockTimeout > 0 { - clearCacheTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(autoLockTimeout), repeats: false) { [weak self] _ in - print("Auto-lock timer fired, clearing cache") - self?.clearCache() - } - } - } - - @objc private func appWillEnterForeground() { - print("App will enter foreground, canceling clear cache timer") - - // Check if timer has elapsed - if let timer = clearCacheTimer, timer.fireDate < Date() { - print("Timer has elapsed, cache should have been cleared already when app was in background, but clearing it again to be sure") - clearCache() - } - - // Cancel the timer if app comes back to foreground - clearCacheTimer?.invalidate() - clearCacheTimer = nil - } - - // MARK: - Auth Methods Management - public func setAuthMethods(_ methods: AuthMethods) throws { - enabledAuthMethods = methods - userDefaults.set(methods.rawValue, forKey: authMethodsKey) - userDefaults.synchronize() - - if !enabledAuthMethods.contains(.faceID) { - print("Face ID is now disabled, removing key from keychain immediately") - do { - try keychain - .authenticationPrompt("Authenticate to remove your vault decryption key") - .remove(encryptionKeyKey) - print("Successfully removed encryption key from keychain") - } catch { - print("Failed to remove encryption key from keychain: \(error)") - throw error - } - } else { - print("Face ID is now enabled, next time user logs in the key will be persisted in keychain") - } - } - - public func getAuthMethods() -> AuthMethods { - return enabledAuthMethods - } - - public func getAuthMethodsAsStrings() -> [String] { - var methods: [String] = [] - if enabledAuthMethods.contains(.faceID) { - methods.append("faceid") - } - if enabledAuthMethods.contains(.password) { - methods.append("password") - } - return methods - } - - // MARK: - Vault Status - public func hasStoredVault() -> Bool { - // Check if encrypted database file exists - let hasDatabase = FileManager.default.fileExists(atPath: getEncryptedDbPath().path) - - return hasDatabase - } - - public func isVaultUnlocked() -> Bool { - // Check if encryption key is in memory - return encryptionKey != nil - } - - // MARK: - Encryption Key Management - private func getEncryptionKey() throws -> Data { - if let key = encryptionKey { - return key - } - - // If Face ID is enabled, try to get the key from keychain - if enabledAuthMethods.contains(.faceID) { - let context = LAContext() - var error: NSError? - - #if targetEnvironment(simulator) - print("Simulator detected, skipping biometric policy evaluation check and continuing with key retrieval from keychain") - #else - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - throw NSError(domain: "VaultStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "Face ID not available: \(error?.localizedDescription ?? "Unknown error")"]) - } - #endif - - - // Get the encryption key from keychain - print("Attempting to get encryption key from keychain as Face ID is enabled as an option") - do { - guard let keyData = try keychain - .authenticationPrompt("Authenticate to unlock your vault") - .getData(encryptionKeyKey) else { - throw NSError(domain: "VaultStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"]) - } - encryptionKey = keyData - return keyData - } catch let keychainError as KeychainAccess.Status { - // Handle specific keychain errors - switch keychainError { - case .itemNotFound: - throw NSError(domain: "VaultStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"]) - case .authFailed: - throw NSError(domain: "VaultStore", code: 8, userInfo: [NSLocalizedDescriptionKey: "Authentication failed"]) - default: - throw NSError(domain: "VaultStore", code: 9, userInfo: [NSLocalizedDescriptionKey: "Keychain access error: \(keychainError.localizedDescription)"]) - } - } catch { - throw NSError(domain: "VaultStore", code: 9, userInfo: [NSLocalizedDescriptionKey: "Unexpected error accessing keychain: \(error.localizedDescription)"]) - } - } - - // If Face ID is not enabled and we don't have a key in memory, throw an error - throw NSError(domain: "VaultStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "No encryption key found in memory"]) - } - - public func storeEncryptionKey(base64Key: String) throws { - // Convert base64 string to bytes - guard let keyData = Data(base64Encoded: base64Key) else { - throw NSError(domain: "VaultStore", code: 6, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 key"]) - } - - // Validate key length (AES-256 requires 32 bytes) - guard keyData.count == 32 else { - throw NSError(domain: "VaultStore", code: 7, userInfo: [NSLocalizedDescriptionKey: "Invalid key length. Expected 32 bytes"]) - } - - // Store the key in memory - encryptionKey = keyData - print("Stored key in memory") - - // Store the key in the keychain if Face ID is enabled - if enabledAuthMethods.contains(.faceID) { - print("Face ID is enabled, storing key in keychain") - do { - try keychain - .authenticationPrompt("Authenticate to save your vault decryption key in the iOS keychain") - .set(keyData, key: encryptionKeyKey) - print("Encryption key saved succesfully to keychain") - } catch { - // If storing the key fails, we don't throw an error because it's not critical. - // The decryption key will then only be stored in memory which requires the user - // to re-authenticate on next app launch. We only print for logging purposes. - print("Failed to save encryption key to keychain: \(error)") - } - } - else { - print("Face ID is disabled, not storing encryption key in keychain") - } - } - - // MARK: - Database Management - - private func getEncryptedDbPath() -> URL { - guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.aliasvault.autofill") else { - fatalError("Failed to get shared container URL") - } - return containerURL.appendingPathComponent(encryptedDbFileName) - } - - // Store the encrypted database (base64 encoded) in the app's documents directory - public func storeEncryptedDatabase(_ base64EncryptedDb: String) throws { - try base64EncryptedDb.write(to: getEncryptedDbPath(), atomically: true, encoding: .utf8) - } - - // Store metadata in UserDefaults - // Metadata is stored in plain text in UserDefaults. The metadata consists of the following: - // - public and private email domains - // - vault revision number - public func storeMetadata(_ metadata: String) throws { - userDefaults.set(metadata, forKey: vaultMetadataKey) - userDefaults.synchronize() - } - - // Get the encrypted database as a base64 encoded string - public func getEncryptedDatabase() -> String? { - do { - // Return the base64 encoded string - return try String(contentsOf: getEncryptedDbPath(), encoding: .utf8) - } catch { - return nil - } - } - - // Get the current vault revision number - public func getCurrentVaultRevisionNumber() -> Int { - guard let metadata = getVaultMetadataObject() else { - return 0 - } - return metadata.vaultRevisionNumber - } - - // Set the current vault revision number - public func setCurrentVaultRevisionNumber(_ revisionNumber: Int) { - var metadata: VaultMetadata - - if let existingMetadata = getVaultMetadataObject() { - metadata = existingMetadata - } else { - metadata = VaultMetadata( - publicEmailDomains: [], - privateEmailDomains: [], - vaultRevisionNumber: revisionNumber - ) - } - - metadata.vaultRevisionNumber = revisionNumber - if let data = try? JSONEncoder().encode(metadata), - let jsonString = String(data: data, encoding: .utf8) { - userDefaults.set(jsonString, forKey: vaultMetadataKey) - userDefaults.synchronize() - } - } - - // Get the vault metadata from UserDefaults - public func getVaultMetadata() -> String? { - return userDefaults.string(forKey: vaultMetadataKey) - } - - // Helper to decode the JSON metadata into VaultMetadata object - private func getVaultMetadataObject() -> VaultMetadata? { - guard let jsonString = getVaultMetadata(), - let data = jsonString.data(using: .utf8), - let metadata = try? JSONDecoder().decode(VaultMetadata.self, from: data) else { - return nil - } - return metadata - } - - /** - * Unlock the vault. - * - * This function will decrypt the encrypted database and import it into an in-memory database. - * Note: as part of decryption, executing this function can prompt the user for biometric authentication. - * - * The in-memory database is used for all queries and updates to the database. - */ - public func unlockVault() throws { - // Get the encrypted database - guard let encryptedDbBase64 = getEncryptedDatabase() else { - throw NSError(domain: "VaultStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "No encrypted database found"]) - } - - let encryptedDbData = Data(base64Encoded: encryptedDbBase64)! - - // First attempt with current encryption key - do { - let encryptionKey = try getEncryptionKey() - let decryptedDbBase64 = try decrypt(data: encryptedDbData, key: encryptionKey) - try setupDatabaseWithDecryptedData(decryptedDbBase64) - } catch { - // If the first attempt fails, clear the cached encryption key and try again. - // This can be necessary if the user has changed their password or logged in with - // a different account while the autofill extension was still running and had its - // previous encryption key cached in memory. - print("First decryption attempt failed: \(error)") - - // Clear the cached encryption key and try again - encryptionKey = nil - - do { - // Second attempt with fresh encryption key - let freshEncryptionKey = try getEncryptionKey() - let decryptedDbBase64 = try decrypt(data: encryptedDbData, key: freshEncryptionKey) - try setupDatabaseWithDecryptedData(decryptedDbBase64) - } catch { - print("Second decryption attempt failed: \(error)") - throw NSError(domain: "VaultStore", code: 5, userInfo: [NSLocalizedDescriptionKey: "Failed to decrypt database after retry: \(error.localizedDescription)"]) - } - } - } - - private func setupDatabaseWithDecryptedData(_ decryptedDbBase64: Data) throws { - // The decrypted data is still base64 encoded, so decode it - guard let decryptedDbData = Data(base64Encoded: decryptedDbBase64) else { - throw NSError(domain: "VaultStore", code: 10, userInfo: [NSLocalizedDescriptionKey: "Failed to decode base64 data after decryption"]) - } - - // Create a temporary file for the decrypted database in the same directory as the encrypted one - let tempDbPath = FileManager.default.temporaryDirectory.appendingPathComponent("temp_db.sqlite") - try decryptedDbData.write(to: tempDbPath) - - // Create an in-memory database - dbConnection = try Connection(":memory:") - - // Import the decrypted database into memory - try dbConnection?.attach(.uri(tempDbPath.path, parameters: [.mode(.readOnly)]), as: "source") - try dbConnection?.execute("BEGIN TRANSACTION") - - // Copy all tables from source to memory - let tables = try dbConnection?.prepare("SELECT name FROM source.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") - for table in tables! { - let tableName = table[0] as! String - try dbConnection?.execute("CREATE TABLE \(tableName) AS SELECT * FROM source.\(tableName)") - } - - try dbConnection?.execute("COMMIT") - try dbConnection?.execute("DETACH DATABASE source") - - // Clean up the temporary file - try? FileManager.default.removeItem(at: tempDbPath) - - // Setup database pragmas - try dbConnection?.execute("PRAGMA journal_mode = WAL") - try dbConnection?.execute("PRAGMA synchronous = NORMAL") - try dbConnection?.execute("PRAGMA foreign_keys = ON") - } - - // MARK: - Encryption/Decryption - - private func encrypt(data: Data, key: Data) throws -> Data { - let key = SymmetricKey(data: key) - let sealedBox = try AES.GCM.seal(data, using: key) - return sealedBox.combined! - } - - private func decrypt(data: Data, key: Data) throws -> Data { - let key = SymmetricKey(data: key) - let sealedBox = try AES.GCM.SealedBox(combined: data) - return try AES.GCM.open(sealedBox, using: key) - } - - // MARK: - Credential Operations - public func getAllCredentials() throws -> [Credential] { - guard let dbConnection = dbConnection else { - throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) - } - - print("Executing get all credentials query..") - - let query = """ - WITH LatestPasswords AS ( - SELECT - p.Id as password_id, - p.CredentialId, - p.Value, - p.CreatedAt, - p.UpdatedAt, - p.IsDeleted, - ROW_NUMBER() OVER (PARTITION BY p.CredentialId ORDER BY p.CreatedAt DESC) as rn - FROM Passwords p - WHERE p.IsDeleted = 0 - ) - SELECT - c.Id, - c.AliasId, - c.Username, - c.Notes, - c.CreatedAt, - c.UpdatedAt, - c.IsDeleted, - s.Id as service_id, - s.Name as service_name, - s.Url as service_url, - s.Logo as service_logo, - s.CreatedAt as service_created_at, - s.UpdatedAt as service_updated_at, - s.IsDeleted as service_is_deleted, - lp.password_id, - lp.Value as password_value, - lp.CreatedAt as password_created_at, - lp.UpdatedAt as password_updated_at, - lp.IsDeleted as password_is_deleted, - a.Id as alias_id, - a.Gender as alias_gender, - a.FirstName as alias_first_name, - a.LastName as alias_last_name, - a.NickName as alias_nick_name, - a.BirthDate as alias_birth_date, - a.Email as alias_email, - a.CreatedAt as alias_created_at, - a.UpdatedAt as alias_updated_at, - a.IsDeleted as alias_is_deleted - FROM Credentials c - LEFT JOIN Services s ON s.Id = c.ServiceId AND s.IsDeleted = 0 - LEFT JOIN LatestPasswords lp ON lp.CredentialId = c.Id AND lp.rn = 1 - LEFT JOIN Aliases a ON a.Id = c.AliasId AND a.IsDeleted = 0 - WHERE c.IsDeleted = 0 - ORDER BY c.CreatedAt DESC - """ - - var result: [Credential] = [] - for row in try dbConnection.prepare(query) { - guard let idString = row[0] as? String else { - continue - } - - let createdAtString = row[4] as? String - let updatedAtString = row[5] as? String - - guard let createdAtString = createdAtString, - let updatedAtString = updatedAtString else { - continue - } - - guard let createdAt = DateHelper.parseDateString(createdAtString), - let updatedAt = DateHelper.parseDateString(updatedAtString) else { - continue - } - - guard let isDeletedInt64 = row[6] as? Int64 else { continue } - let isDeleted = isDeletedInt64 == 1 - - guard let serviceId = row[7] as? String, - let serviceCreatedAtString = row[11] as? String, - let serviceUpdatedAtString = row[12] as? String, - let serviceIsDeletedInt64 = row[13] as? Int64, - let serviceCreatedAt = DateHelper.parseDateString(serviceCreatedAtString), - let serviceUpdatedAt = DateHelper.parseDateString(serviceUpdatedAtString) else { - continue - } - - let serviceIsDeleted = serviceIsDeletedInt64 == 1 - - let service = Service( - id: UUID(uuidString: serviceId)!, - name: row[8] as? String, - url: row[9] as? String, - logo: (row[10] as? SQLite.Blob).map { Data($0.bytes) }, - createdAt: serviceCreatedAt, - updatedAt: serviceUpdatedAt, - isDeleted: serviceIsDeleted - ) - - var alias: Alias? = nil - if let aliasIdString = row[19] as? String, - let aliasCreatedAtString = row[26] as? String, - let aliasUpdatedAtString = row[27] as? String, - let aliasIsDeletedInt64 = row[28] as? Int64, - let aliasCreatedAt = DateHelper.parseDateString(aliasCreatedAtString), - let aliasUpdatedAt = DateHelper.parseDateString(aliasUpdatedAtString), - let aliasBirthDateString = row[24] as? String, - let aliasBirthDate = DateHelper.parseDateString(aliasBirthDateString) { - - let aliasIsDeleted = aliasIsDeletedInt64 == 1 - - alias = Alias( - id: UUID(uuidString: aliasIdString)!, - gender: row[20] as? String, - firstName: row[21] as? String, - lastName: row[22] as? String, - nickName: row[23] as? String, - birthDate: aliasBirthDate, - email: row[25] as? String, - createdAt: aliasCreatedAt, - updatedAt: aliasUpdatedAt, - isDeleted: aliasIsDeleted - ) - } - - var password: Password? = nil - if let passwordIdString = row[14] as? String, - let passwordValue = row[15] as? String, - let passwordCreatedAtString = row[16] as? String, - let passwordUpdatedAtString = row[17] as? String, - let passwordIsDeletedInt64 = row[18] as? Int64, - let passwordCreatedAt = DateHelper.parseDateString(passwordCreatedAtString), - let passwordUpdatedAt = DateHelper.parseDateString(passwordUpdatedAtString) { - - let passwordIsDeleted = passwordIsDeletedInt64 == 1 - - password = Password( - id: UUID(uuidString: passwordIdString)!, - credentialId: UUID(uuidString: idString)!, - value: passwordValue, - createdAt: passwordCreatedAt, - updatedAt: passwordUpdatedAt, - isDeleted: passwordIsDeleted - ) - } - - let credential = Credential( - id: UUID(uuidString: idString)!, - alias: alias, - service: service, - username: row[2] as? String, - notes: row[3] as? String, - password: password, - createdAt: createdAt, - updatedAt: updatedAt, - isDeleted: isDeleted - ) - result.append(credential) - } - - print("Found \(result.count) credentials") - - return result - } - - /// Clears cached encryption key and decrypted database to force re-initialization on next access. - public func clearCache() { - print("Clearing cache - removing encryption key and decrypted database from memory") - - // Clear the cached encryption key - encryptionKey = nil - - // Clear the in-memory (decrypted) database - dbConnection = nil - } - - /// Clears cached and saved encryption key and encrypted database, called during explicit logout. - public func clearVault() { - print("Clearing vault - removing all stored data") - - // Remove the encryption key from keychain with proper error handling - do { - try keychain - .authenticationPrompt("Authenticate to remove your vault decryption key") - .remove(encryptionKeyKey) - print("Successfully removed encryption key from keychain") - } catch { - print("Failed to remove encryption key from keychain: \(error)") - } - - // Remove the encrypted database from the app's documents directory - do { - try FileManager.default.removeItem(at: getEncryptedDbPath()) - print("Successfully removed encrypted database file") - } catch { - print("Failed to remove encrypted database file: \(error)") - } - - // Clear UserDefaults - userDefaults.removeObject(forKey: vaultMetadataKey) - userDefaults.removeObject(forKey: authMethodsKey) - userDefaults.removeObject(forKey: autoLockTimeoutKey) - userDefaults.synchronize() - print("Cleared UserDefaults") - - clearCache() - } - - // MARK: - Query Execution - - /** - * Execute a SELECT query. - */ - public func executeQuery(_ query: String, params: [Binding?]) throws -> [[String: Any]] { - guard let dbConnection = dbConnection else { - throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) - } - - // Loop through all params and convert any base64 strings to SQLite.Blob. - // Do this by checking for a statically determined prefix "av-base64:" - // and then decoding the base64 string so we're inserting the original binary data. - var params = params // Make params mutable - for (index, param) in params.enumerated() { - if let base64String = param as? String { - if base64String.hasPrefix("av-base64-to-blob:") { - let base64 = String(base64String.dropFirst("av-base64-to-blob:".count)) - if let data = Data(base64Encoded: base64) { - params[index] = Blob(bytes: [UInt8](data)) - } - } - } - } - - let statement = try dbConnection.prepare(query) - var results: [[String: Any]] = [] - - for row in try statement.run(params) { - var rowDict: [String: Any] = [:] - for (index, column) in statement.columnNames.enumerated() { - // Handle different SQLite data types appropriately - let value = row[index] - switch value { - case let data as SQLite.Blob: - // Convert SQLite blob to base64 string for React Native bridge - let binaryData = Data(data.bytes) - rowDict[column] = binaryData.base64EncodedString() - case let number as Int64: - rowDict[column] = number - case let number as Double: - rowDict[column] = number - case let text as String: - rowDict[column] = text - case .none: - rowDict[column] = NSNull() - default: - rowDict[column] = value - } - } - results.append(rowDict) - } - - return results - } - - /** - * Execute an update (INSERT, DELETE) query. - */ - public func executeUpdate(_ query: String, params: [Binding?]) throws -> Int { - guard let dbConnection = dbConnection else { - throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) - } - - // Loop through all params and convert any base64 strings to SQLite.Blob. - // Do this by checking for a statically determined prefix "av-base64:" - // and then decoding the base64 string so we're inserting the original binary data. - var params = params // Make params mutable - for (index, param) in params.enumerated() { - if let base64String = param as? String { - if base64String.hasPrefix("av-base64-to-blob:") { - let base64 = String(base64String.dropFirst("av-base64-to-blob:".count)) - if let data = Data(base64Encoded: base64) { - params[index] = Blob(bytes: [UInt8](data)) - } - } - } - } - - let statement = try dbConnection.prepare(query) - try statement.run(params) - return dbConnection.changes - } - - public func beginTransaction() throws { - guard let dbConnection = dbConnection else { - throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) - } - try dbConnection.execute("BEGIN TRANSACTION") - } - - public func commitTransaction() throws { - guard let dbConnection = dbConnection else { - throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) - } - - // First commit the transaction - try dbConnection.execute("COMMIT") - - // Get the encryption key - let key = try getEncryptionKey() - - // Create a temporary file path - let tempDbPath = FileManager.default.temporaryDirectory.appendingPathComponent("temp_db.sqlite") - - // Create the physical empty file, replace any existing file at this path - try Data().write(to: tempDbPath) - - // Attach a new empty database file as target - try dbConnection.attach(.uri(tempDbPath.path, parameters: [.mode(.readWrite)]), as: "target") - - // Copy all tables from memory to target file - let tables = try dbConnection.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") - try dbConnection.execute("BEGIN TRANSACTION") - for table in tables { - let tableName = table[0] as! String - try dbConnection.execute("CREATE TABLE target.\(tableName) AS SELECT * FROM main.\(tableName)") - } - try dbConnection.execute("COMMIT") - try dbConnection.execute("DETACH DATABASE target") - - // Read the raw contents of the temporary file - let rawData = try Data(contentsOf: tempDbPath) - - // Convert rawdata to base64 string - let base64String = rawData.base64EncodedString() - - // Encrypt the base64 string - let encryptedBase64Data = try encrypt(data: Data(base64String.utf8), key: key) - let encryptedBase64String = encryptedBase64Data.base64EncodedString() - - // Persist the encrypted base64 string. - try storeEncryptedDatabase(encryptedBase64String) - try storeMetadata(getVaultMetadata()!) - - // Cleanup - try FileManager.default.removeItem(at: tempDbPath) - } - - public func rollbackTransaction() throws { - guard let dbConnection = dbConnection else { - throw NSError(domain: "VaultStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Database not initialized"]) - } - try dbConnection.execute("ROLLBACK") - } - - // MARK: - Auto Lock Timeout Management - public func setAutoLockTimeout(_ timeout: Int) { - print("Setting auto-lock timeout to \(timeout) seconds") - autoLockTimeout = timeout - userDefaults.set(timeout, forKey: autoLockTimeoutKey) - userDefaults.synchronize() - } - - public func getAutoLockTimeout() -> Int { - return autoLockTimeout - } -} diff --git a/apps/mobile-app/ios/VaultStoreKitTests/VaultStoreKitTests.swift b/apps/mobile-app/ios/VaultStoreKitTests/VaultStoreKitTests.swift index 4b3c6835e..ecaa43398 100644 --- a/apps/mobile-app/ios/VaultStoreKitTests/VaultStoreKitTests.swift +++ b/apps/mobile-app/ios/VaultStoreKitTests/VaultStoreKitTests.swift @@ -8,11 +8,11 @@ import XCTest @testable import VaultStoreKit -final class VaultStoreKitTests: XCTestCase { +final public class VaultStoreKitTests: XCTestCase { var vaultStore: VaultStore! let testEncryptionKeyBase64 = "/9So3C83JLDIfjsF0VQOc4rz1uAFtIseW7yrUuztAD0=" // 32 bytes for AES-256 - override func setUp() { + override public func setUp() { super.setUp() vaultStore = VaultStore.shared @@ -37,7 +37,7 @@ final class VaultStoreKitTests: XCTestCase { } } - override func tearDown() { + override public func tearDown() { // Clean up after each test vaultStore.clearVault() super.tearDown() @@ -45,7 +45,7 @@ final class VaultStoreKitTests: XCTestCase { func testDatabaseInitialization() async throws { // If we get here without throwing, initialization was successful - XCTAssertTrue(vaultStore.isVaultUnlocked(), "Vault should be unlocked after initialization") + XCTAssertTrue(vaultStore.isVaultUnlocked, "Vault should be unlocked after initialization") } func testGetAllCredentials() async throws { diff --git a/apps/mobile-app/specs/NativeVaultManager.ts b/apps/mobile-app/specs/NativeVaultManager.ts index 71d6ff17f..98964c051 100644 --- a/apps/mobile-app/specs/NativeVaultManager.ts +++ b/apps/mobile-app/specs/NativeVaultManager.ts @@ -8,7 +8,6 @@ export interface Spec extends TurboModule { clearVault(): Promise; // Vault state management - hasStoredVault(): Promise; isVaultUnlocked(): Promise; getVaultMetadata(): Promise; unlockVault(): Promise; @@ -18,6 +17,7 @@ export interface Spec extends TurboModule { storeMetadata(metadata: string): Promise; setAuthMethods(authMethods: string[]): Promise; storeEncryptionKey(base64EncryptionKey: string): Promise; + hasEncryptedDatabase(): Promise; getEncryptedDatabase(): Promise; getCurrentVaultRevisionNumber(): Promise; setCurrentVaultRevisionNumber(revisionNumber: number): Promise;