diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index eddec111d..833e2d156 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -40,6 +40,9 @@ class SettingsStore @Inject constructor( val data: Flow = store.data val isPaykitEnabled: Flow = localStore.data.map { it[PAYKIT_ENABLED_KEY] ?: false } + val isPubkyProfileSetupPending: Flow = localStore.data.map { + it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false + } @Volatile var restoredMonitoredTypesFromBackup: Boolean = false @@ -66,6 +69,10 @@ class SettingsStore @Inject constructor( localStore.edit { it[PAYKIT_ENABLED_KEY] = value } } + suspend fun setPubkyProfileSetupPending(value: Boolean) { + localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value } + } + suspend fun addLastUsedTag(newTag: String) { store.updateData { currentSettings -> val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct() @@ -98,6 +105,7 @@ class SettingsStore @Inject constructor( private const val TAG = "SettingsStore" private const val MAX_LAST_USED_TAGS = 10 private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled") + private val PUBKY_PROFILE_SETUP_PENDING_KEY = booleanPreferencesKey("pubky_profile_setup_pending") } } diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 86ce14a80..be1090620 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import to.bitkit.utils.AppError import java.net.URI import java.net.URLDecoder +import java.net.URLEncoder import java.nio.charset.StandardCharsets enum class PubkyAuthClaim(val wireValue: String) { @@ -71,13 +72,23 @@ data class PubkyAuthRequest( val permissions: List, val serviceNames: List, val bitkitClaim: PubkyAuthClaim?, + val homeserverPublicKey: String? = null, + val signupToken: String? = null, + val authorizationUrl: String? = rawUrl, ) { + val isSignup: Boolean + get() = isSignupUrl(rawUrl) + companion object { + @Suppress("LongParameterList") fun parse( rawUrl: String, clientId: String, relay: String, capabilities: String, + homeserverPublicKey: String? = null, + signupToken: String? = null, + authorizationUrl: String? = rawUrl, ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim -> val permissions = parseCapabilities(capabilities) PubkyAuthRequest( @@ -88,9 +99,76 @@ data class PubkyAuthRequest( permissions = permissions, serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(), bitkitClaim = bitkitClaim, + homeserverPublicKey = homeserverPublicKey, + signupToken = signupToken, + authorizationUrl = authorizationUrl, ) } + fun isProtocolUrl(rawUrl: String): Boolean = runCatching { + val uri = URI(rawUrl) + when (uri.scheme?.lowercase()) { + "pubkyauth" -> true + "pubkyring" -> uri.host.equals("signup", ignoreCase = true) + else -> false + } + }.getOrDefault(false) + + fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) + + fun isDirectSignupUrl(rawUrl: String): Boolean = + parseSignup(rawUrl).getOrNull()?.let { it.authorizationUrl == null } ?: false + + fun parseSignup(rawUrl: String): Result = runCatching { + val uri = URI(rawUrl) + require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" } + val query = parseQuery(uri) + val homeserver = query.requiredSingle("hs") + val authorizesApp = uri.authorizesApp(query) + val relay = if (authorizesApp) query.requiredSingle("relay") else "" + val secret = if (authorizesApp) query.requiredSingle("secret") else "" + val capabilities = if (authorizesApp) query.requiredSingle("caps") else "" + val authorizationUrl = if (authorizesApp) { + ringAuthorizationUrl(relay, secret, capabilities) + } else { + null + } + + parse( + rawUrl = rawUrl, + clientId = "", + relay = relay, + capabilities = capabilities, + homeserverPublicKey = homeserver, + signupToken = query.optionalSingle("st"), + authorizationUrl = authorizationUrl, + ).getOrThrow().also { + require(it.bitkitClaim == null) { "Pubky signup does not support Bitkit companion claims" } + } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) }, + ) + + private fun URI.isSignupRequest(): Boolean = when (scheme?.lowercase()) { + "pubkyring" -> host.equals("signup", ignoreCase = true) + "pubkyauth" -> isDirectSignupRequest() + else -> false + } + + private fun URI.isDirectSignupRequest(): Boolean = + scheme.equals("pubkyauth", ignoreCase = true) && (host ?: rawAuthority).let { + it.equals("direct_signup", ignoreCase = true) || it.equals("signup", ignoreCase = true) + } + + private fun URI.authorizesApp(query: Map>): Boolean = + scheme.equals("pubkyring", ignoreCase = true) || + ( + scheme.equals("pubkyauth", ignoreCase = true) && + (host ?: rawAuthority).equals("signup", ignoreCase = true) && + listOf("relay", "secret", "caps").any(query::containsKey) + ) + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = parseBitkitClaimValues(rawUrl).fold( onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, @@ -152,5 +230,31 @@ data class PubkyAuthRequest( } private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + + private fun ringAuthorizationUrl(relay: String, secret: String, capabilities: String): String = + "pubkyauth:///?relay=${encodeQueryComponent(relay)}" + + "&secret=${encodeQueryComponent(secret)}&caps=${encodeQueryComponent(capabilities)}" + + private fun encodeQueryComponent(value: String) = + URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20") + + private fun parseQuery(uri: URI): Map> = uri.rawQuery.orEmpty() + .split("&") + .filter { it.isNotEmpty() } + .map { it.split("=", limit = 2) } + .groupBy( + keySelector = { decodeQueryComponent(it.first()) }, + valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) }, + ) + + private fun Map>.requiredSingle(name: String): String = + optionalSingle(name)?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Missing Pubky signup parameter: $name") + + private fun Map>.optionalSingle(name: String): String? { + val values = this[name].orEmpty() + require(values.size <= 1) { "Duplicate Pubky signup parameter: $name" } + return values.singleOrNull()?.takeIf { it.isNotBlank() } + } } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 7bd294ee0..ffdc95a90 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -75,6 +75,7 @@ sealed class PubkyContactError(message: String) : AppError(message) { } private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active") +data object PubkyAlreadySignedInError : AppError("Already signed in") private enum class AuthAttemptWaitResult { Approved, Inactive } @@ -550,6 +551,16 @@ class PubkyRepo @Inject constructor( tags: List, avatarBytes: ByteArray?, ): Result { + if (settingsStore.isPubkyProfileSetupPending.first()) { + return runSuspendCatching { + withContext(ioDispatcher) { + val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } + val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) + finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl) + } + } + } + var shouldRevokeSessionOnFailure = false return try { val result = runSuspendCatching { @@ -567,8 +578,7 @@ class PubkyRepo @Inject constructor( pubkyService.signIn(secretKeyHex) } - val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - writeProfile(name, bio, links, tags, imageUrl) + val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) shouldRevokeSessionOnFailure = false finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) } @@ -581,6 +591,18 @@ class PubkyRepo @Inject constructor( } } + private suspend fun publishIdentityProfile( + name: String, + bio: String, + links: List, + tags: List, + avatarBytes: ByteArray?, + ): String? { + val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } + writeProfile(name, bio, links, tags, imageUrl) + return imageUrl + } + private suspend fun finishIdentityCreation( publicKey: String, name: String, @@ -602,6 +624,7 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Authenticated } _profile.update { createdProfile } cacheMetadata(createdProfile) + settingsStore.setPubkyProfileSetupPending(false) notifyBackupStateChanged() Logger.info("Created identity for '${redacted(publicKey)}'", context = TAG) loadProfile() @@ -943,8 +966,22 @@ class PubkyRepo @Inject constructor( managedSecretKeyFor(publicKey) != null }.getOrDefault(false) + fun hasIdentity(): Boolean = + _publicKey.value != null || + !keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() || + !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty() + suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { + if (PubkyAuthRequest.isSignupUrl(authUrl)) { + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + pubkyService.validateSignupRequest( + authorizationUrl = request.authorizationUrl, + homeserverPublicKey = requireNotNull(request.homeserverPublicKey), + ) + return@withContext request + } + val details = pubkyService.parseAuthUrl(authUrl) PubkyAuthRequest.parse( rawUrl = authUrl, @@ -955,6 +992,57 @@ class PubkyRepo @Inject constructor( } } + suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + require(request.isSignup) { "Not a Pubky signup request" } + if (hasIdentity()) throw PubkyAlreadySignedInError + + val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() + if (hasIdentity()) throw PubkyAlreadySignedInError + + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + val registeredSession = pubkyService.registerIdentity( + secretKeyHex = secretKeyHex, + homeserverZ32 = requireNotNull(request.homeserverPublicKey), + signupCode = request.signupToken, + ) + request.authorizationUrl?.let { pubkyService.approveRingAuth(it, secretKeyHex) } + var activated = false + try { + pubkyService.activateRegisteredIdentity(registeredSession) + activated = true + } finally { + if (!activated) { + withContext(NonCancellable) { + settingsStore.setPubkyProfileSetupPending(false) + } + } + } + + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + var pendingSaved = false + try { + settingsStore.setPubkyProfileSetupPending(true) + pendingSaved = true + } finally { + if (!pendingSaved) { + withContext(NonCancellable) { + runSuspendCatching { pubkyService.forgetSessionAccess() } + .onFailure { + Logger.warn("Failed to roll back Pubky signup session", it, context = TAG) + } + _publicKey.update { null } + _authState.update { PubkyAuthState.Idle } + } + } + } + notifyBackupStateChanged() + } + } + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -1317,6 +1405,7 @@ class PubkyRepo @Inject constructor( publicPaykitCleanupPending = publicPaykitCleanupPending, ) } + settingsStore.setPubkyProfileSetupPending(false) } private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 39bfef26d..55beff229 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -69,12 +69,14 @@ import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic import com.synonym.paykit.requiredSessionCapabilities import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.Network import to.bitkit.data.keychain.Keychain import to.bitkit.env.Env @@ -266,6 +268,40 @@ class PaykitSdkService @Inject constructor( return result } + suspend fun registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String?, + ): PubkySessionBootstrapResult { + isSetup.await() + return bootstrap().signUp( + localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey = homeserverPublicKey, + signupCode = signupCode, + requiredCapabilities = requiredCapabilities(), + ) + } + + suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { + isSetup.await() + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + operationMutex.withLock { + var activated = false + try { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + activated = true + } finally { + if (!activated) clearRegisteredIdentityActivationLocked() + } + } + notifyBackupStateChanged() + } + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } @@ -865,6 +901,15 @@ class PaykitSdkService @Inject constructor( publishReceiverMarkerIfLiveSessionAvailable(handle) } + private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) { + runSuspendCatching { sessionProvider.clearSessionAccess() } + .onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) } + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } + .onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) } + resetRuntime() + notifyBackupStateChanged() + } + private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) { runSuspendCatching { val capabilities = receiverCapabilities(handle) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 79bebe088..ac3a82bf7 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,14 +1,18 @@ package to.bitkit.services +import com.synonym.bitkitcore.approvePubkyAuth import com.synonym.paykit.ContactProfileResolution import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile +import com.synonym.paykit.PaykitPublicKeys import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkySessionBootstrapResult import to.bitkit.async.ServiceQueue import to.bitkit.ext.runSuspendCatching import to.bitkit.utils.AppError import javax.inject.Inject import javax.inject.Singleton +import com.synonym.bitkitcore.parsePubkyAuthUrl as parseLegacyPubkyAuthUrl @Suppress("TooManyFunctions") @Singleton @@ -76,6 +80,19 @@ class PubkyService @Inject constructor( Unit } + suspend fun registerIdentity( + secretKeyHex: String, + homeserverZ32: String, + signupCode: String?, + ): PubkySessionBootstrapResult = + ServiceQueue.CORE.background { + paykitSdkService.registerIdentity(secretKeyHex, homeserverZ32, signupCode) + } + + suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) = ServiceQueue.CORE.background { + paykitSdkService.activateRegisteredIdentity(result) + } + suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background { paykitSdkService.signIn(secretKeyHex) Unit @@ -106,6 +123,13 @@ class PubkyService @Inject constructor( PaykitSdkService.parseAuthUrl(url) } + suspend fun validateSignupRequest(authorizationUrl: String?, homeserverPublicKey: String): Unit = + ServiceQueue.CORE.background { + authorizationUrl?.let { parseLegacyPubkyAuthUrl(it) } + PaykitPublicKeys.normalize(homeserverPublicKey) + Unit + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -115,6 +139,10 @@ class PubkyService @Inject constructor( paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } + suspend fun approveRingAuth(authUrl: String, secretKeyHex: String) = ServiceQueue.CORE.background { + approvePubkyAuth(authUrl, secretKeyHex) + } + suspend fun approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 46113e75b..0dad141b0 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -7,8 +7,11 @@ import android.content.Intent import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState @@ -27,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -63,8 +67,11 @@ import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen +import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.DefaultSheetContainerColor import to.bitkit.ui.components.DrawerMenu +import to.bitkit.ui.components.GradientCircularProgressIndicator +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.Sheet import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost @@ -448,6 +455,7 @@ fun ContentView( val hasSeenWidgetsIntro by settingsViewModel.hasSeenWidgetsIntro.collectAsStateWithLifecycle() val hasSeenShopIntro by settingsViewModel.hasSeenShopIntro.collectAsStateWithLifecycle() val hasSeenProfileIntro by settingsViewModel.hasSeenProfileIntro.collectAsStateWithLifecycle() + val isPubkyProfileSetupPending by settingsViewModel.isPubkyProfileSetupPending.collectAsStateWithLifecycle() val hasSeenContactsIntro by settingsViewModel.hasSeenContactsIntro.collectAsStateWithLifecycle() val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() val hasPubkyContacts by settingsViewModel.hasPubkyContacts.collectAsStateWithLifecycle() @@ -455,6 +463,7 @@ fun ContentView( val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() val isCreatingPaymentRequest by appViewModel.isCreatingPaymentRequest.collectAsStateWithLifecycle() + val isCompletingPubkySignup by appViewModel.isCompletingPubkySignup.collectAsStateWithLifecycle() val hwSendViewModel = hiltViewModel() val hwSendUiState by hwSendViewModel.uiState.collectAsStateWithLifecycle() val canDismissSheet = currentSheet !is Sheet.Send || @@ -622,6 +631,7 @@ fun ContentView( ) { Box(modifier = Modifier.fillMaxSize()) { var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + var didResumePendingPubkyProfileSetup by remember { mutableStateOf(false) } RootNavHost( navController = navController, @@ -642,6 +652,26 @@ fun ContentView( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + LaunchedEffect( + isPaykitEnabled, + isPubkyProfileSetupPending, + isProfileAuthenticated, + currentSheet, + currentRoute, + ) { + if (!isPubkyProfileSetupPending) { + didResumePendingPubkyProfileSetup = false + } + val canNavigate = currentSheet == null && + currentRoute != Routes.CreateProfile::class.qualifiedName + val shouldResumeProfileSetup = isPaykitEnabled && + isPubkyProfileSetupPending && + isProfileAuthenticated + if (shouldResumeProfileSetup && canNavigate && !didResumePendingPubkyProfileSetup) { + didResumePendingPubkyProfileSetup = true + navController.navigateTo(Routes.CreateProfile) + } + } val currentHardwareWalletId = navBackStackEntry ?.takeIf { it.destination.hasRoute() } ?.toRoute() @@ -697,6 +727,21 @@ fun ContentView( onOpenWidgetsSheet = { appViewModel.showSheet(Sheet.Widgets()) }, modifier = Modifier.align(Alignment.TopEnd) ) + + if (isCompletingPubkySignup) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Colors.Black), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) + HorizontalSpacer(12.dp) + BodyM(text = stringResource(R.string.profile__deriving_keys), color = Colors.White64) + } + } + } } } } @@ -1498,7 +1543,7 @@ private fun NavGraphBuilder.shop( page = it.toRoute().page, title = it.toRoute().title, onPaymentIntent = { data -> - appViewModel.onScanResult(data) + appViewModel.onScanResult(data, allowPubkyAuth = false) }, onBlockedNavigation = { appViewModel.toast( diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt index 8cb85ac8a..ab8632c00 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt @@ -382,13 +382,17 @@ private fun ColumnScope.ApprovalDetails( DescriptionText(serviceName = uiState.serviceName) VerticalSpacer(8.dp) - BodyS( - text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId), - color = Colors.White64, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - VerticalSpacer(32.dp) + if (uiState.clientId.isNotBlank()) { + BodyS( + text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + VerticalSpacer(32.dp) + } else { + VerticalSpacer(24.dp) + } PermissionsSection(permissions = uiState.permissions) FillHeight(min = 32.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt index 516ac5705..e95a4a704 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModel.kt @@ -24,6 +24,7 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.models.WatchOnlyAccountSetupState +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.WatchOnlyAccountAuthorizationStartError import to.bitkit.repositories.WatchOnlyAccountRepo @@ -171,6 +172,10 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) + if (request.isSignup) { + _effects.emit(PubkyAuthApprovalEffect.Dismiss) + return + } _uiState.update { state -> if (state.authUrl == authUrl) state.copy(state = ApprovalState.Success) else state } @@ -179,6 +184,21 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, + ): Boolean = if (request.isSignup) { + pubkyRepo.approveSignupAuth(request).fold( + onSuccess = { true }, + onFailure = { + handleApprovalFailure(it, authUrl) + false + }, + ) + } else { + approveSignInRequest(request, authUrl) + } + + private suspend fun approveSignInRequest( + request: PubkyAuthRequest, + authUrl: String, ): Boolean { val preparedClaim = runSuspendCatching { if (request.bitkitClaim == PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1) { @@ -273,8 +293,16 @@ class PubkyAuthApprovalViewModel @Inject constructor( } private suspend fun handleApprovalFailure(error: Throwable, authUrl: String) { - Logger.error("Auth approval failed", error, context = TAG) if (_uiState.value.authUrl != authUrl) return + if (error is PubkyAlreadySignedInError) { + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.pubky_auth__already_signed_in), + ) + _effects.emit(PubkyAuthApprovalEffect.Dismiss) + return + } + Logger.error("Auth approval failed", error, context = TAG) _uiState.update { it.copy(state = ApprovalState.Authorize) } ToastEventBus.send( type = Toast.ToastType.ERROR, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 91dfb4879..8cd5ab3f4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -114,6 +114,7 @@ import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.NodeLifecycleState +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkyRingAuthCallback @@ -130,6 +131,7 @@ import to.bitkit.models.WalletScope import to.bitkit.models.msatFloorOf import to.bitkit.models.safe import to.bitkit.models.sanitizedDeeplinkLogValue +import to.bitkit.models.sanitizedQrLogValue import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork import to.bitkit.models.toTxType @@ -161,6 +163,7 @@ import to.bitkit.repositories.PendingPaymentResolution import to.bitkit.repositories.PreActivityMetadataRepo import to.bitkit.repositories.PrivatePaykitPaymentContext import to.bitkit.repositories.PrivatePaykitRepo +import to.bitkit.repositories.PubkyAlreadySignedInError import to.bitkit.repositories.PubkyRepo import to.bitkit.repositories.PublicPaykitPaymentResult import to.bitkit.repositories.PublicPaykitRepo @@ -182,6 +185,7 @@ import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.hardware.HardwareRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.ScreenDeepLinks +import to.bitkit.ui.utils.localizedPubkyAuthMessage import to.bitkit.usecases.FormatMoneyValue import to.bitkit.usecases.RefreshContactPaykitReceiversUseCase import to.bitkit.utils.AppError @@ -321,6 +325,8 @@ class AppViewModel @Inject constructor( private val _currentSheet: MutableStateFlow = MutableStateFlow(null) val currentSheet = _currentSheet.asStateFlow() + private val _isCompletingPubkySignup = MutableStateFlow(false) + val isCompletingPubkySignup = _isCompletingPubkySignup.asStateFlow() val pendingPaymentRequests = paykitPaymentRequestRepo.pendingRequests val paymentRequestHistory = paykitPaymentRequestRepo.paymentRequestHistory val eligiblePaymentRequestTargets = paykitPaymentRequestRepo.eligibleTargets @@ -1709,7 +1715,7 @@ class AppViewModel @Inject constructor( // Skip validation for empty input if (valueWithoutSpaces.isEmpty()) return - if (valueWithoutSpaces.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) return + if (PubkyAuthRequest.isProtocolUrl(valueWithoutSpaces)) return if (PubkyPublicKeyFormat.normalized(valueWithoutSpaces) != null) { if (isPaykitEnabled.value) { @@ -1921,21 +1927,15 @@ class AppViewModel @Inject constructor( routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, preserveUntilComplete: Boolean = false, + allowPubkyAuth: Boolean = isMainScanner, ) { if (!_isAuthenticated.value) { - enqueueDeferredScan( - source = source, - data = data, - startDelay = startDelay, - routePubkyKeys = routePubkyKeys, - contactPaymentContext = contactPaymentContext, - ) + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) return } val normalized = data.removeLightningSchemes() val scanId = scanLogId(data) - val scheduled = scheduledScan val isSameActiveScan = normalized == scheduled?.normalizedInput && scheduled.job.isActive && @@ -1946,16 +1946,16 @@ class AppViewModel @Inject constructor( } if (scheduled?.job?.isActive == true && scheduled.mustComplete) { - enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext) + enqueueDeferredScan(source, data, startDelay, routePubkyKeys, contactPaymentContext, allowPubkyAuth) return } val previousJob = scheduled?.job val nextJob = viewModelScope.launch(start = CoroutineStart.LAZY) { scanMutex.withLock { - setActiveContactPaymentContext(contactPaymentContext) + prepareContactPaymentContextForScan(normalized, allowPubkyAuth, contactPaymentContext) if (startDelay > Duration.ZERO) delay(startDelay) - handleScan(data, routePubkyKeys) + handleScan(data, routePubkyKeys, contactPaymentContext, allowPubkyAuth) } } val nextScheduledScan = ScheduledScan( @@ -1981,7 +1981,7 @@ class AppViewModel @Inject constructor( } private fun scanLogId(data: String): String { - val scanLogInput = SamRockSetupRequest.sanitizedDescription(data.removeLightningSchemes()) ?: data + val scanLogInput = data.removeLightningSchemes().sanitizedQrLogValue() return if (scanLogInput.length > SCAN_LOG_ID_MAX_LENGTH) { "${scanLogInput.take(SCAN_LOG_ID_AFFIX_LENGTH)}…${scanLogInput.takeLast(SCAN_LOG_ID_AFFIX_LENGTH)}" } else { @@ -1995,6 +1995,7 @@ class AppViewModel @Inject constructor( startDelay: Duration, routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) { val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() @@ -2008,6 +2009,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) return } @@ -2026,6 +2028,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) } Logger.info("Queuing '${source.label}' scan for deferred handling: '$scanId'", context = TAG) @@ -2064,6 +2067,7 @@ class AppViewModel @Inject constructor( routePubkyKeys = pending.routePubkyKeys, contactPaymentContext = pending.contactPaymentContext, preserveUntilComplete = true, + allowPubkyAuth = pending.allowPubkyAuth, ) } @@ -2415,6 +2419,7 @@ class AppViewModel @Inject constructor( startDelay: Duration = Duration.ZERO, routePubkyKeys: Boolean = false, contactPaymentContext: ContactPaymentContext? = null, + allowPubkyAuth: Boolean = isMainScanner, ) { launchScan( source = ScanSource.SCAN_RESULT, @@ -2422,6 +2427,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) } @@ -2436,7 +2442,11 @@ class AppViewModel @Inject constructor( privatePaymentContext = privatePaymentContext, incomingPaymentRequest = incomingPaymentRequest, ) - onScanResult(paymentRequest, contactPaymentContext = context) + onScanResult( + data = paymentRequest, + contactPaymentContext = context, + allowPubkyAuth = false, + ) } fun preserveContactPaymentContext(paymentHash: String) { @@ -2453,7 +2463,21 @@ class AppViewModel @Inject constructor( private suspend fun handleScan( result: String, routePubkyKeys: Boolean, + contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) = withContext(bgDispatcher) { + val input = result.removeLightningSchemes() + + if (PubkyAuthRequest.isProtocolUrl(input) && !allowPubkyAuth) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) + clearRejectedContactPaymentContext(contactPaymentContext) + return@withContext + } + val contactPaymentProfile = activeContactPaymentProfile() val isPaymentRequest = activeIncomingPaymentRequest() != null // always reset state on new scan @@ -2461,7 +2485,6 @@ class AppViewModel @Inject constructor( resetQuickPay() val fromMainScanner = isMainScanner - val input = result.removeLightningSchemes() // TODO Workaround for https://github.com/synonymdev/bitkit-core/issues/63 if (Bip21Utils.isDuplicatedBip21(input)) { @@ -2486,16 +2509,9 @@ class AppViewModel @Inject constructor( return@withContext } - if (input.startsWith("$PUBKYAUTH_SCHEME://", ignoreCase = true)) { + if (PubkyAuthRequest.isProtocolUrl(input)) { clearActiveContactPaymentContext() - if (!fromMainScanner) { - hideSheet() - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), - ) - } else if (isPaykitEnabled.value) { + if (isPaykitEnabled.value) { handlePubkyAuth(input) } else { hideSheet() @@ -2624,12 +2640,7 @@ class AppViewModel @Inject constructor( if (interruptedRequest == null) return if (!retryIncomingRequest) { - paymentRequestPresentationGeneration++ - if (requestedPaymentRequestId == interruptedRequest.id) { - requestedPaymentRequestId = null - } - clearPaymentRequestPresentationRetry(interruptedRequest.id) - viewModelScope.launch { paykitPaymentRequestRepo.markPresented(interruptedRequest) } + viewModelScope.launch { markIncomingPaymentRequestPresented(interruptedRequest) } return } @@ -2642,6 +2653,25 @@ class AppViewModel @Inject constructor( isSubmittingPaymentRequest = false } + private suspend fun clearRejectedContactPaymentContext(context: ContactPaymentContext?) { + val request = context?.incomingPaymentRequest ?: return + synchronized(contactPaymentContextLock) { + if (activeContactPaymentContext != context) return + activeContactPaymentContext = null + preparedContactPaymentContext = null + } + markIncomingPaymentRequestPresented(request) + } + + private suspend fun markIncomingPaymentRequestPresented(request: PaykitPaymentRequest) { + paymentRequestPresentationGeneration++ + if (requestedPaymentRequestId == request.id) { + requestedPaymentRequestId = null + } + clearPaymentRequestPresentationRetry(request.id) + paykitPaymentRequestRepo.markPresented(request) + } + private fun setActiveContactPaymentContext(context: ContactPaymentContext?) { synchronized(contactPaymentContextLock) { if (activeContactPaymentContext != context) preparedContactPaymentContext = null @@ -2649,6 +2679,15 @@ class AppViewModel @Inject constructor( } } + private fun prepareContactPaymentContextForScan( + input: String, + allowPubkyAuth: Boolean, + context: ContactPaymentContext?, + ) { + val preservesExistingContext = PubkyAuthRequest.isProtocolUrl(input) && !allowPubkyAuth && context == null + if (!preservesExistingContext) setActiveContactPaymentContext(context) + } + private fun clearPendingContactPaymentContext(paymentHash: String) { synchronized(contactPaymentContextLock) { pendingContactPaymentContexts.remove(paymentHash) @@ -4629,7 +4668,7 @@ class AppViewModel @Inject constructor( return@launch } - if (uri.scheme == PUBKYAUTH_SCHEME) { + if (PubkyAuthRequest.isProtocolUrl(uri.toString())) { if (!isPaykitEnabled.value) return@launch handlePubkyAuth(uri.toString()) return@launch @@ -4653,7 +4692,15 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { - if (pubkyRepo.publicKey.value == null) { + val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) + if (isSignup && rejectPubkySignupForExistingIdentity()) return + + if (PubkyAuthRequest.isDirectSignupUrl(authUrl)) { + handleDirectPubkySignup(authUrl) + return + } + + if (!isSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -4662,7 +4709,7 @@ class AppViewModel @Inject constructor( return } - if (!pubkyRepo.hasSecretKey()) { + if (!isSignup && !pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.profile__auth_approval_ring_only), @@ -4672,6 +4719,55 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun handleDirectPubkySignup(authUrl: String) { + hideSheet() + _isCompletingPubkySignup.value = true + try { + val request = pubkyRepo.parseAuthUrl(authUrl).getOrElse { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = it.localizedPubkyAuthMessage(context), + ) + return + } + pubkyRepo.approveSignupAuth(request).onFailure { + val alreadySignedIn = it is PubkyAlreadySignedInError + ToastEventBus.send( + type = if (alreadySignedIn) Toast.ToastType.INFO else Toast.ToastType.ERROR, + title = context.getString( + if (alreadySignedIn) { + R.string.pubky_auth__already_signed_in + } else { + R.string.profile__auth_error_title + }, + ), + description = if (alreadySignedIn) null else it.localizedPubkyAuthMessage(context), + ) + } + } finally { + _isCompletingPubkySignup.value = false + } + } + + private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { + val hasIdentity = runCatching { pubkyRepo.hasIdentity() }.getOrElse { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__auth_error_title), + description = it.localizedPubkyAuthMessage(context), + ) + return true + } + if (!hasIdentity) return false + + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = context.getString(R.string.pubky_auth__already_signed_in), + ) + return true + } + private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) { when (val result = pubkyRepo.handleAuthCallback(callback)) { is PubkyRingAuthCallbackHandlingResult.TrustedError -> { @@ -4756,7 +4852,6 @@ class AppViewModel @Inject constructor( private val PUBLIC_PAYKIT_SYNC_DEBOUNCE = 1.seconds private val PUBLIC_PAYKIT_BOLT11_REFRESH_WINDOW = 30.minutes private const val BITKIT_SCHEME = "bitkit" - private const val PUBKYAUTH_SCHEME = "pubkyauth" private const val RECOVERY_MODE_DEEPLINK = "recovery-mode" /** Max characters kept in a scan log id before truncating. */ @@ -4793,6 +4888,7 @@ private data class DeferredScan( val startDelay: Duration, val routePubkyKeys: Boolean, val contactPaymentContext: ContactPaymentContext?, + val allowPubkyAuth: Boolean, ) // region send contract diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index e406efd3e..4afc06582 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -130,6 +130,9 @@ class SettingsViewModel @Inject constructor( val hasSeenProfileIntro = settingsStore.data.map { it.hasSeenProfileIntro } .asStateFlow(initialValue = false) + val isPubkyProfileSetupPending = settingsStore.isPubkyProfileSetupPending + .asStateFlow(initialValue = false) + fun setHasSeenProfileIntro(value: Boolean) { viewModelScope.launch { settingsStore.update { it.copy(hasSeenProfileIntro = value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 10f3e8262..40b491379 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -682,6 +682,7 @@ Suggestions To Add Your Name Your Pubky + Already signed in Pubky Identity Required Create a Pubky identity in your profile to approve auth requests. Back Up diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 1577dd834..c9102a65e 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,13 +1,61 @@ package to.bitkit.models +import java.net.URLEncoder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue class PubkyAuthRequestTest { + @Test + fun `parse authorized signup preserves registration and authorization details`() { + listOf("pubkyring", "pubkyauth").forEach { scheme -> + val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code", scheme)).getOrThrow() + + assertTrue(request.isSignup) + assertEquals("homeserver", request.homeserverPublicKey) + assertEquals("invite code", request.signupToken) + assertEquals("https://relay.example/inbox/", request.relay) + assertEquals("/pub/example.app/:rw", request.capabilities) + assertEquals( + "pubkyauth:///?relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw", + request.authorizationUrl, + ) + assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) + } + } + + @Test + fun `parse direct signup accepts canonical and legacy formats`() { + listOf("direct_signup", "signup").forEach { action -> + val request = PubkyAuthRequest.parseSignup(directSignupUrl(action, "invite code")).getOrThrow() + + assertTrue(request.isSignup) + assertEquals("homeserver", request.homeserverPublicKey) + assertEquals("invite code", request.signupToken) + assertEquals("", request.relay) + assertEquals("", request.capabilities) + assertNull(request.authorizationUrl) + } + } + + @Test + fun `parse Ring signup rejects missing and duplicate required values`() { + val invalidUrls = listOf( + ringSignupUrl().replace("&secret=secret", ""), + "${ringSignupUrl()}&hs=other", + directSignupUrl("signup") + "&relay=https%3A%2F%2Frelay.example", + ) + + invalidUrls.forEach { url -> + assertIs(PubkyAuthRequest.parseSignup(url).exceptionOrNull()) + } + } + @Test fun `parse recognizes watch-only account claim`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES @@ -50,6 +98,8 @@ class PubkyAuthRequestTest { capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() + assertFalse(request.isSignup) + assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -261,4 +311,14 @@ class PubkyAuthRequestTest { } return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" } + + private fun ringSignupUrl(signupToken: String? = null, scheme: String = "pubkyring"): String = + "$scheme://signup?hs=homeserver" + + "&relay=https%3A%2F%2Frelay.example%2Finbox%2F" + + "&secret=secret&caps=%2Fpub%2Fexample.app%2F%3Arw" + + signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty() + + private fun directSignupUrl(action: String, signupToken: String? = null): String = + "pubkyauth://$action?hs=homeserver" + + signupToken?.let { "&st=${URLEncoder.encode(it, Charsets.UTF_8.name())}" }.orEmpty() } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7c97d4296..45ec1a4ce 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -9,6 +9,7 @@ import com.synonym.paykit.ContactProfileSource import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PubkyAuthCompanionClaim +import com.synonym.paykit.PubkySessionBootstrapResult import com.synonym.paykit.PublicationStatus import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -41,6 +42,7 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.models.PubkyAuthClaim +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyRingAuthCallback import to.bitkit.models.PubkyRingAuthCallbackHandlingResult @@ -74,12 +76,18 @@ class PubkyRepoTest : BaseUnitTest() { private val pubkyStore = mock() private val settingsStore = mock() private val settingsFlow = MutableStateFlow(SettingsData()) + private val profileSetupPending = MutableStateFlow(false) @Before fun setUp() = runBlocking { settingsFlow.value = SettingsData() whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData())) whenever(settingsStore.data).thenReturn(settingsFlow) + whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(profileSetupPending) + whenever { settingsStore.setPubkyProfileSetupPending(any()) }.thenAnswer { + profileSetupPending.value = it.getArgument(0) + Unit + } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) @@ -105,6 +113,95 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } + @Test + fun `Ring signup registers and authorizes before activating the local session`() = test { + val events = mutableListOf() + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenAnswer { + events += "register" + registeredSession + } + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")).thenAnswer { + events += "authorize" + } + whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { events += "activate" } + + val result = sut.approveSignupAuth(request) + + assertTrue(result.isSuccess) + assertEquals(listOf("register", "authorize", "activate"), events) + verifyBlocking(pubkyService, never()) { signIn(any()) } + assertTrue(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `Ring signup does not activate the registered session when authorization fails`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")) + .thenThrow(IllegalStateException("authorization failed")) + + assertTrue(sut.approveSignupAuth(request).isFailure) + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } + assertFalse(profileSetupPending.value) + assertNull(sut.publicKey.value) + } + + @Test + fun `Ring signup clears profile setup state when local activation fails`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + profileSetupPending.value = true + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { + throw TestAppError("activation failed") + } + + assertTrue(sut.approveSignupAuth(request).isFailure) + assertFalse(profileSetupPending.value) + assertNull(sut.publicKey.value) + } + + @Test + fun `direct signup skips app authorization and activates the registered session`() = test { + val registeredSession = mock() + val request = directSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + + assertTrue(sut.approveSignupAuth(request).isSuccess) + verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any()) } + verifyBlocking(pubkyService) { activateRegisteredIdentity(registeredSession) } + assertTrue(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `Ring signup stops when registration fails`() = test { + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")) + .thenThrow(IllegalStateException("registration failed")) + + assertTrue(sut.approveSignupAuth(request).isFailure) + verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any()) } + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } + assertFalse(profileSetupPending.value) + } + + @Test + fun `identity check fails closed when secure storage cannot be read`() = test { + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenThrow(IllegalStateException("unavailable")) + + assertTrue(runCatching { sut.hasIdentity() }.isFailure) + } + @Test fun `startAuthentication should return auth uri on success`() = test { val authUri = "pubky://auth?capabilities=..." @@ -516,6 +613,31 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { forgetSessionAccess() } } + @Test + fun `createIdentity should preserve signup session when pending profile publication fails`() = test { + val registeredSession = mock() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + assertTrue(sut.approveSignupAuth(ringSignupRequest()).isSuccess) + clearInvocations(pubkyService) + whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + + val result = sut.createIdentity( + name = "Test", + bio = "", + links = emptyList(), + tags = emptyList(), + avatarBytes = null, + ) + + assertTrue(result.isFailure) + verifyBlocking(pubkyService) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signOut() } + assertTrue(profileSetupPending.value) + } + @Test fun `createIdentity should keep session when canceled during contact load`() = test { val contactsLoadStarted = CompletableDeferred() @@ -1542,6 +1664,21 @@ class PubkyRepoTest : BaseUnitTest() { status = status, ) + private suspend fun stubSignupKeys() { + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("seed words") + whenever(pubkyService.deriveSecretKey("seed words")).thenReturn("secret") + whenever(pubkyService.publicKeyFromSecret("secret")).thenReturn(VALID_SELF_KEY) + } + + private fun ringSignupRequest() = PubkyAuthRequest.parseSignup( + "pubkyring://signup?hs=homeserver&relay=https%3A%2F%2Frelay.example" + + "&secret=request&caps=%2Fpub%2Fexample%2F%3Arw&st=invite", + ).getOrThrow() + + private fun directSignupRequest() = PubkyAuthRequest.parseSignup( + "pubkyauth://direct_signup?hs=homeserver&st=invite", + ).getOrThrow() + private fun createPaykitProfile( name: String, bio: String = "", diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt index bc6617bd8..9c28ad776 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyAuthApprovalViewModelTest.kt @@ -146,6 +146,26 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } } + @Test + fun `Ring signup delegates registration and authorization to Pubky repository`() = test { + val authUrl = "pubkyring://signup?hs=homeserver" + val request = authRequest( + authUrl = authUrl, + capabilities = "/pub/example/:rw", + ) + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn(Result.success(request)) + whenever { pubkyRepo.approveSignupAuth(request) }.thenReturn(Result.success(Unit)) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } + } + @Test fun `load exposes watch-only account claim for approval`() = test { val authUrl = "pubkyauth://signin?caps=${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}" @@ -551,6 +571,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, + homeserverPublicKey = if (PubkyAuthRequest.isSignupUrl(authUrl)) "homeserver" else null, ) private fun watchOnlyAccount() = WatchOnlyAccountRecord( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 75eb224dd..b61a16871 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -56,6 +56,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -78,6 +79,7 @@ import to.bitkit.models.HwWalletReceivedTx import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.SamRockPaymentMethod import to.bitkit.models.SamRockSetupRequest @@ -219,6 +221,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val paykitPaymentRequestHistory = MutableStateFlow>(emptyList()) private val surfacedPaykitPaymentRequestIds = mutableSetOf() private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private val signupAuthUrl = + "pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw" + private val legacyAuthorizedSignupAuthUrl = signupAuthUrl.replace("pubkyring://", "pubkyauth://") + private val directSignupAuthUrl = "pubkyauth://direct_signup?hs=homeserver&st=invite" + private val legacyDirectSignupAuthUrl = "pubkyauth://signup?hs=homeserver&st=invite" private val timedSheetManager = mock() private val timedSheetType = MutableStateFlow(null) @@ -280,6 +287,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever { lightningRepo.updateGeoBlockState() }.thenReturn(Unit) whenever(pubkyRepo.sessionRestorationFailed).thenReturn(MutableStateFlow(false)) whenever(pubkyRepo.publicKey).thenReturn(pubkyPublicKey) + whenever(pubkyRepo.hasIdentity()).thenAnswer { pubkyPublicKey.value != null } whenever(pubkyRepo.contacts).thenReturn(pubkyContacts) whenever { refreshContactPaykitReceivers(any()) }.thenReturn(Result.success(Unit)) whenever { publicPaykitRepo.syncLocalReceiverMarker(anyOrNull(), anyOrNull()) } @@ -1918,21 +1926,62 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `global scanner accepts authorized signup without an existing identity`() = test { + enablePaykitUi() + + listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl -> + scanSignup(authUrl) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } + verify(pubkyRepo, never()).hasSecretKey() + } + + @Test + fun `global scanner processes direct signup without auth sheet`() = test { + enablePaykitUi() + listOf(directSignupAuthUrl, legacyDirectSignupAuthUrl).forEach { authUrl -> + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request)) + whenever(pubkyRepo.approveSignupAuth(request)).thenReturn(Result.success(Unit)) + + scanSignup(authUrl) + + assertNull(sut.currentSheet.value) + verifyBlocking(pubkyRepo) { approveSignupAuth(request) } + } + } + + @Test + fun `signup scan stops when already signed in`() = test { + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(context.getString(R.string.pubky_auth__already_signed_in)).thenReturn("Already signed in") + scanSignup(directSignupAuthUrl) + + assertNull(sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + verify(toastManager).enqueue(check { assertEquals("Already signed in", it.title) }) + } + @Test fun `send paste rejects pubky auth`() = test { - val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + val authUrl = directSignupAuthUrl + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) val clipData = mock() val item = mock() whenever(item.text).thenReturn(authUrl) whenever(clipData.getItemAt(0)).thenReturn(item) whenever(clipboardManager.primaryClip).thenReturn(clipData) sut.showSheet(Sheet.Send()) + setSendState(paymentState) advanceUntilIdle() sut.setSendEvent(SendEvent.Paste) advanceUntilIdle() - assertNull(sut.currentSheet.value) + assertEquals(Sheet.Send(), sut.currentSheet.value) + assertEquals(paymentState, sut.sendUiState.value) verify(pubkyRepo, never()).hasSecretKey() verify(coreService, never()).decode(any()) verify(toastManager).enqueue(any()) @@ -1941,18 +1990,54 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `send scanner rejects pubky auth`() = test { val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) sut.showSheet(Sheet.Send()) + setSendState(paymentState) + setActiveContactPaymentContext(testPublicKey) advanceUntilIdle() sut.onScanResult(authUrl) advanceUntilIdle() - assertNull(sut.currentSheet.value) + assertEquals(Sheet.Send(), sut.currentSheet.value) + assertEquals(paymentState, sut.sendUiState.value) + assertEquals(testPublicKey, activeContactPaymentContext()?.publicKey) verify(pubkyRepo, never()).hasSecretKey() verify(coreService, never()).decode(any()) verify(toastManager).enqueue(any()) } + @Test + fun `incoming payment target rejects pubky auth without clearing payment state`() = test { + val request = paymentRequest() + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) + setSendState(paymentState) + pendingPaykitPaymentRequests.value = listOf(request) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + whenever(paykitPaymentRequestRepo.refresh()).thenReturn(Result.success(Unit)) + stubOpenedPaymentRequest(request, signupAuthUrl) + + sut.onHomeResumed() + advanceUntilIdle() + + assertEquals(paymentState, sut.sendUiState.value) + assertNull(activeContactPaymentContext()) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo).markPresented(request) + verify(pubkyRepo, never()).parseAuthUrl(any()) + } + + @Test + fun `signup scan stops when secure identity storage is unavailable`() = test { + enablePaykitUi() + whenever(pubkyRepo.hasIdentity()).thenThrow(IllegalStateException("storage unavailable")) + scanSignup() + + assertNull(sut.currentSheet.value) + verify(toastManager).enqueue(any()) + } + @Test fun `manual address input rejects pubky auth without decoding`() = test { val authUrl = "pubkyauth://auth?caps=/pub/paykit/v0/:rw" @@ -5016,6 +5101,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } + private suspend fun TestScope.scanSignup(authUrl: String = signupAuthUrl) { + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult(authUrl) + advanceUntilIdle() + } + private fun samRockSetupRequest() = SamRockSetupRequest( postUrl = "https://btcpay.example.com/plugins/store/samrock/protocol?setup=btc-chain&otp=secret", storeId = "store", diff --git a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt index 8dfd31cab..08aea6f1b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/SettingsViewModelTest.kt @@ -61,6 +61,7 @@ class SettingsViewModelTest : BaseUnitTest() { fun setUp() { whenever(settingsStore.data).thenReturn(settingsData) whenever(settingsStore.isPaykitEnabled).thenReturn(isPaykitEnabled) + whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(MutableStateFlow(false)) whenever(contactPaymentSettingsRepo.isEnabled).thenReturn(contactPaymentsEnabled) whenever { contactPaymentSettingsRepo.setEnabled(any()) }.thenReturn(Result.success(Unit)) whenever { settingsStore.update(any()) }.thenAnswer { diff --git a/changelog.d/next/1224.added.md b/changelog.d/next/1224.added.md new file mode 100644 index 000000000..fe1fcd278 --- /dev/null +++ b/changelog.d/next/1224.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests.