mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-13 18:05:28 -04:00
Refactor VaultStoreKit and fix linting issues (#771)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultStoreKitTests; sourceTree = "<group>"; };
|
||||
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultUI; sourceTree = "<group>"; };
|
||||
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = VaultModels; sourceTree = "<group>"; };
|
||||
CEE909812DA548C7008D568F /* Autofill */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (CEE9098F2DA548C7008D568F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Autofill; sourceTree = "<group>"; };
|
||||
CEE480882DBE86DC00F4A367 /* VaultStoreKit */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = VaultStoreKit;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CEE480972DBE86DD00F4A367 /* VaultStoreKitTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = VaultStoreKitTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CEE4816B2DBE8AC800F4A367 /* VaultUI */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = VaultUI;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CEE482AB2DBE8EFE00F4A367 /* VaultModels */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = VaultModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CEE909812DA548C7008D568F /* Autofill */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CEE9098F2DA548C7008D568F /* Exceptions for "Autofill" folder in "Autofill" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = Autofill;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
18
apps/mobile-app/ios/VaultStoreKit/VaultConstants.swift
Normal file
18
apps/mobile-app/ios/VaultStoreKit/VaultConstants.swift
Normal file
@@ -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]
|
||||
}
|
||||
|
||||
46
apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
Normal file
46
apps/mobile-app/ios/VaultStoreKit/VaultStore+Auth.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
53
apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift
Normal file
53
apps/mobile-app/ios/VaultStoreKit/VaultStore+Cache.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
99
apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift
Normal file
99
apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift
Normal file
@@ -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"])
|
||||
}
|
||||
}
|
||||
96
apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift
Normal file
96
apps/mobile-app/ios/VaultStoreKit/VaultStore+Database.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
56
apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift
Normal file
56
apps/mobile-app/ios/VaultStoreKit/VaultStore+Metadata.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
350
apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift
Normal file
350
apps/mobile-app/ios/VaultStoreKit/VaultStore+Query.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
109
apps/mobile-app/ios/VaultStoreKit/VaultStore.swift
Normal file
109
apps/mobile-app/ios/VaultStoreKit/VaultStore.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface Spec extends TurboModule {
|
||||
clearVault(): Promise<void>;
|
||||
|
||||
// Vault state management
|
||||
hasStoredVault(): Promise<boolean>;
|
||||
isVaultUnlocked(): Promise<boolean>;
|
||||
getVaultMetadata(): Promise<string>;
|
||||
unlockVault(): Promise<boolean>;
|
||||
@@ -18,6 +17,7 @@ export interface Spec extends TurboModule {
|
||||
storeMetadata(metadata: string): Promise<void>;
|
||||
setAuthMethods(authMethods: string[]): Promise<void>;
|
||||
storeEncryptionKey(base64EncryptionKey: string): Promise<void>;
|
||||
hasEncryptedDatabase(): Promise<boolean>;
|
||||
getEncryptedDatabase(): Promise<string | null>;
|
||||
getCurrentVaultRevisionNumber(): Promise<number>;
|
||||
setCurrentVaultRevisionNumber(revisionNumber: number): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user