mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-30 04:24:07 -04:00
Add iOS native password unlock flow (#1776)
This commit is contained in:
@@ -29,7 +29,7 @@ type DbContextType = {
|
||||
hasPendingMigrations: () => Promise<boolean>;
|
||||
clearDatabase: () => void;
|
||||
getVaultMetadata: () => Promise<VaultMetadata | null>;
|
||||
testDatabaseConnection: (derivedKey: string) => Promise<boolean>;
|
||||
testDatabaseConnection: (derivedKey: string, persistToKeychain?: boolean) => Promise<boolean>;
|
||||
verifyEncryptionKey: (derivedKey: string) => Promise<boolean>;
|
||||
unlockVault: () => Promise<boolean>;
|
||||
checkStoredVault: () => Promise<void>;
|
||||
@@ -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<boolean> => {
|
||||
const testDatabaseConnection = useCallback(async (derivedKey: string, persistToKeychain = true): Promise<boolean> => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
198
apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockView.swift
Normal file
198
apps/mobile-app/ios/VaultUI/Auth/PasswordUnlockView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user