Compare commits

...

2 Commits

Author SHA1 Message Date
filip
3d935dcfbf 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>
2026-03-11 23:26:56 +01:00
filip
e36dfe1cee Fix: isolate auto-login errors from confirmation code errors
- confirmRegistration: wrap auto-login in separate try-catch so a failed
  login after confirmation never shows as 'wrong code' to the user.
  If auto-login fails, user is sent to LoginScreen to log in manually.
- SessionManager.login: always reconnect fresh (disconnect + enableReconnect
  + connect) instead of reusing the stale post-registration TCP connection.
  Fixes login failures caused by server closing the registration connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:26:56 +01:00
2 changed files with 114 additions and 145 deletions

View File

@@ -64,9 +64,9 @@ class SessionManager @Inject constructor(
// Re-authenticate automatically whenever the connection is (re)established. // Re-authenticate automatically whenever the connection is (re)established.
// During the initial login() call, lastEmail is null (cleared before connect), // During the initial login() call, lastEmail is null (cleared before connect),
// so this handler is a no-op for the first connection. // so this handler is a no-op for the first connection.
connection.onConnected = { connection.onConnected = reconnect@{
val email = lastEmail ?: return@onConnected val email = lastEmail ?: return@reconnect
val key = lastRsaPrivateKey ?: return@onConnected val key = lastRsaPrivateKey ?: return@reconnect
scope.launch { scope.launch {
try { try {
val session = performAuthHandshake(email, key, lastDeviceId, "Android") val session = performAuthHandshake(email, key, lastDeviceId, "Android")
@@ -100,9 +100,13 @@ class SessionManager @Inject constructor(
lastRsaPrivateKey = null lastRsaPrivateKey = null
try { try {
if (connection.state.value != ConnectionManager.State.CONNECTED) { // Always start with a fresh connection.
connection.connect(host, port, useTls) // This handles stale post-registration connections and ensures reconnect is armed.
if (connection.state.value == ConnectionManager.State.CONNECTED) {
connection.disconnect()
} }
connection.enableReconnect()
connection.connect(host, port, useTls)
val session = performAuthHandshake(email, rsaPrivateKey, deviceId, deviceName) val session = performAuthHandshake(email, rsaPrivateKey, deviceId, deviceName)

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,38 +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 — errors here are genuinely "wrong code"
sessionManager.confirmRegistration(email, code) sessionManager.confirmRegistration(email, code)
// Auto-login immediately after confirmation using the already-decrypted // Step 2: Auto-login exactly as iOS does:
// key material from register(). This avoids re-asking for the password. // call login() with the stored password (keys are already on disk from register())
val auth = pendingAuth val pwd = tempPassword
if (auth != null && auth.email == email) { tempPassword = null
_uiState.update { it.copy(loadingMessage = "Přihlašuji se…") }
val state = _uiState.value if (pwd != null) {
sessionManager.login( // performLogin updates uiState (isLoggedIn = true on success, error on failure)
email = email, performLogin(email, pwd)
rsaPrivateKey = auth.rsaPrivate, } else {
host = state.serverHost, // No stored password (e.g. app restarted between register and confirm).
port = state.serverPort, // Mark registration done and send to LoginScreen.
useTls = state.useTls, _uiState.update {
) it.copy(
// Init local DB encryption key (no password needed — bytes already decrypted) isLoading = false,
keyStorage.initLocalKey(auth.identityPrivateBytes) loadingMessage = null,
pendingAuth = null // consumed — clear for security needsConfirmation = false,
hasExistingAccount = true,
)
}
} }
_uiState.update {
it.copy(
isLoading = false,
loadingMessage = null,
isLoggedIn = true,
needsConfirmation = false,
)
}
} 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) {
@@ -249,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() {
@@ -282,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)