Refactor AuthViewModel to match iOS auth flow

Replace PendingAuth/RSA key passing with tempPassword pattern:
- register(): saves keys to disk, stores password temporarily
- confirmRegistration(): verifies code, then calls performLogin(email, tempPassword)
  which re-loads keys from disk - exactly as iOS does
- Extract shared performLogin() suspend fn used by both login() and post-confirm flow
- Remove PendingAuth data class entirely

Simpler, correct, matches reference iOS implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
filip
2026-03-11 01:52:33 +01:00
parent e36dfe1cee
commit 3d935dcfbf

View File

@@ -1,6 +1,7 @@
package com.kecalek.chat.ui.auth package com.kecalek.chat.ui.auth
import android.util.Base64 import android.util.Base64
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.kecalek.chat.core.AuthException import com.kecalek.chat.core.AuthException
@@ -17,16 +18,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey import java.security.interfaces.RSAPublicKey
import javax.inject.Inject import javax.inject.Inject
/**
* UI state for all auth screens (Login, Register, Pairing).
*/
data class AuthUiState( data class AuthUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val loadingMessage: String? = null, // e.g. "Generating keys…", "Connecting…" val loadingMessage: String? = null,
val error: String? = null, val error: String? = null,
val isLoggedIn: Boolean = false, val isLoggedIn: Boolean = false,
val isRegistered: Boolean = false, val isRegistered: Boolean = false,
@@ -42,19 +39,6 @@ data class AuthUiState(
val registeredEmail: String? = null, val registeredEmail: String? = null,
) )
/**
* Holds the already-decrypted key material between register() and confirmRegistration()
* so we can auto-login immediately after email confirmation without re-asking for the
* password or repeating the expensive PBKDF2 derivation.
*
* Cleared as soon as it is consumed (or when the ViewModel is cleared).
*/
private data class PendingAuth(
val email: String,
val rsaPrivate: RSAPrivateKey,
val identityPrivateBytes: ByteArray, // raw Ed25519 seed — used for initLocalKey
)
@HiltViewModel @HiltViewModel
class AuthViewModel @Inject constructor( class AuthViewModel @Inject constructor(
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
@@ -64,122 +48,107 @@ class AuthViewModel @Inject constructor(
private val _uiState = MutableStateFlow(AuthUiState()) private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow() val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
// Holds key material between register() and confirmRegistration(). Never leaves memory. // Stored temporarily between register() and confirmRegistration() so we can
private var pendingAuth: PendingAuth? = null // auto-login after confirmation without asking the user for their password again.
// Cleared immediately after use (or in onCleared).
// Matches the iOS pattern: AuthViewModel stores self.password for this purpose.
private var tempPassword: String? = null
init { init {
_uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) } _uiState.update { it.copy(hasExistingAccount = keyStorage.hasRsaKeys()) }
} }
// ───────────────────────────── LOGIN ─────────────────────────────
fun login(emailOrUsername: String, password: String) { fun login(emailOrUsername: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) } performLogin(emailOrUsername, password)
try { }
if (!keyStorage.hasRsaKeys()) { }
_uiState.update {
it.copy(
isLoading = false,
error = "No account on this device. Register or pair first."
)
}
return@launch
}
// Load RSA private key (decrypted with user's password via ECP1). /**
// PBKDF2 600k iterations — must run off the main thread. * Core login logic shared by login() and the auto-login after confirmRegistration().
val rsaPrivate = try { * Matches iOS ChatClient.login() + AuthViewModel.login(appState:).
withContext(Dispatchers.Default) { */
keyStorage.loadRsaPrivate(password) private suspend fun performLogin(emailOrUsername: String, password: String) {
} _uiState.update { it.copy(isLoading = true, loadingMessage = "Dešifruji klíče…", error = null) }
} catch (e: Exception) { try {
_uiState.update { if (!keyStorage.hasRsaKeys()) {
it.copy(isLoading = false, error = "Wrong password or corrupted key.")
}
return@launch
}
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
val state = _uiState.value
sessionManager.login(
email = emailOrUsername,
rsaPrivateKey = rsaPrivate,
host = state.serverHost,
port = state.serverPort,
useTls = state.useTls,
)
// Load identity key and init local storage key (also PBKDF2 — off main thread)
if (keyStorage.hasIdentityKeys()) {
val identityPrivate = withContext(Dispatchers.Default) {
keyStorage.loadIdentityPrivate(password)
}
keyStorage.initLocalKey(Ed25519Crypto.serializePrivate(identityPrivate))
}
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
} catch (e: AuthException) {
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) {
_uiState.update { _uiState.update {
it.copy(isLoading = false, loadingMessage = null, error = "Connection failed: ${e.message}") it.copy(isLoading = false, error = "No account on this device. Register or pair first.")
} }
return
}
// Load RSA private key — PBKDF2 600k iterations, must run off the main thread
val rsaPrivate = try {
withContext(Dispatchers.Default) { keyStorage.loadRsaPrivate(password) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Wrong password or corrupted key.") }
return
}
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
val state = _uiState.value
sessionManager.login(
email = emailOrUsername,
rsaPrivateKey = rsaPrivate,
host = state.serverHost,
port = state.serverPort,
useTls = state.useTls,
)
// Init local DB encryption key from identity key (also PBKDF2, off main thread)
if (keyStorage.hasIdentityKeys()) {
val identityPrivate = withContext(Dispatchers.Default) {
keyStorage.loadIdentityPrivate(password)
}
keyStorage.initLocalKey(Ed25519Crypto.serializePrivate(identityPrivate))
}
_uiState.update { it.copy(isLoading = false, loadingMessage = null, isLoggedIn = true) }
} catch (e: AuthException) {
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, loadingMessage = null, error = "Connection failed: ${e.message}")
} }
} }
} }
// ───────────────────────────── REGISTER ─────────────────────────────
fun register(username: String, email: String, password: String) { fun register(username: String, email: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) } _uiState.update { it.copy(isLoading = true, loadingMessage = "Generuji klíče…", error = null) }
try { try {
// Steps 1-4 are CPU-intensive (RSA-4096 keygen + 2× PBKDF2 600k iters). // CPU-intensive: RSA-4096 keygen + 2× PBKDF2 600k iterations
// Run on Default dispatcher to avoid blocking the UI thread. val (rsaPublicPem, identityKeyBase64) = withContext(Dispatchers.Default) {
data class KeyMaterial(
val rsaPublicPem: String,
val identityKeyBase64: String,
val rsaPrivate: RSAPrivateKey,
val identityPrivateBytes: ByteArray,
)
val keys = withContext(Dispatchers.Default) {
// 1. Generate RSA-4096 keypair (~5-30 seconds)
val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair() val (rsaPrivate, rsaPublic) = RSACrypto.generateKeypair()
// 2. Generate Ed25519 identity keypair (fast)
val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair() val (identityPrivate, identityPublic) = Ed25519Crypto.generateKeypair()
// 3. Save keys encrypted with password (PBKDF2 600k iters each)
keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password) keyStorage.saveRsaKeys(rsaPrivate, rsaPublic, password)
keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password) keyStorage.saveIdentityKeys(identityPrivate, identityPublic, password)
// 4. Convert to server format and capture private key material Pair(
KeyMaterial( rsaPublicKeyToPem(rsaPublic),
rsaPublicPem = rsaPublicKeyToPem(rsaPublic), Base64.encodeToString(Ed25519Crypto.serializePublic(identityPublic), Base64.NO_WRAP),
identityKeyBase64 = Base64.encodeToString(
Ed25519Crypto.serializePublic(identityPublic),
Base64.NO_WRAP,
),
rsaPrivate = rsaPrivate,
identityPrivateBytes = Ed25519Crypto.serializePrivate(identityPrivate),
) )
} }
// Save decrypted keys for use in confirmRegistration (auto-login). // Save password for auto-login after email confirmation (same pattern as iOS)
// pendingAuth is cleared after use or when this ViewModel is destroyed. tempPassword = password
pendingAuth = PendingAuth(
email = email,
rsaPrivate = keys.rsaPrivate,
identityPrivateBytes = keys.identityPrivateBytes,
)
_uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") } _uiState.update { it.copy(loadingMessage = "Připojuji se k serveru…") }
// 5. Register on server (network I/O — SessionManager uses IO dispatcher internally)
val state = _uiState.value val state = _uiState.value
sessionManager.register( sessionManager.register(
username = username, username = username,
email = email, email = email,
rsaPublicKeyPem = keys.rsaPublicPem, rsaPublicKeyPem = rsaPublicPem,
identityKeyBase64 = keys.identityKeyBase64, identityKeyBase64 = identityKeyBase64,
host = state.serverHost, host = state.serverHost,
port = state.serverPort, port = state.serverPort,
useTls = state.useTls, useTls = state.useTls,
@@ -196,10 +165,10 @@ class AuthViewModel @Inject constructor(
) )
} }
} catch (e: AuthException) { } catch (e: AuthException) {
pendingAuth = null tempPassword = null
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) } _uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) { } catch (e: Exception) {
pendingAuth = null tempPassword = null
_uiState.update { _uiState.update {
it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}") it.copy(isLoading = false, loadingMessage = null, error = "Registration failed: ${e.message}")
} }
@@ -207,53 +176,36 @@ class AuthViewModel @Inject constructor(
} }
} }
// ───────────────────────────── CONFIRM ─────────────────────────────
fun confirmRegistration(email: String, code: String) { fun confirmRegistration(email: String, code: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) } _uiState.update { it.copy(isLoading = true, loadingMessage = "Potvrzuji kód…", error = null) }
try { try {
// Step 1: Verify the email code — this is the only thing that should report // Step 1: Verify the email code — errors here are genuinely "wrong code"
// "wrong code". Any error here is genuinely a bad/expired code.
sessionManager.confirmRegistration(email, code) sessionManager.confirmRegistration(email, code)
// Step 2: Try auto-login using the key material saved during register(). // Step 2: Auto-login exactly as iOS does:
// This is best-effort — failure here does NOT mean the code was wrong. // call login() with the stored password (keys are already on disk from register())
// On failure the user is sent back to LoginScreen to log in manually. val pwd = tempPassword
val auth = pendingAuth tempPassword = null
var loggedIn = false
if (auth != null && auth.email == email) { if (pwd != null) {
try { // performLogin updates uiState (isLoggedIn = true on success, error on failure)
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") } performLogin(email, pwd)
val state = _uiState.value } else {
sessionManager.login( // No stored password (e.g. app restarted between register and confirm).
email = email, // Mark registration done and send to LoginScreen.
rsaPrivateKey = auth.rsaPrivate, _uiState.update {
host = state.serverHost, it.copy(
port = state.serverPort, isLoading = false,
useTls = state.useTls, loadingMessage = null,
needsConfirmation = false,
hasExistingAccount = true,
) )
keyStorage.initLocalKey(auth.identityPrivateBytes)
loggedIn = true
} catch (loginEx: Exception) {
// Auto-login failed (e.g. stale connection, transient server error).
// Log it but don't surface to user as a "wrong code" error.
android.util.Log.w(
"AuthViewModel",
"Auto-login after confirm failed, user must log in manually: ${loginEx.message}"
)
} finally {
pendingAuth = null
} }
} }
_uiState.update {
it.copy(
isLoading = false,
loadingMessage = null,
isLoggedIn = loggedIn,
needsConfirmation = false,
hasExistingAccount = true,
)
}
} catch (e: AuthException) { } catch (e: AuthException) {
_uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) } _uiState.update { it.copy(isLoading = false, loadingMessage = null, error = e.message) }
} catch (e: Exception) { } catch (e: Exception) {
@@ -264,32 +216,28 @@ class AuthViewModel @Inject constructor(
} }
} }
// ───────────────────────────── PAIRING / BIOMETRIC ─────────────────────────────
fun startPairing() { fun startPairing() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { _uiState.update { it.copy(isLoading = false, error = "Device pairing not yet implemented.") }
it.copy(isLoading = false, error = "Device pairing not yet implemented.")
}
} }
} }
fun cancelPairing() { fun cancelPairing() {
_uiState.update { _uiState.update { it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false) }
it.copy(isPairingWaiting = false, pairingCode = null, isPairingComplete = false)
}
} }
fun loginWithBiometric() { fun loginWithBiometric() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { _uiState.update { it.copy(isLoading = false, error = "Biometric login not yet implemented.") }
it.copy(isLoading = false, error = "Biometric login not yet implemented.")
}
} }
} }
// ───────────────────────────── MISC ─────────────────────────────
fun updateServerConfig(host: String, port: Int, useTls: Boolean) { fun updateServerConfig(host: String, port: Int, useTls: Boolean) {
_uiState.update { _uiState.update { it.copy(serverHost = host, serverPort = port, useTls = useTls) }
it.copy(serverHost = host, serverPort = port, useTls = useTls)
}
} }
fun clearError() { fun clearError() {
@@ -297,16 +245,18 @@ class AuthViewModel @Inject constructor(
} }
fun resetState() { fun resetState() {
pendingAuth = null tempPassword = null
_uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys()) _uiState.value = AuthUiState(hasExistingAccount = keyStorage.hasRsaKeys())
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
pendingAuth = null // Ensure key material doesn't linger after ViewModel is destroyed tempPassword = null // don't let the password linger after ViewModel is destroyed
} }
companion object { companion object {
private const val TAG = "AuthViewModel"
fun rsaPublicKeyToPem(key: RSAPublicKey): String { fun rsaPublicKeyToPem(key: RSAPublicKey): String {
val der = key.encoded val der = key.encoded
val base64 = Base64.encodeToString(der, Base64.NO_WRAP) val base64 = Base64.encodeToString(der, Base64.NO_WRAP)