From 2237b38a8354870e99b701174066abd4ecc89875 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:21:58 -0500 Subject: [PATCH 1/5] feat: support Pubky Ring signup --- .../main/java/to/bitkit/data/SettingsStore.kt | 8 ++ .../java/to/bitkit/models/PubkyAuthRequest.kt | 84 ++++++++++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 68 +++++++++- .../to/bitkit/services/PaykitSdkService.kt | 15 +++ .../java/to/bitkit/services/PubkyService.kt | 19 +++ app/src/main/java/to/bitkit/ui/ContentView.kt | 19 ++- .../screens/profile/PubkyAuthApprovalSheet.kt | 18 ++- .../profile/PubkyAuthApprovalViewModel.kt | 30 ++++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 125 +++++++++++++----- .../to/bitkit/viewmodels/SettingsViewModel.kt | 3 + app/src/main/res/values/strings.xml | 1 + .../to/bitkit/models/PubkyAuthRequestTest.kt | 37 ++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 73 ++++++++++ .../profile/PubkyAuthApprovalViewModelTest.kt | 21 +++ .../viewmodels/AppViewModelSendFlowTest.kt | 74 ++++++++++- .../viewmodels/SettingsViewModelTest.kt | 1 + changelog.d/next/1224.added.md | 1 + 17 files changed, 550 insertions(+), 47 deletions(-) create mode 100644 changelog.d/next/1224.added.md diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index eddec111d1..833e2d156c 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 86ce14a809..b46f3d53a7 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 isRingSignup: Boolean + get() = isRingSignupUrl(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,56 @@ 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 isRingSignupUrl(rawUrl: String): Boolean = runCatching { + val uri = URI(rawUrl) + uri.scheme.equals("pubkyring", ignoreCase = true) && + uri.host.equals("signup", ignoreCase = true) + }.getOrDefault(false) + + fun parseRingSignup(rawUrl: String): Result = runCatching { + val uri = URI(rawUrl) + require( + uri.scheme.equals("pubkyring", ignoreCase = true) && + uri.host.equals("signup", ignoreCase = true), + ) { "Unsupported Pubky signup URL" } + val query = parseQuery(uri) + val relay = query.requiredSingle("relay") + val secret = query.requiredSingle("secret") + val capabilities = query.requiredSingle("caps") + val homeserver = query.requiredSingle("hs") + val authorizationUrl = ringAuthorizationUrl(relay, secret, capabilities) + + parse( + rawUrl = rawUrl, + clientId = "", + relay = relay, + capabilities = capabilities, + homeserverPublicKey = homeserver, + signupToken = query.optionalSingle("st"), + authorizationUrl = authorizationUrl, + ).getOrThrow().also { + require(it.bitkitClaim == null) { "Ring signup does not support Bitkit companion claims" } + } + }.fold( + onSuccess = { Result.success(it) }, + onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) }, + ) + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = parseBitkitClaimValues(rawUrl).fold( onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, @@ -152,5 +210,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 7bd294ee0e..3554086038 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.isRingSignupUrl(authUrl)) { + val request = PubkyAuthRequest.parseRingSignup(authUrl).getOrThrow() + pubkyService.validateRingSignupAuth( + authorizationUrl = request.authorizationUrl, + homeserverPublicKey = requireNotNull(request.homeserverPublicKey), + ) + return@withContext request + } + val details = pubkyService.parseAuthUrl(authUrl) PubkyAuthRequest.parse( rawUrl = authUrl, @@ -955,6 +992,32 @@ class PubkyRepo @Inject constructor( } } + suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + require(request.isRingSignup) { "Not a Pubky Ring signup request" } + if (hasIdentity()) throw PubkyAlreadySignedInError + + val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() + if (hasIdentity()) throw PubkyAlreadySignedInError + + pubkyService.registerIdentity( + secretKeyHex = secretKeyHex, + homeserverZ32 = requireNotNull(request.homeserverPublicKey), + signupCode = request.signupToken, + ) + pubkyService.approveRingAuth(request.authorizationUrl, secretKeyHex) + settingsStore.setPubkyProfileSetupPending(true) + pubkyService.signIn(secretKeyHex) + + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + notifyBackupStateChanged() + } + } + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -1317,6 +1380,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 39bfef26d6..ce2a118be7 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -266,6 +266,21 @@ class PaykitSdkService @Inject constructor( return result } + suspend fun registerIdentity( + secretKeyHex: String, + homeserverPublicKey: String, + signupCode: String?, + ) { + isSetup.await() + bootstrap().signUp( + localSecretKey = localSecretKey(secretKeyHex), + receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), + homeserverPublicKey = homeserverPublicKey, + signupCode = signupCode, + requiredCapabilities = requiredCapabilities(), + ) + } + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 79bebe0881..c21f6ebf4c 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -1,14 +1,17 @@ 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 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 +79,11 @@ class PubkyService @Inject constructor( Unit } + suspend fun registerIdentity(secretKeyHex: String, homeserverZ32: String, signupCode: String?) = + ServiceQueue.CORE.background { + paykitSdkService.registerIdentity(secretKeyHex, homeserverZ32, signupCode) + } + suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background { paykitSdkService.signIn(secretKeyHex) Unit @@ -106,6 +114,13 @@ class PubkyService @Inject constructor( PaykitSdkService.parseAuthUrl(url) } + suspend fun validateRingSignupAuth(authorizationUrl: String, homeserverPublicKey: String): Unit = + ServiceQueue.CORE.background { + parseLegacyPubkyAuthUrl(authorizationUrl) + PaykitPublicKeys.normalize(homeserverPublicKey) + Unit + } + suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -115,6 +130,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 46113e75bd..2c0a203cb7 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -448,6 +448,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() @@ -642,6 +643,22 @@ fun ContentView( val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + LaunchedEffect( + isPaykitEnabled, + isPubkyProfileSetupPending, + isProfileAuthenticated, + currentSheet, + currentRoute, + ) { + val canNavigate = currentSheet == null && + currentRoute != Routes.CreateProfile::class.qualifiedName + val shouldResumeProfileSetup = isPaykitEnabled && + isPubkyProfileSetupPending && + isProfileAuthenticated + if (shouldResumeProfileSetup && canNavigate) { + navController.navigateTo(Routes.CreateProfile) + } + } val currentHardwareWalletId = navBackStackEntry ?.takeIf { it.destination.hasRoute() } ?.toRoute() @@ -1498,7 +1515,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 8cb85ac8a7..ab8632c00b 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 516ac57055..0dcad59906 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.isRingSignup) { + _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.isRingSignup) { + 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 91dfb48792..6e8379659c 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 @@ -182,6 +184,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 @@ -1709,7 +1712,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 +1924,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 +1943,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 +1978,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 +1992,7 @@ class AppViewModel @Inject constructor( startDelay: Duration, routePubkyKeys: Boolean, contactPaymentContext: ContactPaymentContext?, + allowPubkyAuth: Boolean, ) { val scanId = scanLogId(data) val normalized = data.removeLightningSchemes() @@ -2008,6 +2006,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) return } @@ -2026,6 +2025,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 +2064,7 @@ class AppViewModel @Inject constructor( routePubkyKeys = pending.routePubkyKeys, contactPaymentContext = pending.contactPaymentContext, preserveUntilComplete = true, + allowPubkyAuth = pending.allowPubkyAuth, ) } @@ -2415,6 +2416,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 +2424,7 @@ class AppViewModel @Inject constructor( startDelay = startDelay, routePubkyKeys = routePubkyKeys, contactPaymentContext = contactPaymentContext, + allowPubkyAuth = allowPubkyAuth, ) } @@ -2436,7 +2439,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 +2460,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 +2482,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 +2506,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 +2637,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 +2650,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 +2676,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 +4665,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 +4689,10 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { - if (pubkyRepo.publicKey.value == null) { + val isRingSignup = PubkyAuthRequest.isRingSignupUrl(authUrl) + if (isRingSignup && rejectPubkySignupForExistingIdentity()) return + + if (!isRingSignup && pubkyRepo.publicKey.value == null) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.pubky_auth__no_identity), @@ -4662,7 +4701,7 @@ class AppViewModel @Inject constructor( return } - if (!pubkyRepo.hasSecretKey()) { + if (!isRingSignup && !pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.profile__auth_approval_ring_only), @@ -4672,6 +4711,24 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + 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 +4813,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 +4849,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 e406efd3e6..4afc065826 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 10f3e82627..40b4913792 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 1577dd834e..9c4647b414 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -1,13 +1,43 @@ 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 Ring signup preserves registration and authorization details`() { + val request = PubkyAuthRequest.parseRingSignup(ringSignupUrl("invite code")).getOrThrow() + + assertTrue(request.isRingSignup) + 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, + ) + } + + @Test + fun `parse Ring signup rejects missing and duplicate required values`() { + val invalidUrls = listOf( + ringSignupUrl().replace("&secret=secret", ""), + "${ringSignupUrl()}&hs=other", + ) + + invalidUrls.forEach { url -> + assertIs(PubkyAuthRequest.parseRingSignup(url).exceptionOrNull()) + } + } + @Test fun `parse recognizes watch-only account claim`() { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES @@ -50,6 +80,7 @@ class PubkyAuthRequestTest { capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() + assertFalse(request.isRingSignup) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -261,4 +292,10 @@ class PubkyAuthRequestTest { } return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" } + + private fun ringSignupUrl(signupToken: String? = null): String = + "pubkyring://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() } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7c97d4296e..2ba0d01f53 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -41,6 +41,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 +75,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 +112,61 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } + @Test + fun `Ring signup registers and authorizes before activating the local session`() = test { + val events = mutableListOf() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenAnswer { + events += "register" + } + whenever(pubkyService.approveRingAuth(request.authorizationUrl, "secret")).thenAnswer { + events += "authorize" + } + whenever(pubkyService.signIn("secret")).thenAnswer { events += "activate" } + + val result = sut.approveSignupAuth(request) + + assertTrue(result.isSuccess) + assertEquals(listOf("register", "authorize", "activate"), events) + assertTrue(profileSetupPending.value) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + } + + @Test + fun `Ring signup marks profile setup pending before local activation`() = test { + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.signIn("secret")).thenAnswer { + assertTrue(profileSetupPending.value) + throw TestAppError("activation failed") + } + + assertTrue(sut.approveSignupAuth(request).isFailure) + assertTrue(profileSetupPending.value) + assertNull(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()) { signIn(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=..." @@ -1542,6 +1604,17 @@ 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.parseRingSignup( + "pubkyring://signup?hs=homeserver&relay=https%3A%2F%2Frelay.example" + + "&secret=request&caps=%2Fpub%2Fexample%2F%3Arw&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 bc6617bd8e..af85469b60 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.isRingSignupUrl(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 75eb224dda..f56e9c5e3b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -219,6 +219,8 @@ 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 timedSheetManager = mock() private val timedSheetType = MutableStateFlow(null) @@ -280,6 +282,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 +1921,45 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) } + @Test + fun `global scanner accepts Ring signup without an existing identity`() = test { + enablePaykitUi() + scanSignup() + + assertEquals(Sheet.PubkyAuth(signupAuthUrl), sut.currentSheet.value) + verify(pubkyRepo, never()).hasSecretKey() + } + + @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() + + 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 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 +1968,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 +5079,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } + private suspend fun TestScope.scanSignup() { + sut.showScannerSheet() + advanceUntilIdle() + sut.onScannerSheetResult(signupAuthUrl) + 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 8dfd31cab0..08aea6f1b9 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 0000000000..8aab2c4bb0 --- /dev/null +++ b/changelog.d/next/1224.added.md @@ -0,0 +1 @@ +Added support for creating a Pubky identity from Pubky Ring signup requests. From 17c50ba2236c6afdf690d2629b348f256aad78b9 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:35:04 -0500 Subject: [PATCH 2/5] fix: complete Pubky Ring signup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 4 +-- .../to/bitkit/services/PaykitSdkService.kt | 17 +++++++++-- .../java/to/bitkit/services/PubkyService.kt | 11 ++++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 7 ++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 29 ++++++++++++++++--- 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 3554086038..75ab641aee 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1001,14 +1001,14 @@ class PubkyRepo @Inject constructor( val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() if (hasIdentity()) throw PubkyAlreadySignedInError - pubkyService.registerIdentity( + val registeredSession = pubkyService.registerIdentity( secretKeyHex = secretKeyHex, homeserverZ32 = requireNotNull(request.homeserverPublicKey), signupCode = request.signupToken, ) pubkyService.approveRingAuth(request.authorizationUrl, secretKeyHex) settingsStore.setPubkyProfileSetupPending(true) - pubkyService.signIn(secretKeyHex) + pubkyService.activateRegisteredIdentity(registeredSession) settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } _publicKey.update { publicKey } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ce2a118be7..ea864d842d 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -270,9 +270,9 @@ class PaykitSdkService @Inject constructor( secretKeyHex: String, homeserverPublicKey: String, signupCode: String?, - ) { + ): PubkySessionBootstrapResult { isSetup.await() - bootstrap().signUp( + return bootstrap().signUp( localSecretKey = localSecretKey(secretKeyHex), receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), homeserverPublicKey = homeserverPublicKey, @@ -281,6 +281,19 @@ class PaykitSdkService @Inject constructor( ) } + suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) { + isSetup.await() + val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } + operationMutex.withLock { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + } + notifyBackupStateChanged() + } + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index c21f6ebf4c..1c473df599 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -6,6 +6,7 @@ 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 @@ -79,11 +80,19 @@ class PubkyService @Inject constructor( Unit } - suspend fun registerIdentity(secretKeyHex: String, homeserverZ32: String, signupCode: String?) = + 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 diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 2c0a203cb7..48adf172d4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -623,6 +623,7 @@ fun ContentView( ) { Box(modifier = Modifier.fillMaxSize()) { var isHomeCalculatorInputActive by remember { mutableStateOf(false) } + var didResumePendingPubkyProfileSetup by remember { mutableStateOf(false) } RootNavHost( navController = navController, @@ -650,12 +651,16 @@ fun ContentView( currentSheet, currentRoute, ) { + if (!isPubkyProfileSetupPending) { + didResumePendingPubkyProfileSetup = false + } val canNavigate = currentSheet == null && currentRoute != Routes.CreateProfile::class.qualifiedName val shouldResumeProfileSetup = isPaykitEnabled && isPubkyProfileSetupPending && isProfileAuthenticated - if (shouldResumeProfileSetup && canNavigate) { + if (shouldResumeProfileSetup && canNavigate && !didResumePendingPubkyProfileSetup) { + didResumePendingPubkyProfileSetup = true navController.navigateTo(Routes.CreateProfile) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 2ba0d01f53..41cb2fe9b5 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 @@ -115,29 +116,49 @@ class PubkyRepoTest : BaseUnitTest() { @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(request.authorizationUrl, "secret")).thenAnswer { events += "authorize" } - whenever(pubkyService.signIn("secret")).thenAnswer { events += "activate" } + 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 marks profile setup pending before local activation`() = test { + fun `Ring signup does not activate the registered session when authorization fails`() = test { + val registeredSession = mock() val request = ringSignupRequest() stubSignupKeys() - whenever(pubkyService.signIn("secret")).thenAnswer { + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.approveRingAuth(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 marks profile setup pending before activating the registered session`() = test { + val registeredSession = mock() + val request = ringSignupRequest() + stubSignupKeys() + whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) + whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { assertTrue(profileSetupPending.value) throw TestAppError("activation failed") } @@ -156,7 +177,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.approveSignupAuth(request).isFailure) verifyBlocking(pubkyService, never()) { approveRingAuth(any(), any()) } - verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { activateRegisteredIdentity(any()) } assertFalse(profileSetupPending.value) } From d83ce205698ac46aec54beab97fcee4a8b9dcc07 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 17:56:20 -0500 Subject: [PATCH 3/5] feat: support direct Pubky signup --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 50 ++++++++++++------- .../java/to/bitkit/repositories/PubkyRepo.kt | 10 ++-- .../java/to/bitkit/services/PubkyService.kt | 4 +- .../profile/PubkyAuthApprovalViewModel.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 36 +++++++++++-- .../to/bitkit/models/PubkyAuthRequestTest.kt | 26 ++++++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 24 +++++++-- .../profile/PubkyAuthApprovalViewModelTest.kt | 2 +- .../viewmodels/AppViewModelSendFlowTest.kt | 30 +++++++++-- changelog.d/next/1224.added.md | 2 +- 10 files changed, 142 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index b46f3d53a7..b713cb80ec 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -74,10 +74,10 @@ data class PubkyAuthRequest( val bitkitClaim: PubkyAuthClaim?, val homeserverPublicKey: String? = null, val signupToken: String? = null, - val authorizationUrl: String = rawUrl, + val authorizationUrl: String? = rawUrl, ) { - val isRingSignup: Boolean - get() = isRingSignupUrl(rawUrl) + val isSignup: Boolean + get() = isSignupUrl(rawUrl) companion object { @Suppress("LongParameterList") @@ -88,7 +88,7 @@ data class PubkyAuthRequest( capabilities: String, homeserverPublicKey: String? = null, signupToken: String? = null, - authorizationUrl: String = rawUrl, + authorizationUrl: String? = rawUrl, ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim -> val permissions = parseCapabilities(capabilities) PubkyAuthRequest( @@ -114,24 +114,25 @@ data class PubkyAuthRequest( } }.getOrDefault(false) - fun isRingSignupUrl(rawUrl: String): Boolean = runCatching { - val uri = URI(rawUrl) - uri.scheme.equals("pubkyring", ignoreCase = true) && - uri.host.equals("signup", ignoreCase = true) - }.getOrDefault(false) + fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) + + fun isDirectSignupUrl(rawUrl: String): Boolean = + runCatching { URI(rawUrl).isDirectSignupRequest() }.getOrDefault(false) - fun parseRingSignup(rawUrl: String): Result = runCatching { + fun parseSignup(rawUrl: String): Result = runCatching { val uri = URI(rawUrl) - require( - uri.scheme.equals("pubkyring", ignoreCase = true) && - uri.host.equals("signup", ignoreCase = true), - ) { "Unsupported Pubky signup URL" } + require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" } val query = parseQuery(uri) - val relay = query.requiredSingle("relay") - val secret = query.requiredSingle("secret") - val capabilities = query.requiredSingle("caps") val homeserver = query.requiredSingle("hs") - val authorizationUrl = ringAuthorizationUrl(relay, secret, capabilities) + val authorizesApp = uri.scheme.equals("pubkyring", ignoreCase = true) + 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, @@ -142,13 +143,24 @@ data class PubkyAuthRequest( signupToken = query.optionalSingle("st"), authorizationUrl = authorizationUrl, ).getOrThrow().also { - require(it.bitkitClaim == null) { "Ring signup does not support Bitkit companion claims" } + 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) + } + fun parseBitkitClaim(rawUrl: String, capabilities: String): Result = parseBitkitClaimValues(rawUrl).fold( onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) }, diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 75ab641aee..352dbc221a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -973,9 +973,9 @@ class PubkyRepo @Inject constructor( suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { withContext(ioDispatcher) { - if (PubkyAuthRequest.isRingSignupUrl(authUrl)) { - val request = PubkyAuthRequest.parseRingSignup(authUrl).getOrThrow() - pubkyService.validateRingSignupAuth( + if (PubkyAuthRequest.isSignupUrl(authUrl)) { + val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow() + pubkyService.validateSignupRequest( authorizationUrl = request.authorizationUrl, homeserverPublicKey = requireNotNull(request.homeserverPublicKey), ) @@ -995,7 +995,7 @@ class PubkyRepo @Inject constructor( suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { - require(request.isRingSignup) { "Not a Pubky Ring signup request" } + require(request.isSignup) { "Not a Pubky signup request" } if (hasIdentity()) throw PubkyAlreadySignedInError val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() @@ -1006,7 +1006,7 @@ class PubkyRepo @Inject constructor( homeserverZ32 = requireNotNull(request.homeserverPublicKey), signupCode = request.signupToken, ) - pubkyService.approveRingAuth(request.authorizationUrl, secretKeyHex) + request.authorizationUrl?.let { pubkyService.approveRingAuth(it, secretKeyHex) } settingsStore.setPubkyProfileSetupPending(true) pubkyService.activateRegisteredIdentity(registeredSession) diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 1c473df599..ac3a82bf7c 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -123,9 +123,9 @@ class PubkyService @Inject constructor( PaykitSdkService.parseAuthUrl(url) } - suspend fun validateRingSignupAuth(authorizationUrl: String, homeserverPublicKey: String): Unit = + suspend fun validateSignupRequest(authorizationUrl: String?, homeserverPublicKey: String): Unit = ServiceQueue.CORE.background { - parseLegacyPubkyAuthUrl(authorizationUrl) + authorizationUrl?.let { parseLegacyPubkyAuthUrl(it) } PaykitPublicKeys.normalize(homeserverPublicKey) Unit } 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 0dcad59906..e95a4a704f 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 @@ -172,7 +172,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) - if (request.isRingSignup) { + if (request.isSignup) { _effects.emit(PubkyAuthApprovalEffect.Dismiss) return } @@ -184,7 +184,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( private suspend fun approveRequest( request: PubkyAuthRequest, authUrl: String, - ): Boolean = if (request.isRingSignup) { + ): Boolean = if (request.isSignup) { pubkyRepo.approveSignupAuth(request).fold( onSuccess = { true }, onFailure = { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6e8379659c..7f585e57bd 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -163,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 @@ -4689,10 +4690,15 @@ class AppViewModel @Inject constructor( } private suspend fun handlePubkyAuth(authUrl: String) { - val isRingSignup = PubkyAuthRequest.isRingSignupUrl(authUrl) - if (isRingSignup && rejectPubkySignupForExistingIdentity()) return + val isSignup = PubkyAuthRequest.isSignupUrl(authUrl) + if (isSignup && rejectPubkySignupForExistingIdentity()) return - if (!isRingSignup && pubkyRepo.publicKey.value == null) { + 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), @@ -4701,7 +4707,7 @@ class AppViewModel @Inject constructor( return } - if (!isRingSignup && !pubkyRepo.hasSecretKey()) { + if (!isSignup && !pubkyRepo.hasSecretKey()) { ToastEventBus.send( type = Toast.ToastType.WARNING, title = context.getString(R.string.profile__auth_approval_ring_only), @@ -4711,6 +4717,28 @@ class AppViewModel @Inject constructor( showSheet(Sheet.PubkyAuth(authUrl)) } + private suspend fun handleDirectPubkySignup(authUrl: String) { + hideSheet() + 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), + ) + } + } + private suspend fun rejectPubkySignupForExistingIdentity(): Boolean { val hasIdentity = runCatching { pubkyRepo.hasIdentity() }.getOrElse { ToastEventBus.send( diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 9c4647b414..2db0a79abc 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -12,9 +12,9 @@ class PubkyAuthRequestTest { @Test fun `parse Ring signup preserves registration and authorization details`() { - val request = PubkyAuthRequest.parseRingSignup(ringSignupUrl("invite code")).getOrThrow() + val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code")).getOrThrow() - assertTrue(request.isRingSignup) + assertTrue(request.isSignup) assertEquals("homeserver", request.homeserverPublicKey) assertEquals("invite code", request.signupToken) assertEquals("https://relay.example/inbox/", request.relay) @@ -26,6 +26,20 @@ class PubkyAuthRequestTest { ) } + @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( @@ -34,7 +48,7 @@ class PubkyAuthRequestTest { ) invalidUrls.forEach { url -> - assertIs(PubkyAuthRequest.parseRingSignup(url).exceptionOrNull()) + assertIs(PubkyAuthRequest.parseSignup(url).exceptionOrNull()) } } @@ -80,7 +94,7 @@ class PubkyAuthRequestTest { capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() - assertFalse(request.isRingSignup) + assertFalse(request.isSignup) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -298,4 +312,8 @@ class PubkyAuthRequestTest { "&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 41cb2fe9b5..56af3286be 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -123,7 +123,7 @@ class PubkyRepoTest : BaseUnitTest() { events += "register" registeredSession } - whenever(pubkyService.approveRingAuth(request.authorizationUrl, "secret")).thenAnswer { + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")).thenAnswer { events += "authorize" } whenever(pubkyService.activateRegisteredIdentity(registeredSession)).thenAnswer { events += "activate" } @@ -143,7 +143,7 @@ class PubkyRepoTest : BaseUnitTest() { val request = ringSignupRequest() stubSignupKeys() whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) - whenever(pubkyService.approveRingAuth(request.authorizationUrl, "secret")) + whenever(pubkyService.approveRingAuth(requireNotNull(request.authorizationUrl), "secret")) .thenThrow(IllegalStateException("authorization failed")) assertTrue(sut.approveSignupAuth(request).isFailure) @@ -168,6 +168,20 @@ class PubkyRepoTest : BaseUnitTest() { 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() @@ -1631,11 +1645,15 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.publicKeyFromSecret("secret")).thenReturn(VALID_SELF_KEY) } - private fun ringSignupRequest() = PubkyAuthRequest.parseRingSignup( + 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 af85469b60..9c28ad7766 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 @@ -571,7 +571,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), serviceNames = listOf("paykit"), bitkitClaim = bitkitClaim, - homeserverPublicKey = if (PubkyAuthRequest.isRingSignupUrl(authUrl)) "homeserver" else null, + 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 f56e9c5e3b..21095ef4d0 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 @@ -221,6 +223,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { private val testPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private val signupAuthUrl = "pubkyring://signup?hs=homeserver&relay=https://relay&secret=request&caps=/pub/example/:rw" + 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) @@ -1924,18 +1928,34 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `global scanner accepts Ring signup without an existing identity`() = test { enablePaykitUi() - scanSignup() + + scanSignup(signupAuthUrl) assertEquals(Sheet.PubkyAuth(signupAuthUrl), 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() + scanSignup(directSignupAuthUrl) assertNull(sut.currentSheet.value) verify(pubkyRepo, never()).hasSecretKey() @@ -1944,7 +1964,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @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() @@ -5079,10 +5099,10 @@ class AppViewModelSendFlowTest : BaseUnitTest() { isPaykitEnabled.value = true } - private suspend fun TestScope.scanSignup() { + private suspend fun TestScope.scanSignup(authUrl: String = signupAuthUrl) { sut.showScannerSheet() advanceUntilIdle() - sut.onScannerSheetResult(signupAuthUrl) + sut.onScannerSheetResult(authUrl) advanceUntilIdle() } diff --git a/changelog.d/next/1224.added.md b/changelog.d/next/1224.added.md index 8aab2c4bb0..fe1fcd278c 100644 --- a/changelog.d/next/1224.added.md +++ b/changelog.d/next/1224.added.md @@ -1 +1 @@ -Added support for creating a Pubky identity from Pubky Ring signup requests. +Added support for creating a Pubky identity from app-authorized and direct Pubky signup requests. From 105294ade374866acf5b7b3d33c71f09983ec785 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 07:46:41 -0500 Subject: [PATCH 4/5] fix: complete Pubky signup handoff --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 12 ++++- app/src/main/java/to/bitkit/ui/ContentView.kt | 23 ++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 45 ++++++++++++------- .../to/bitkit/models/PubkyAuthRequestTest.kt | 35 ++++++++------- .../viewmodels/AppViewModelSendFlowTest.kt | 10 +++-- 5 files changed, 87 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index b713cb80ec..be10906204 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -117,14 +117,14 @@ data class PubkyAuthRequest( fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false) fun isDirectSignupUrl(rawUrl: String): Boolean = - runCatching { URI(rawUrl).isDirectSignupRequest() }.getOrDefault(false) + 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.scheme.equals("pubkyring", ignoreCase = true) + 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 "" @@ -161,6 +161,14 @@ data class PubkyAuthRequest( 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) }, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 48adf172d4..0dad141b08 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 @@ -456,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 || @@ -719,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) + } + } + } } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 7f585e57bd..8cd5ab3f4e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -325,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 @@ -4719,23 +4721,32 @@ class AppViewModel @Inject constructor( private suspend fun handleDirectPubkySignup(authUrl: String) { hideSheet() - 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), - ) + _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 } } diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index 2db0a79abc..c9102a65e2 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -11,19 +11,22 @@ import kotlin.test.assertTrue class PubkyAuthRequestTest { @Test - fun `parse Ring signup preserves registration and authorization details`() { - val request = PubkyAuthRequest.parseSignup(ringSignupUrl("invite code")).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, - ) + 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 @@ -45,6 +48,7 @@ class PubkyAuthRequestTest { val invalidUrls = listOf( ringSignupUrl().replace("&secret=secret", ""), "${ringSignupUrl()}&hs=other", + directSignupUrl("signup") + "&relay=https%3A%2F%2Frelay.example", ) invalidUrls.forEach { url -> @@ -95,6 +99,7 @@ class PubkyAuthRequestTest { ).getOrThrow() assertFalse(request.isSignup) + assertFalse(PubkyAuthRequest.isDirectSignupUrl(request.rawUrl)) assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -307,8 +312,8 @@ class PubkyAuthRequestTest { return "pubkyauth://signin?caps=$capabilities&relay=https%3A%2F%2Fhttprelay.pubky.app%2Finbox%2F$claims" } - private fun ringSignupUrl(signupToken: String? = null): String = - "pubkyring://signup?hs=homeserver" + + 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() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 21095ef4d0..b61a16871f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -223,6 +223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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" @@ -1926,12 +1927,13 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `global scanner accepts Ring signup without an existing identity`() = test { + fun `global scanner accepts authorized signup without an existing identity`() = test { enablePaykitUi() - scanSignup(signupAuthUrl) - - assertEquals(Sheet.PubkyAuth(signupAuthUrl), sut.currentSheet.value) + listOf(signupAuthUrl, legacyAuthorizedSignupAuthUrl).forEach { authUrl -> + scanSignup(authUrl) + assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value) + } verify(pubkyRepo, never()).hasSecretKey() } From ef49aa11bb07dd961189f4bd14741ce44a7ad8bb Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 17:12:07 -0500 Subject: [PATCH 5/5] fix: recover failed pubky signup --- .../java/to/bitkit/repositories/PubkyRepo.kt | 31 +++++++++++++++++-- .../to/bitkit/services/PaykitSdkService.kt | 27 +++++++++++++--- .../to/bitkit/repositories/PubkyRepoTest.kt | 31 +++++++++++++++++-- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 352dbc221a..ffdc95a90a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1001,18 +1001,43 @@ class PubkyRepo @Inject constructor( 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) } - settingsStore.setPubkyProfileSetupPending(true) - pubkyService.activateRegisteredIdentity(registeredSession) + var activated = false + try { + pubkyService.activateRegisteredIdentity(registeredSession) + activated = true + } finally { + if (!activated) { + withContext(NonCancellable) { + settingsStore.setPubkyProfileSetupPending(false) + } + } + } - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = 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() } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index ea864d842d..55beff2290 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 @@ -285,11 +287,17 @@ class PaykitSdkService @Inject constructor( isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } operationMutex.withLock { - activateBootstrapResult( - result = result, - previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = true, - ) + var activated = false + try { + activateBootstrapResult( + result = result, + previousPublicKey = previousPublicKey, + shouldStoreLocalSecret = true, + ) + activated = true + } finally { + if (!activated) clearRegisteredIdentityActivationLocked() + } } notifyBackupStateChanged() } @@ -893,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/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 56af3286be..45ec1a4cea 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -153,18 +153,18 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `Ring signup marks profile setup pending before activating the registered session`() = 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 { - assertTrue(profileSetupPending.value) throw TestAppError("activation failed") } assertTrue(sut.approveSignupAuth(request).isFailure) - assertTrue(profileSetupPending.value) + assertFalse(profileSetupPending.value) assertNull(sut.publicKey.value) } @@ -613,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()