Add iOS native password unlock flow (#1776)

This commit is contained in:
Leendert de Borst
2026-02-24 20:36:18 +01:00
parent 28e448933d
commit 964c74eabf
8 changed files with 375 additions and 6 deletions

View File

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

View File

@@ -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."
}
}
},

View File

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

View File

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

View File

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

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

View File

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

View File

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