Refactor VaultStoreKit and fix linting issues (#771)

This commit is contained in:
Leendert de Borst
2025-05-04 18:06:37 +02:00
parent e7aae996d1
commit 61692db40b
17 changed files with 913 additions and 795 deletions

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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)
}

View 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]
}

View 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
}
}

View 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
}
}

View 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"])
}
}

View 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")
}
}

View 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()
}
}
}

View 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
}
}

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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>;