From 9ed29c90ba2da7b16caa8833bbf68e92b015cb00 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 6 Jul 2026 07:33:06 +0000 Subject: [PATCH] chore: improve authentication handling (#37) Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/37 --- .../composeResources/drawable/ic_login.xml | 10 ++ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 2 +- .../su/reya/coop/screens/ImportScreen.kt | 119 +++++++------ .../su/reya/coop/screens/NewIdentityScreen.kt | 6 - .../su/reya/coop/screens/OnboardingScreen.kt | 3 + .../reya/coop/screens/UpdateProfileScreen.kt | 2 - .../su/reya/coop/shared/ProfileEditor.kt | 20 ++- .../su/reya/coop/viewmodel/AuthViewModel.kt | 156 +++++++----------- 8 files changed, 146 insertions(+), 172 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_login.xml diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_login.xml b/composeApp/src/androidMain/composeResources/drawable/ic_login.xml new file mode 100644 index 0000000..820129b --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_login.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index d0283e3..f2fbe89 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -626,7 +626,7 @@ fun NewRequests(requests: List) { val secondRoom = requests.getOrNull(1) val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) - val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel) + val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel) val supportingText = when { total == 1 -> { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index aea7ee5..967b712 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -30,7 +29,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,32 +38,24 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnectUri -import rust.nostr.sdk.PublicKey import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator -import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Screen -import su.reya.coop.shared.Avatar -import su.reya.coop.shared.getExpressiveFontFamily @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -74,24 +64,18 @@ fun ImportScreen() { val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current - val nostrViewModel = LocalNostrViewModel.current val authViewModel = LocalAuthViewModel.current - val scope = rememberCoroutineScope() - val authState by authViewModel.state.collectAsStateWithLifecycle() - val isBusy = authState.isBusy var secret by remember { mutableStateOf("") } - var pubkey by remember { mutableStateOf(null) } - - val profile by remember(pubkey) { - pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) - }.collectAsStateWithLifecycle(null) + var password by remember { mutableStateOf("") } + var requirePassword by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(false) } LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> runCatching { - if (result.startsWith("nsec1")) { + if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) { Keys.parse(result) } else if (result.startsWith("bunker://")) { NostrConnectUri.parse(result) @@ -101,13 +85,19 @@ fun ImportScreen() { }.onSuccess { secret = result }.onFailure { e -> - snackbarHostState.showSnackbar("Invalid secret: ${e.message}") + e.message?.let { snackbarHostState.showSnackbar(it) } } // Clear the nav state qrScanResult.clear() } } + LaunchedEffect(secret) { + if (secret.startsWith("ncryptsec1")) { + requirePassword = true + } + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -147,35 +137,6 @@ fun ImportScreen() { .padding(top = innerPadding.calculateTopPadding()) .imePadding(), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(120.dp) - .clip(MaterialShapes.Cookie9Sided.toShape()), - contentAlignment = Alignment.Center - ) { - Avatar( - picture = profile?.picture, - description = "Profile picture", - modifier = Modifier.fillMaxSize(), - shape = MaterialShapes.Cookie9Sided.toShape(), - ) - } - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = profile?.name ?: "", - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLargeEmphasized.copy( - fontFamily = getExpressiveFontFamily() - ), - ) - } Surface( modifier = Modifier .fillMaxWidth() @@ -186,7 +147,7 @@ fun ImportScreen() { Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(24.dp), ) { Column( modifier = Modifier @@ -203,9 +164,9 @@ fun ImportScreen() { BasicTextField( value = secret, onValueChange = { secret = it }, - enabled = !isBusy, + enabled = !loading, modifier = Modifier.fillMaxWidth(), - maxLines = 4, + singleLine = true, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, ), @@ -237,32 +198,68 @@ fun ImportScreen() { } } ) + Spacer(modifier = Modifier.size(8.dp)) + if (requirePassword) { + Text( + text = "Decrypt Password:", + style = MaterialTheme.typography.titleMediumEmphasized.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + BasicTextField( + value = password, + onValueChange = { password = it }, + enabled = !loading && requirePassword, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + } + ), + visualTransformation = PasswordVisualTransformation('*'), + textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy( + color = MaterialTheme.colorScheme.tertiaryFixedDim, + fontWeight = FontWeight.SemiBold, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + innerTextField() + } + } + ) + } } Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { scope.launch { - if (pubkey == null) { - authViewModel.verifyIdentity(secret).let { pubkey = it } - } else { + loading = true + try { // Import the identity - authViewModel.importIdentity(secret) + authViewModel.importIdentity(secret, password) // Navigate to the home screen navigator.navigate(Screen.Home) + } catch (e: Exception) { + snackbarHostState.showSnackbar(e.message ?: "Error") + loading = false } } - }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = secret.isNotBlank() && !isBusy, + enabled = secret.isNotBlank() && !loading, ) { - if (isBusy) { + if (loading) { LoadingIndicator() } else { Text( - text = if (pubkey == null) "Verify" else "Click again to Continue", + text = "Continue", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 758d75d..80a3393 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -1,9 +1,7 @@ package su.reya.coop.screens import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import su.reya.coop.LocalAuthViewModel import su.reya.coop.LocalNavigator @@ -16,13 +14,9 @@ fun NewIdentityScreen() { val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val authState by authViewModel.state.collectAsStateWithLifecycle() - val isBusy = authState.isBusy - ProfileEditor( title = "Create a new identity", buttonLabel = "Continue", - isBusy = isBusy, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt index bbdf7d6..a042cd7 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -160,7 +160,10 @@ fun OnboardingScreen() { scope.launch { if (authViewModel.isExternalSignerAvailable()) { try { + // Connect to the external signer + // TODO: show all available signers? authViewModel.connectExternalSigner() + // Navigate to the home screen navigator.navigate(Screen.Home) } catch (e: Exception) { e.message?.let { snackbarHostState.showSnackbar(it) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt index 074a9b7..efa531a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/UpdateProfileScreen.kt @@ -15,7 +15,6 @@ fun UpdateProfileScreen() { val navigator = LocalNavigator.current val scope = rememberCoroutineScope() - val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle() val profile = currentUser?.metadata?.asRecord() @@ -25,7 +24,6 @@ fun UpdateProfileScreen() { initialName = profile?.displayName ?: profile?.name ?: "", initialBio = profile?.about ?: "", initialPicture = profile?.picture, - isBusy = isBusy, onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt index 9aab16a..e975f85 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt @@ -68,7 +68,6 @@ fun ProfileEditor( initialName: String = "", initialBio: String = "", initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL) - isBusy: Boolean = false, onBack: () -> Unit, onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit ) { @@ -80,6 +79,7 @@ fun ProfileEditor( var name by remember(initialName) { mutableStateOf(initialName) } var bio by remember(initialBio) { mutableStateOf(initialBio) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) } + var isBusy by remember { mutableStateOf(false) } val hasPicture = remember(picture) { when (picture) { @@ -267,14 +267,20 @@ fun ProfileEditor( .size(ButtonDefaults.MediumContainerHeight), onClick = { scope.launch { - val bytes = withContext(Dispatchers.IO) { - (picture as? Uri)?.let { - context.contentResolver.openInputStream(it)?.readBytes() + isBusy = true + try { + val bytes = withContext(Dispatchers.IO) { + (picture as? Uri)?.let { + context.contentResolver.openInputStream(it)?.readBytes() + } } + val type = + (picture as? Uri)?.let { context.contentResolver.getType(it) } + onConfirm(name, bio, bytes, type) + } catch (e: Exception) { + snackbarHostState.showSnackbar(e.message ?: "Error") } - val type = - (picture as? Uri)?.let { context.contentResolver.getType(it) } - onConfirm(name, bio, bytes, type) + isBusy = false } }, enabled = name.isNotBlank() && !isBusy diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt index 5d8b226..e77db2e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AuthViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.EncryptedSecretKey import rust.nostr.sdk.Keys import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri @@ -20,7 +21,6 @@ import su.reya.coop.storage.SecretStorage import kotlin.time.Duration.Companion.seconds data class AuthState( - val isBusy: Boolean = false, val signerRequired: Boolean? = null, val isNotificationBannerDismissed: Boolean = false, ) @@ -59,7 +59,7 @@ class AuthViewModel( private fun login() { viewModelScope.launch { try { - val secret = withTimeoutOrNull(3.seconds) { + val secret = withTimeoutOrNull(5.seconds) { secretStore.get(KEY_USER_SIGNER) } @@ -69,7 +69,7 @@ class AuthViewModel( } runCatching { - val signer = createSigner(secret) + val (signer, _) = createSigner(secret) nostr.setSigner(signer) }.onSuccess { _state.update { it.copy(signerRequired = false) } @@ -87,8 +87,6 @@ class AuthViewModel( fun logout(onLogout: () -> Unit = {}) { viewModelScope.launch { try { - _state.update { it.copy(isBusy = true) } - // Reset the nostr signer and prune the database nostr.signer.switch(Keys.generate()) nostr.prune() @@ -98,11 +96,10 @@ class AuthViewModel( // Clear credentials from persistent storage secretStore.clear(KEY_USER_SIGNER) secretStore.clear(KEY_BANNER_DISMISSED) - // Call cleanup callback (e.g. to reset other ViewModels) onLogout() - - _state.update { it.copy(isBusy = false, signerRequired = true) } + // Reset local states + _state.update { it.copy(signerRequired = true) } } } } @@ -116,28 +113,35 @@ class AuthViewModel( private suspend fun getOrInitAppKeys(): Keys { val secret = secretStore.get(KEY_APP_KEYS) - // If app keys are already stored, use them - if (secret != null) { - return Keys.parse(secret) - } - + if (secret != null) return Keys.parse(secret) // Generate new app keys and save to the secret storage val keys = Keys.generate() secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32()) - return keys } - private suspend fun createSigner(secret: String): AsyncNostrSigner { + private suspend fun createSigner( + secret: String, + password: String? = null + ): Pair { return when { - secret.startsWith("nsec1") -> Keys.parse(secret) + secret.startsWith("nsec1") -> Keys.parse(secret) to null + + secret.startsWith("ncryptsec1") -> { + if (password == null) throw IllegalArgumentException("Password is required") + val enc = EncryptedSecretKey.fromBech32(secret) + val secret = enc.decrypt(password) + val keys = Keys(secret) + + keys to keys.secretKey().toBech32() + } secret.startsWith("bunker://") -> { val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) val timeout = 50.seconds - NostrConnect(uri = bunker, appKeys, timeout, null) + NostrConnect(uri = bunker, appKeys, timeout, null) to null } secret.startsWith("nip55://") -> { @@ -150,77 +154,50 @@ class AuthViewModel( val pubkey = PublicKey.parse(parts[1]) handler.setPackageName(packageName) - ExternalSignerProxy(handler, pubkey) + ExternalSignerProxy(handler, pubkey) to null } else -> throw IllegalArgumentException("Invalid secret format") } } - suspend fun verifyIdentity(secret: String): PublicKey? { - try { - val signer = createSigner(secret) - if (secret.startsWith("bunker://")) { - showError("Please approve the connection.") - } - return signer.getPublicKeyAsync() - } catch (e: Exception) { - showError("Error: ${e.message}") - return null - } - } - - suspend fun importIdentity(secret: String) { - _state.update { it.copy(isBusy = true) } - try { - val signer = createSigner(secret) - // Update signer - nostr.setSigner(signer) - // Persist the secret in the secret storage - secretStore.set(KEY_USER_SIGNER, secret) - // Update local states - _state.update { it.copy(signerRequired = false, isBusy = false) } - } catch (e: Exception) { - showError("Error: ${e.message}") - _state.update { it.copy(isBusy = false) } - } + suspend fun importIdentity(secret: String, password: String? = null) { + val (signer, decryptedSecret) = createSigner(secret, password) + // Update signer + nostr.setSigner(signer) + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret) + // Update local states + _state.update { it.copy(signerRequired = false) } } suspend fun connectExternalSigner() { val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available") - _state.update { it.copy(isBusy = true) } - try { - val permissions = SignerPermissions.toJson( - listOf( - SignerPermissions.signEvent(0), - SignerPermissions.signEvent(3), - SignerPermissions.signEvent(10000), - SignerPermissions.signEvent(10050), - SignerPermissions.signEvent(10063), - SignerPermissions.signEvent(22242), - SignerPermissions.signEvent(30030), - SignerPermissions.signEvent(30315), - SignerPermissions.nip44Encrypt(), - SignerPermissions.nip44Decrypt(), - ) - ) - val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") - val signer = ExternalSignerProxy(handler, result.pubkey) - - // Update signer - nostr.setSigner(signer) - // Store the signer in the secret storage - secretStore.set( - KEY_USER_SIGNER, - "nip55://${result.packageName}/${result.pubkey.toHex()}" + val permissions = SignerPermissions.toJson( + listOf( + SignerPermissions.signEvent(0), + SignerPermissions.signEvent(3), + SignerPermissions.signEvent(10000), + SignerPermissions.signEvent(10050), + SignerPermissions.signEvent(10063), + SignerPermissions.signEvent(22242), + SignerPermissions.signEvent(30030), + SignerPermissions.signEvent(30315), + SignerPermissions.nip44Encrypt(), + SignerPermissions.nip44Decrypt(), ) - // Update local states - _state.update { it.copy(signerRequired = false, isBusy = false) } - } catch (e: Exception) { - _state.update { it.copy(isBusy = false) } - showError("Notice: ${e.message}") - } + ) + + val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected") + val signer = ExternalSignerProxy(handler, result.pubkey) + + // Update signer + nostr.setSigner(signer) + // Store the signer in the secret storage + secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}") + // Update local states + _state.update { it.copy(signerRequired = false) } } fun isExternalSignerAvailable(): Boolean { @@ -233,27 +210,16 @@ class AuthViewModel( picture: ByteArray?, contentType: String? = null ) { - _state.update { it.copy(isBusy = true) } - val keys = Keys.generate() val secret = keys.secretKey().toBech32() - - try { - val avatarUrl = picture?.let { - mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg") - } - - // Create identity - nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) - - // Persist the secret in the secret storage - secretStore.set(KEY_USER_SIGNER, secret) - - // Update local states - _state.update { it.copy(isBusy = false, signerRequired = false) } - } catch (e: Exception) { - showError("Error: ${e.message}") - _state.update { it.copy(isBusy = false) } + val avatarUrl = picture?.let { + mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg") } + // Create identity + nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl) + // Persist the secret in the secret storage + secretStore.set(KEY_USER_SIGNER, secret) + // Update local states + _state.update { it.copy(signerRequired = false) } } }