diff --git a/apps/mobile-app/context/DbContext.tsx b/apps/mobile-app/context/DbContext.tsx index 725f4c88d..a4cd3de3e 100644 --- a/apps/mobile-app/context/DbContext.tsx +++ b/apps/mobile-app/context/DbContext.tsx @@ -29,7 +29,7 @@ type DbContextType = { hasPendingMigrations: () => Promise; clearDatabase: () => void; getVaultMetadata: () => Promise; - testDatabaseConnection: (derivedKey: string) => Promise; + testDatabaseConnection: (derivedKey: string, persistToKeychain?: boolean) => Promise; verifyEncryptionKey: (derivedKey: string) => Promise; unlockVault: () => Promise; checkStoredVault: () => Promise; @@ -244,15 +244,23 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } * @returns true if the database is working * @throws Error with error code if unlock fails - caller should handle the error */ - const testDatabaseConnection = useCallback(async (derivedKey: string): Promise => { + const testDatabaseConnection = useCallback(async (derivedKey: string, persistToKeychain = true): Promise => { await sqliteClient.storeEncryptionKeyInMemory(derivedKey); await unlockVault(); const version = await sqliteClient.getDatabaseVersion(); if (version && version.version && version.version.length > 0) { - // Key is valid: store in keychain (possibly overwriting a previous entry) - await sqliteClient.storeEncryptionKey(derivedKey); + /* + * Key is valid: optionally store in keychain. + * When persistToKeychain=false, only store in memory. This is used during password unlock + * when biometric authentication is unavailable (user cancelled or failed biometric prompt). + * Storing in keychain requires biometric auth, which the user can't provide at that moment. + * The old key in keychain is preserved for future biometric unlocks. + */ + if (persistToKeychain) { + await sqliteClient.storeEncryptionKey(derivedKey); + } return true; } diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index c1abb7eac..fc8731019 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -49,7 +49,9 @@ "verify": "Verify", "unlockVault": "Unlock Vault", "unlockWithPin": "Unlock with PIN", + "unlockWithPassword": "Unlock with Password", "enterPassword": "Enter your password to unlock your vault", + "enterPasswordToUnlock": "Enter your password to unlock your vault", "enterPasswordPlaceholder": "Password", "enterAuthCode": "Enter 6-digit code", "usernamePlaceholder": "name / name@company.com", @@ -321,7 +323,8 @@ "confirmSubtitle": "Re-authenticate to approve login on another device.", "confirmMessage": "You are about to log in on a remote device with your account. This other device will have full access to your vault. Only proceed if you trust this device.", "successDescription": "The remote device has been successfully logged in.", - "requestExpired": "This login request has expired. Please generate a new QR code." + "requestExpired": "This login request has expired. Please generate a new QR code.", + "noPinOrBiometricError": "Mobile unlock requires PIN or biometric authentication. Please enable PIN or Face ID/Touch ID in vault unlock settings to use this feature." } } }, diff --git a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm index 37912e5a6..5a672365c 100644 --- a/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm +++ b/apps/mobile-app/ios/NativeVaultManager/RCTNativeVaultManager.mm @@ -281,6 +281,10 @@ [vaultManager showPinSetup:resolve rejecter:reject]; } +- (void)showPasswordUnlock:(NSString *)title subtitle:(NSString *)subtitle resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [vaultManager showPasswordUnlock:title subtitle:subtitle resolver:resolve rejecter:reject]; +} + // MARK: - Mobile Login - (void)encryptDecryptionKeyForMobileLogin:(NSString *)publicKeyJWK resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { diff --git a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift index 28e9cdb33..d32c2d5a1 100644 --- a/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift +++ b/apps/mobile-app/ios/NativeVaultManager/VaultManager.swift @@ -808,6 +808,61 @@ public class VaultManager: NSObject { } } + @objc + func showPasswordUnlock(_ title: String?, + subtitle: String?, + resolver resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { + reject("INTERNAL_ERROR", "VaultManager instance deallocated", nil) + return + } + + // Get the root view controller from React Native + guard let rootVC = RCTPresentedViewController() else { + reject("NO_VIEW_CONTROLLER", "No view controller available", nil) + return + } + + // Create password unlock view with ViewModel + let customTitle = (title?.isEmpty == false) ? title : nil + let customSubtitle = (subtitle?.isEmpty == false) ? subtitle : nil + let viewModel = PasswordUnlockViewModel( + customTitle: customTitle, + customSubtitle: customSubtitle, + unlockHandler: { [weak self] password in + guard let self = self else { + throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "VaultManager instance deallocated"]) + } + + // Verify password and get encryption key + guard let encryptionKeyBase64 = self.vaultStore.verifyPassword(password) else { + throw NSError(domain: "VaultManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Incorrect password"]) + } + + await MainActor.run { + rootVC.dismiss(animated: true) { + resolve(encryptionKeyBase64) + } + } + }, + cancelHandler: { + rootVC.dismiss(animated: true) { + resolve(NSNull()) + } + } + ) + + let passwordView = PasswordUnlockView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: passwordView) + + // Present modally as full screen + hostingController.modalPresentationStyle = .fullScreen + rootVC.present(hostingController, animated: true) + } + } + @objc func showPinSetup(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { @@ -972,7 +1027,16 @@ public class VaultManager: NSObject { } else { // Use biometric authentication let authenticated = vaultStore.issueBiometricAuthentication(title: title) - resolve(authenticated) + if !authenticated { + // Biometric failed - reject with error instead of resolving false + reject( + "AUTH_ERROR", + "No authentication method available. Please enable PIN or biometric unlock in settings.", + nil + ) + } else { + resolve(authenticated) + } } } diff --git a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift index 9d9ac071c..966159cb4 100644 --- a/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift +++ b/apps/mobile-app/ios/VaultStoreKit/VaultStore+Crypto.swift @@ -103,6 +103,42 @@ extension VaultStore { return self.keyDerivationParams } + /// Verify password and return encryption key if correct + /// Returns nil if password is incorrect + public func verifyPassword(_ password: String) -> String? { + do { + // Get encryption key derivation parameters + guard let paramsString = getEncryptionKeyDerivationParams(), + let paramsData = paramsString.data(using: .utf8), + let params = try? JSONSerialization.jsonObject(with: paramsData) as? [String: Any], + let salt = params["salt"] as? String, + let encryptionType = params["encryptionType"] as? String, + let encryptionSettings = params["encryptionSettings"] as? String else { + return nil + } + + // Derive key from password + let derivedKey = try deriveKeyFromPassword(password, salt: salt, encryptionType: encryptionType, encryptionSettings: encryptionSettings) + + // Try to decrypt the vault to verify the password is correct + guard let encryptedDbBase64 = getEncryptedDatabase(), + let encryptedDbData = Data(base64Encoded: encryptedDbBase64) else { + return nil + } + + // Test decryption + let key = SymmetricKey(data: derivedKey) + let sealedBox = try AES.GCM.SealedBox(combined: encryptedDbData) + _ = try AES.GCM.open(sealedBox, using: key) + + // If decryption succeeded, return the key as base64 + return derivedKey.base64EncodedString() + } catch { + // Password incorrect or decryption failed + return nil + } + } + /// Encrypt the data using the encryption key internal func encrypt(data: Data) throws -> Data { let encryptionKey = try getEncryptionKey() diff --git a/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockView.swift b/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockView.swift new file mode 100644 index 000000000..c6b25f94e --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockView.swift @@ -0,0 +1,198 @@ +import SwiftUI +import VaultModels +import UIKit + +private let locBundle = Bundle.vaultUI + +/// SwiftUI view for password unlock +public struct PasswordUnlockView: View { + @ObservedObject public var viewModel: PasswordUnlockViewModel + @Environment(\.colorScheme) var colorScheme + @FocusState private var isPasswordFocused: Bool + + public init(viewModel: PasswordUnlockViewModel) { + self._viewModel = ObservedObject(wrappedValue: viewModel) + } + + private var colors: ColorConstants.Colors.Type { + ColorConstants.colors(for: colorScheme) + } + + public var body: some View { + ZStack { + // Background gradient + LinearGradient( + gradient: Gradient(colors: [ + colors.primary.opacity(0.1), + colors.background + ]), + startPoint: .top, + endPoint: .center + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + // Header with back button + HStack { + Button( + action: { + viewModel.cancel() + }, + label: { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + Text(String(localized: "back", bundle: locBundle)) + .font(.system(size: 16)) + } + .foregroundColor(colors.primary) + } + ) + .padding(.leading, 16) + Spacer() + } + .padding(.top, 16) + .frame(height: 50) + + Spacer() + + // Content + VStack(spacing: 28) { + // AliasVault Logo with animation + Image("Logo", bundle: .vaultUI) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 80, height: 80) + .shadow(color: colors.primary.opacity(0.2), radius: 10, x: 0, y: 5) + .transition(.scale.combined(with: .opacity)) + + // Title + Text(viewModel.customTitle ?? String(localized: "unlock_vault", bundle: locBundle)) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(colors.text) + .transition(.opacity) + + // Subtitle + Text(viewModel.customSubtitle ?? String(localized: "enter_password_to_unlock", bundle: locBundle)) + .font(.system(size: 16)) + .foregroundColor(colors.text.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + .transition(.opacity) + + // Password Field Container + VStack(alignment: .leading, spacing: 12) { + // Password Field + HStack(spacing: 12) { + Image(systemName: "lock.fill") + .foregroundColor(colors.text.opacity(0.4)) + .font(.system(size: 16)) + + SecureField(String(localized: "password", bundle: locBundle), text: $viewModel.password) + .textFieldStyle(.plain) + .font(.system(size: 16)) + .foregroundColor(colors.text) + .focused($isPasswordFocused) + .autocapitalization(.none) + .disableAutocorrection(true) + .submitLabel(.done) + .onSubmit { + if !viewModel.password.isEmpty && !viewModel.isProcessing { + Task { + await viewModel.unlock() + } + } + } + } + .padding(16) + .background(colors.accentBackground) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + isPasswordFocused ? colors.primary.opacity(0.5) : colors.accentBorder, + lineWidth: isPasswordFocused ? 2 : 1 + ) + ) + .shadow(color: isPasswordFocused ? colors.primary.opacity(0.1) : Color.clear, radius: 8, x: 0, y: 4) + .animation(.easeInOut(duration: 0.2), value: isPasswordFocused) + + // Error message with animation + if let error = viewModel.error { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + Text(error) + .font(.system(size: 14)) + } + .foregroundColor(.red) + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: viewModel.error) + } + } + .padding(.horizontal, 32) + .padding(.top, 8) + + // Unlock Button with gradient + Button( + action: { + Task { + await viewModel.unlock() + } + }, + label: { + if viewModel.isProcessing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity) + .frame(height: 54) + } else { + HStack(spacing: 8) { + Text(String(localized: "unlock", bundle: locBundle)) + .font(.system(size: 17, weight: .semibold)) + Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .semibold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 54) + } + } + ) + .background( + LinearGradient( + gradient: Gradient(colors: [ + viewModel.password.isEmpty || viewModel.isProcessing ? colors.primary.opacity(0.5) : colors.primary, + viewModel.password.isEmpty || viewModel.isProcessing ? colors.primary.opacity(0.4) : colors.primary.opacity(0.8) + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(12) + .shadow( + color: (viewModel.password.isEmpty || viewModel.isProcessing) ? Color.clear : colors.primary.opacity(0.3), + radius: 8, + x: 0, + y: 4 + ) + .disabled(viewModel.password.isEmpty || viewModel.isProcessing) + .padding(.horizontal, 32) + .padding(.top, 8) + .scaleEffect(viewModel.password.isEmpty || viewModel.isProcessing ? 0.98 : 1.0) + .animation(.easeInOut(duration: 0.2), value: viewModel.password.isEmpty) + .animation(.easeInOut(duration: 0.2), value: viewModel.isProcessing) + } + + Spacer() + Spacer() + } + } + .onAppear { + // Delay focus slightly to ensure smooth animation + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isPasswordFocused = true + } + } + } +} diff --git a/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift b/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift new file mode 100644 index 000000000..4739e4454 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockViewModel.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftUI + +private let locBundle = Bundle.vaultUI + +/// ViewModel for password unlock +@MainActor +public class PasswordUnlockViewModel: ObservableObject { + @Published public var password: String = "" + @Published public var error: String? + @Published public var isProcessing: Bool = false + + public let customTitle: String? + public let customSubtitle: String? + + private let unlockHandler: (String) async throws -> Void + private let cancelHandler: () -> Void + + public init( + customTitle: String?, + customSubtitle: String?, + unlockHandler: @escaping (String) async throws -> Void, + cancelHandler: @escaping () -> Void + ) { + self.customTitle = customTitle + self.customSubtitle = customSubtitle + self.unlockHandler = unlockHandler + self.cancelHandler = cancelHandler + } + + public func unlock() async { + guard !password.isEmpty else { return } + guard !isProcessing else { return } + + isProcessing = true + error = nil + + do { + try await unlockHandler(password) + } catch { + // Show error and clear password + self.error = String(localized: "incorrect_password", bundle: locBundle) + self.password = "" + self.isProcessing = false + } + } + + public func cancel() { + cancelHandler() + } +} diff --git a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings index 50fc875e4..559605953 100644 --- a/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings +++ b/apps/mobile-app/ios/VaultUI/en.lproj/Localizable.strings @@ -68,6 +68,11 @@ "pin_locked_max_attempts" = "PIN locked after too many failed attempts"; "pin_incorrect_attempts_remaining" = "Incorrect PIN. %d attempts remaining"; +/* Password Unlock */ +"enter_password_to_unlock" = "Enter your master password"; +"unlock" = "Unlock"; +"incorrect_password" = "Incorrect password. Please try again."; + /* PIN Setup */ "pin_setup_title" = "Setup PIN"; "pin_setup_subtitle" = "Choose a PIN to unlock your vault";