From 13e4dd7b71eea30ae4dd10b3b9177d2f62e0a186 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 13:49:14 -0500 Subject: [PATCH 01/16] feat: upgrade paykit auth to rc50 --- .../java/to/bitkit/repositories/PubkyRepo.kt | 117 +++++++++++------- .../to/bitkit/services/PaykitSdkService.kt | 34 +++-- .../java/to/bitkit/services/PubkyService.kt | 8 +- .../screens/profile/EditProfileViewModel.kt | 3 +- .../ui/screens/profile/ProfileViewModel.kt | 3 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 19 +-- .../bitkit/services/PaykitSdkServiceTest.kt | 2 + .../profile/EditProfileViewModelTest.kt | 4 +- .../screens/profile/ProfileViewModelTest.kt | 4 +- changelog.d/next/paykit-rc50.security.md | 1 + gradle/libs.versions.toml | 2 +- 11 files changed, 111 insertions(+), 86 deletions(-) create mode 100644 changelog.d/next/paykit-rc50.security.md diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index a82a482f4b..ad0b8a47a9 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -309,7 +309,7 @@ class PubkyRepo @Inject constructor( } if (result.isFailure) { - clearCompletedAuthSessionIfNeeded(didCompleteAuth) + revokeCompletedAuthSessionIfNeeded(didCompleteAuth) if (_activeAuthAttemptId.value == attemptId) { _activeAuthAttemptId.update { null } } @@ -333,7 +333,7 @@ class PubkyRepo @Inject constructor( loadContacts() }.map { } } catch (e: CancellationException) { - clearCompletedAuthSessionIfNeeded(didCompleteAuth) + revokeCompletedAuthSessionIfNeeded(didCompleteAuth) if (_activeAuthAttemptId.value == attemptId) { _activeAuthAttemptId.update { null } } @@ -345,14 +345,14 @@ class PubkyRepo @Inject constructor( } } - private suspend fun clearCompletedAuthSessionIfNeeded(didCompleteAuth: Boolean) { + private suspend fun revokeCompletedAuthSessionIfNeeded(didCompleteAuth: Boolean) { if (!didCompleteAuth) return runSuspendCatching { withContext(NonCancellable + ioDispatcher) { - pubkyService.clearSessionAccess() + pubkyService.signOut() } }.onFailure { - Logger.warn("Failed to clear canceled Pubky auth session", it, context = TAG) + Logger.warn("Failed to revoke canceled Pubky auth session", it, context = TAG) } } @@ -534,40 +534,72 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, - ): Result = runSuspendCatching { - withContext(ioDispatcher) { - val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() + ): Result { + var didActivateSession = false + return try { + val result = runSuspendCatching { + withContext(ioDispatcher) { + val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() - val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } - ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } + val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } + ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } - runSuspendCatching { - pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second) - }.getOrElse { - Logger.warn("Retrying sign in after sign up failed", it, context = TAG) - pubkyService.signIn(secretKeyHex) + runSuspendCatching { + pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second) + }.getOrElse { + Logger.warn("Retrying sign in after sign up failed", it, context = TAG) + pubkyService.signIn(secretKeyHex) + } + didActivateSession = true + + val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } + writeProfile(name, bio, links, tags, imageUrl) + finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) + } } + if (result.isFailure) revokeIncompleteIdentitySessionIfNeeded(didActivateSession) + result + } catch (error: CancellationException) { + revokeIncompleteIdentitySessionIfNeeded(didActivateSession) + throw error + } + } - val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - writeProfile(name, bio, links, tags, imageUrl) + private suspend fun finishIdentityCreation( + publicKey: String, + name: String, + bio: String, + links: List, + tags: List, + imageUrl: String?, + ) { + val createdProfile = PubkyProfile( + publicKey = publicKey, + name = name, + bio = bio, + imageUrl = imageUrl, + links = links, + tags = tags, + status = null, + ) + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + _profile.update { createdProfile } + cacheMetadata(createdProfile) + notifyBackupStateChanged() + Logger.info("Created identity for '${redacted(publicKey)}'", context = TAG) + loadProfile() + loadContacts() + } - val createdProfile = PubkyProfile( - publicKey = publicKeyZ32, - name = name, - bio = bio, - imageUrl = imageUrl, - links = links, - tags = tags, - status = null, - ) - _publicKey.update { publicKeyZ32 } - _authState.update { PubkyAuthState.Authenticated } - _profile.update { createdProfile } - cacheMetadata(createdProfile) - notifyBackupStateChanged() - Logger.info("Created identity for '${redacted(publicKeyZ32)}'", context = TAG) - loadProfile() - loadContacts() + private suspend fun revokeIncompleteIdentitySessionIfNeeded(didActivateSession: Boolean) { + if (!didActivateSession) return + runSuspendCatching { + withContext(NonCancellable + ioDispatcher) { + pubkyService.signOut() + } + }.onFailure { + Logger.warn("Failed to revoke incomplete Pubky profile session", it, context = TAG) } } @@ -975,7 +1007,7 @@ class PubkyRepo @Inject constructor( ensureServiceInitialized() initializeMutex.withLock { - pubkyService.clearSessionAccess() + pubkyService.forgetSessionAccess() clearAuthenticatedState() runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } @@ -1045,19 +1077,20 @@ class PubkyRepo @Inject constructor( val result = runSuspendCatching { withContext(ioDispatcher) { pubkyService.signOut() } - }.fold( - onSuccess = { Result.success(it) }, - onFailure = { - Logger.warn("Forcing local sign out after server sign out failed", it, context = TAG) - runSuspendCatching { withContext(ioDispatcher) { pubkyService.forceSignOut() } } - }, - ) + }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } + + if (result.isFailure) return result clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) return result } suspend fun wipeLocalState() { + runSuspendCatching { + withContext(ioDispatcher) { pubkyService.forgetSessionAccess() } + }.onFailure { + Logger.warn("Failed to forget local Pubky session access", it, context = TAG) + } clearLocalState() } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index fcf69c9e75..c3b37a26b1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -792,28 +792,16 @@ class PaykitSdkService @Inject constructor( } } - suspend fun forceSignOut() { - operationMutex.withLock { - clearSessionAccessLocked() - clearStateLocked() - } - } - - suspend fun clearSessionAccess() { + suspend fun forgetSessionAccess() { + isSetup.await() operationMutex.withLock { - clearSessionAccessLocked() - notifyBackupStateChanged() + withStateRevisionTracking { handle -> + handle.forgetSessionAccess() + } + resetRuntime() } } - private suspend fun clearSessionAccessLocked() { - sessionProvider.clearLiveSessionAccess() - keychain.delete(Keychain.Key.PAYKIT_SESSION.name) - keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) - activeAuthRequest = null - resetRuntime() - } - suspend fun clearState() { operationMutex.withLock { clearStateLocked() @@ -923,7 +911,10 @@ class PaykitSdkService @Inject constructor( ).also { sdk = it } } - private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig(pubkyClientConfig) + private fun bootstrap() = PubkySessionBootstrap.withPubkyClientConfig( + clientId = BitkitPaykitSdkConfig.clientId, + pubkyClient = pubkyClientConfig, + ) private fun resetRuntime() { sdk = null @@ -962,6 +953,8 @@ class PaykitSdkService @Inject constructor( } internal object BitkitPaykitSdkConfig { + val clientId: String + get() = profileNamespace val profileNamespace: String get() = if (Env.network == Network.BITCOIN) "bitkit.to" else "staging.bitkit.to" val endpointManagementScope = PaykitSdkDefaults.DEFAULT_ENDPOINT_MANAGEMENT_SCOPE @@ -1053,6 +1046,7 @@ internal class PaykitSdkSessionProvider( ?.let { return it } PubkySessionAccess( + clientId = BitkitPaykitSdkConfig.clientId, sessionSecret = sessionSecret, localSecretKey = loadLocalSecretKey(), receiverNoiseSecretKey = loadOrDeriveReceiverNoiseSecretKey(), @@ -1080,8 +1074,8 @@ internal class PaykitSdkSessionProvider( override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { - delete(Keychain.Key.PAYKIT_SESSION.name) delete(Keychain.Key.PUBKY_SECRET_KEY.name) + delete(Keychain.Key.PAYKIT_SESSION.name) } } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index cc6cd7360b..991bb6f353 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -37,12 +37,8 @@ class PubkyService @Inject constructor( paykitSdkService.signOut() } - suspend fun forceSignOut() = ServiceQueue.CORE.background { - paykitSdkService.forceSignOut() - } - - suspend fun clearSessionAccess() = ServiceQueue.CORE.background { - paykitSdkService.clearSessionAccess() + suspend fun forgetSessionAccess() = ServiceQueue.CORE.background { + paykitSdkService.forgetSessionAccess() } suspend fun removeBitkitPaymentEndpoints() = ServiceQueue.CORE.background { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt index fdd2ebb361..1e2b9d07a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/EditProfileViewModel.kt @@ -242,13 +242,12 @@ class EditProfileViewModel @Inject constructor( } val result = pubkyRepo.signOut() - privatePaykitRepo.closeAndClear() if (result.isSuccess) { + privatePaykitRepo.closeAndClear() _uiState.update { it.copy(isSaving = false) } _effects.emit(EditProfileEffect.DisconnectSuccess) } else { val error = requireNotNull(result.exceptionOrNull()) { "Disconnect failed without an error" } - Logger.error("Failed to disconnect profile", error, context = TAG) _uiState.update { it.copy(isSaving = false) } ToastEventBus.send( type = Toast.ToastType.ERROR, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt index b4a6a3ebaa..280edad106 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt @@ -122,12 +122,11 @@ class ProfileViewModel @Inject constructor( } val result = pubkyRepo.signOut() - privatePaykitRepo.closeAndClear() if (result.isSuccess) { + privatePaykitRepo.closeAndClear() _effects.emit(ProfileEffect.SignedOut) } else { val error = requireNotNull(result.exceptionOrNull()) { "Sign out failed without an error" } - Logger.error("Sign out failed", error, context = TAG) ToastEventBus.send( type = Toast.ToastType.ERROR, title = context.getString(R.string.profile__sign_out_title), diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7972effba9..424ab465f5 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -233,7 +233,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `completeAuthentication should clear session when auth is canceled after completion`() = test { + fun `completeAuthentication should revoke session when auth is canceled after completion`() = test { whenever(pubkyService.startAuth()).thenReturn("auth_uri") whenever(pubkyService.completeAuth()).thenAnswer { runBlocking { sut.cancelAuthentication() } @@ -245,7 +245,7 @@ class PubkyRepoTest : BaseUnitTest() { val result = sut.completeAuthentication() assertTrue(result.isFailure) - verifyBlocking(pubkyService) { clearSessionAccess() } + verifyBlocking(pubkyService) { signOut() } } @Test @@ -635,7 +635,6 @@ class PubkyRepoTest : BaseUnitTest() { authenticateForTesting() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Sign out failed") } - whenever(pubkyService.forceSignOut()).thenAnswer { throw TestAppError("Force sign out failed") } val result = sut.deleteProfile() @@ -676,15 +675,16 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `signOut should force sign out when server sign out fails`() = test { + fun `signOut should preserve local state when grant revocation fails`() = test { authenticateForTesting() whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val result = sut.signOut() - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { forceSignOut() } - assertFalse(sut.isAuthenticated.value) + assertTrue(result.isFailure) + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + assertTrue(sut.isAuthenticated.value) } @Test @@ -909,7 +909,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `restoreSessionBackupState should clear current session when backup has no pubky state`() = test { + fun `restoreSessionBackupState should forget current session when backup has no pubky state`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) clearInvocations(pubkyService, keychain) @@ -918,7 +918,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertFalse(sut.isAuthenticated.value) assertNull(sut.publicKey.value) - verifyBlocking(pubkyService) { clearSessionAccess() } + verifyBlocking(pubkyService) { forgetSessionAccess() } verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } } @@ -1247,6 +1247,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.contacts.value.isEmpty()) assertFalse(sut.isAuthenticated.value) verify(pubkyService, never()).signOut() + verifyBlocking(pubkyService) { forgetSessionAccess() } verifyBlocking(pubkyStore) { reset() } } diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 88cfcfe2af..1765a7a3fc 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -21,10 +21,12 @@ class PaykitSdkServiceTest { private val basePubkyClientConfig = PubkyClientConfig( requestTimeoutSecs = 30uL, localTestnetHost = null, + authRelayUrl = null, ) @Test fun `config scopes public endpoint sync to Bitkit managed endpoints`() { + assertEquals(BitkitPaykitSdkConfig.profileNamespace, BitkitPaykitSdkConfig.clientId) assertEquals(EndpointManagementScope.MANAGED_ONLY, BitkitPaykitSdkConfig.endpointManagementScope) assertEquals(PublicContactSharingPolicy.LOCAL_ONLY, BitkitPaykitSdkConfig.publicContactSharing) assertEquals(EncryptedLinkRecoveryMarkerPolicy.ENABLED, BitkitPaykitSdkConfig.encryptedLinkRecoveryMarkers) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt index f4ed9db70d..67baeb31d0 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/EditProfileViewModelTest.kt @@ -146,7 +146,7 @@ class EditProfileViewModelTest : BaseUnitTest() { } @Test - fun `disconnectProfile clears local Paykit state when Pubky sign out fails`() = test { + fun `disconnectProfile preserves local Paykit state when Pubky sign out fails`() = test { val sut = createSut() whenever(pubkyRepo.signOut()).thenReturn(Result.failure(TestAppError("sign out failed"))) advanceUntilIdle() @@ -155,7 +155,7 @@ class EditProfileViewModelTest : BaseUnitTest() { advanceUntilIdle() assertFalse(sut.uiState.value.isSaving) - verify(privatePaykitRepo).closeAndClear() + verify(privatePaykitRepo, never()).closeAndClear() } @Test diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt index 7473f30ce7..16fb6886d5 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/ProfileViewModelTest.kt @@ -66,7 +66,7 @@ class ProfileViewModelTest : BaseUnitTest() { } @Test - fun `signOut clears local Paykit state when Pubky sign out fails`() = test { + fun `signOut preserves local Paykit state when Pubky sign out fails`() = test { val sut = createSut() whenever(pubkyRepo.signOut()).thenReturn(Result.failure(ProfileTestAppError("sign out failed"))) advanceUntilIdle() @@ -74,7 +74,7 @@ class ProfileViewModelTest : BaseUnitTest() { sut.signOut() advanceUntilIdle() - verify(privatePaykitRepo).closeAndClear() + verify(privatePaykitRepo, never()).closeAndClear() } @Test diff --git a/changelog.d/next/paykit-rc50.security.md b/changelog.d/next/paykit-rc50.security.md new file mode 100644 index 0000000000..5ab4d364f0 --- /dev/null +++ b/changelog.d/next/paykit-rc50.security.md @@ -0,0 +1 @@ +Updated Pubky authentication to use app-scoped grants with secure sign-out. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17cbb15651..714adcee64 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.14" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc46" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc50" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 7151f64938a30f69c52ab67eaed34b9da7deb655 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 14:05:19 -0500 Subject: [PATCH 02/16] chore: rename changelog fragment --- changelog.d/next/{paykit-rc50.security.md => 1200.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{paykit-rc50.security.md => 1200.security.md} (100%) diff --git a/changelog.d/next/paykit-rc50.security.md b/changelog.d/next/1200.security.md similarity index 100% rename from changelog.d/next/paykit-rc50.security.md rename to changelog.d/next/1200.security.md From 0c2dfad09375f2d4c6152d8f60b0b881d7f3c55f Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 31 Aug 2026 14:23:33 -0500 Subject: [PATCH 03/16] fix: reconcile paykit auth failures --- .../java/to/bitkit/repositories/PubkyRepo.kt | 23 +++++++++++----- .../java/to/bitkit/viewmodels/AppViewModel.kt | 27 ++++++++----------- .../to/bitkit/repositories/PubkyRepoTest.kt | 7 +++++ .../viewmodels/AppViewModelSendFlowTest.kt | 16 +++++++++++ 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index ad0b8a47a9..19599be0d0 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -535,7 +535,7 @@ class PubkyRepo @Inject constructor( tags: List, avatarBytes: ByteArray?, ): Result { - var didActivateSession = false + var shouldRevokeSessionOnFailure = false return try { val result = runSuspendCatching { withContext(ioDispatcher) { @@ -544,23 +544,23 @@ class PubkyRepo @Inject constructor( val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } + shouldRevokeSessionOnFailure = true runSuspendCatching { pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second) }.getOrElse { Logger.warn("Retrying sign in after sign up failed", it, context = TAG) pubkyService.signIn(secretKeyHex) } - didActivateSession = true val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } writeProfile(name, bio, links, tags, imageUrl) finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) } } - if (result.isFailure) revokeIncompleteIdentitySessionIfNeeded(didActivateSession) + if (result.isFailure) revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSessionOnFailure) result } catch (error: CancellationException) { - revokeIncompleteIdentitySessionIfNeeded(didActivateSession) + revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSessionOnFailure) throw error } } @@ -592,8 +592,8 @@ class PubkyRepo @Inject constructor( loadContacts() } - private suspend fun revokeIncompleteIdentitySessionIfNeeded(didActivateSession: Boolean) { - if (!didActivateSession) return + private suspend fun revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSession: Boolean) { + if (!shouldRevokeSession) return runSuspendCatching { withContext(NonCancellable + ioDispatcher) { pubkyService.signOut() @@ -1079,7 +1079,16 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { pubkyService.signOut() } }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } - if (result.isFailure) return result + if (result.isFailure) { + if (hadPaykitState) { + runSuspendCatching { + settingsStore.update { it.copy(publicPaykitCleanupPending = true) } + }.onFailure { + Logger.warn("Failed to mark Paykit state for reconciliation", it, context = TAG) + } + } + return result + } clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) return result diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index dae74658dc..727598884b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -996,23 +996,18 @@ class AppViewModel @Inject constructor( private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { val settings = settingsStore.data.first() if (settings.publicPaykitCleanupPending) { - if (settings.sharesPublicPaykitEndpoints) { - publicPaykitRepo.syncCurrentPublishedEndpoints() - .onSuccess { - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } - } - .onFailure { - Logger.warn("Failed to retry public Paykit endpoint sync for '$reason'", it, context = TAG) - } - } else { - publicPaykitRepo.syncPublishedEndpoints(publish = false) - .onSuccess { - settingsStore.update { it.copy(publicPaykitCleanupPending = false) } - } - .onFailure { - Logger.warn("Failed to retry public Paykit endpoint removal for '$reason'", it, context = TAG) - } + val reconciliationResult = when { + settings.sharesPublicPaykitEndpoints -> publicPaykitRepo.syncCurrentPublishedEndpoints() + settings.sharesPrivatePaykitEndpoints -> publicPaykitRepo.syncLocalReceiverMarker() + else -> publicPaykitRepo.syncPublishedEndpoints(publish = false) } + reconciliationResult + .onSuccess { + settingsStore.update { it.copy(publicPaykitCleanupPending = false) } + } + .onFailure { + Logger.warn("Failed to reconcile public Paykit state for '$reason'", it, context = TAG) + } } privatePaykitRepo.retryPendingEndpointRemoval(contactKeys) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 424ab465f5..75b49e6d63 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -677,6 +677,10 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `signOut should preserve local state when grant revocation fails`() = test { authenticateForTesting() + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val result = sut.signOut() @@ -685,6 +689,9 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { forgetSessionAccess() } verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } assertTrue(sut.isAuthenticated.value) + assertTrue(settingsFlow.value.sharesPublicPaykitEndpoints) + assertTrue(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 76c59f2e27..2053130097 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4913,6 +4913,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } + @Test + fun `private Paykit refresh reconciles pending private-only receiver marker`() = test { + settingsData.value = SettingsData( + sharesPrivatePaykitEndpoints = true, + publicPaykitCleanupPending = true, + ) + whenever(publicPaykitRepo.syncLocalReceiverMarker()).thenReturn(Result.success(Unit)) + + sut.refreshPrivatePaykitEndpoints() + advanceUntilIdle() + + verify(publicPaykitRepo).syncLocalReceiverMarker() + assertFalse(settingsData.value.publicPaykitCleanupPending) + verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) + } + @Test fun `private Paykit refresh republishes marker for private-only sharing`() = test { isPaykitEnabled.value = true From dcf225ddc5d36c1fd8daf90fb6d02863c6d842cf Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 1 Sep 2026 07:38:39 -0500 Subject: [PATCH 04/16] fix: harden paykit auth cleanup --- app/build.gradle.kts | 1 + .../java/to/bitkit/repositories/PubkyRepo.kt | 6 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 71 ++++++++++++++++++- gradle/libs.versions.toml | 1 + 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fa5a5b6671..275472ebdd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -580,6 +580,7 @@ dependencies { implementation(libs.ktor.client.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + testImplementation(libs.ktor.client.mock) // Logging runtimeOnly(libs.slf4j.simple) implementation(libs.slf4j.api) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 19599be0d0..e63977d352 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -293,8 +293,10 @@ class PubkyRepo @Inject constructor( val result = runSuspendCatching { waitForAuthApproval(attemptId) withContext(ioDispatcher) { - pubkyService.completeAuth() - didCompleteAuth = true + withContext(NonCancellable) { + pubkyService.completeAuth() + didCompleteAuth = true + } ensureAuthAttemptActive(attemptId) val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { "No active Pubky session" diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 75b49e6d63..eeb69e2a9d 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -10,6 +10,15 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PublicationStatus +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -19,6 +28,7 @@ import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -79,14 +89,14 @@ class PubkyRepoTest : BaseUnitTest() { sut = createSut() } - private fun createSut() = PubkyRepo( + private fun createSut(httpClient: HttpClient = mock()) = PubkyRepo( ioDispatcher = testDispatcher, pubkyService = pubkyService, keychain = keychain, imageLoader = imageLoader, pubkyStore = pubkyStore, settingsStore = settingsStore, - httpClient = mock(), + httpClient = httpClient, ) @Test @@ -248,6 +258,29 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { signOut() } } + @Test + fun `completeAuthentication should revoke session when canceled during completion`() = test { + val completionStarted = CompletableDeferred() + val finishCompletion = CompletableDeferred() + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).doSuspendableAnswer { + completionStarted.complete(Unit) + finishCompletion.await() + } + + val authRequest = startAuthForTesting() + approveAuthForTesting(authRequest) + val result = async { sut.completeAuthentication() } + completionStarted.await() + + result.cancel() + verifyBlocking(pubkyService, never()) { signOut() } + finishCompletion.complete(Unit) + result.join() + + verifyBlocking(pubkyService) { signOut() } + } + @Test fun `cancelAuthentication should reset state to idle`() = test { whenever(pubkyService.startAuth()).thenReturn("auth_uri") @@ -423,6 +456,40 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { cancelAuth() } } + @Test + fun `createIdentity should revoke session when profile publication fails`() = test { + val httpClient = HttpClient( + MockEngine { + respond( + content = """{"signupCode":"test-code","homeserverPubky":"test-homeserver"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json() } + } + sut = createSut(httpClient) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("test-secret") + whenever(pubkyService.publicKeyFromSecret("test-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenReturn(Unit) + whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + + val result = sut.createIdentity( + name = "Test", + bio = "", + links = emptyList(), + tags = emptyList(), + avatarBytes = null, + ) + httpClient.close() + + assertTrue(result.isFailure) + verifyBlocking(pubkyService) { signUp("test-secret", "test-homeserver", "test-code") } + verifyBlocking(pubkyService) { signOut() } + } + @Test fun `loadProfile should update profile on success`() = test { authenticateForTesting() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 714adcee64..a4a8bf6446 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,6 +62,7 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.66" } From 75e1b4805ab09a54c3317148ab5b69cc728d25da Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 13:03:35 -0500 Subject: [PATCH 05/16] fix: approve external Pubky grants --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 3 + .../java/to/bitkit/repositories/PubkyRepo.kt | 11 ++- .../to/bitkit/services/PaykitSdkService.kt | 23 +++++- .../java/to/bitkit/services/PubkyService.kt | 5 +- .../screens/profile/PubkyAuthApprovalSheet.kt | 7 ++ .../profile/PubkyAuthApprovalViewModel.kt | 13 +++- app/src/main/res/values/strings.xml | 1 + .../to/bitkit/models/PubkyAuthRequestTest.kt | 12 +++ .../to/bitkit/repositories/PubkyRepoTest.kt | 9 ++- .../profile/PubkyAuthApprovalViewModelTest.kt | 73 +++++++++++++------ changelog.d/next/1200.security.md | 2 +- 11 files changed, 124 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index 4c42ed7d38..fde0811148 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -64,6 +64,7 @@ data class PubkyAuthPermission( data class PubkyAuthRequest( val rawUrl: String, + val clientId: String, val relay: String, val capabilities: String, val permissions: List, @@ -73,12 +74,14 @@ data class PubkyAuthRequest( companion object { fun parse( rawUrl: String, + clientId: String, relay: String, capabilities: String, ): Result = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim -> val permissions = parseCapabilities(capabilities) PubkyAuthRequest( rawUrl = rawUrl, + clientId = clientId, relay = relay, capabilities = capabilities, permissions = permissions, diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index e63977d352..9dab141e63 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -939,23 +939,29 @@ class PubkyRepo @Inject constructor( val details = pubkyService.parseAuthUrl(authUrl) PubkyAuthRequest.parse( rawUrl = authUrl, + clientId = details.clientId.orEmpty(), relay = details.relayUrl.orEmpty(), capabilities = details.capabilities.orEmpty(), ).getOrThrow() } } - suspend fun approveAuth(authUrl: String, expectedCapabilities: String): Result = runSuspendCatching { + suspend fun approveAuth( + authUrl: String, + expectedCapabilities: String, + approvedClientId: String, + ): Result = runSuspendCatching { withContext(ioDispatcher) { val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { "No secret key available — use Ring to manage authorizations" } - pubkyService.approveAuth(authUrl, expectedCapabilities, secretKeyHex) + pubkyService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } } suspend fun approveAuthWithCompanionClaim( authUrl: String, + approvedClientId: String, unsignedPayload: ByteArray, ): Result = runSuspendCatching { withContext(ioDispatcher) { @@ -965,6 +971,7 @@ class PubkyRepo @Inject constructor( pubkyService.approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + approvedClientId = approvedClientId, secretKeyHex = secretKeyHex, claim = PubkyAuthCompanionClaim( queryParameter = PubkyAuthClaim.QUERY_PARAMETER, diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index c3b37a26b1..65c5e1ccfb 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -327,9 +327,14 @@ class PaykitSdkService @Inject constructor( } } - suspend fun approveAuth(authUrl: String, expectedCapabilities: String, secretKeyHex: String) { + suspend fun approveAuth( + authUrl: String, + expectedCapabilities: String, + approvedClientId: String, + secretKeyHex: String, + ) { isSetup.await() - bootstrap().approveAuth( + approvalBootstrap(authUrl, approvedClientId).approveAuth( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), @@ -339,11 +344,12 @@ class PaykitSdkService @Inject constructor( suspend fun approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, + approvedClientId: String, secretKeyHex: String, claim: PubkyAuthCompanionClaim, ) { isSetup.await() - bootstrap().approveAuthWithCompanionClaim( + approvalBootstrap(authUrl, approvedClientId).approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, localSecretKey = localSecretKey(secretKeyHex), @@ -916,6 +922,17 @@ class PaykitSdkService @Inject constructor( pubkyClient = pubkyClientConfig, ) + private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap { + val requestClientId = parsePubkyAuthUrl(authUrl).clientId.orEmpty() + require(approvedClientId.isNotBlank() && approvedClientId == requestClientId) { + "Approved Pubky client ID does not match auth request" + } + return PubkySessionBootstrap.withPubkyClientConfig( + clientId = approvedClientId, + pubkyClient = pubkyClientConfig, + ) + } + private fun resetRuntime() { sdk = null } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 991bb6f353..79bebe0881 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -109,20 +109,23 @@ class PubkyService @Inject constructor( suspend fun approveAuth( authUrl: String, expectedCapabilities: String, + approvedClientId: String, secretKeyHex: String, ) = ServiceQueue.CORE.background { - paykitSdkService.approveAuth(authUrl, expectedCapabilities, secretKeyHex) + paykitSdkService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } suspend fun approveAuthWithCompanionClaim( authUrl: String, expectedCapabilities: String, + approvedClientId: String, secretKeyHex: String, claim: PubkyAuthCompanionClaim, ) = ServiceQueue.CORE.background { paykitSdkService.approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = expectedCapabilities, + approvedClientId = approvedClientId, secretKeyHex = secretKeyHex, claim = claim, ) 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 97ec5eae3f..e0810af68c 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 @@ -43,6 +43,7 @@ import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.BiometricsView import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Display @@ -379,6 +380,11 @@ private fun ColumnScope.ApprovalDetails( VerticalSpacer(26.dp) DescriptionText(serviceName = uiState.serviceName) + VerticalSpacer(8.dp) + BodyS( + text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId), + color = Colors.White64, + ) VerticalSpacer(32.dp) PermissionsSection(permissions = uiState.permissions) @@ -572,6 +578,7 @@ private fun AuthorizePreview() { Content( uiState = PubkyAuthApprovalUiState( state = ApprovalState.Authorize, + clientId = "app.paykit.server", serviceName = "pubky.app", permissions = persistentListOf( PubkyAuthPermission(path = "/pub/pubky.app/", accessLevel = "rw"), 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 702d8abc61..7db6384578 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 @@ -89,6 +89,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( } else { ApprovalState.Authorize }, + clientId = request.clientId, serviceName = serviceName, permissions = request.permissions.toImmutableList(), bitkitClaim = request.bitkitClaim, @@ -165,7 +166,12 @@ class PubkyAuthApprovalViewModel @Inject constructor( handleApprovalFailure(it, authUrl) return } - if (_uiState.value.authUrl != authUrl) return + val approvalState = _uiState.value + if (approvalState.authUrl != authUrl) return + if (approvalState.clientId != request.clientId) { + handleApprovalFailure(IllegalArgumentException("Pubky auth requester changed"), authUrl) + return + } if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) @@ -204,8 +210,8 @@ class PubkyAuthApprovalViewModel @Inject constructor( } val approvalResult = preparedClaim?.let { - pubkyRepo.approveAuthWithCompanionClaim(authUrl, it.payload) - } ?: pubkyRepo.approveAuth(authUrl, request.capabilities) + pubkyRepo.approveAuthWithCompanionClaim(authUrl, request.clientId, it.payload) + } ?: pubkyRepo.approveAuth(authUrl, request.capabilities, request.clientId) if (approvalResult.isFailure) { val approvalError = checkNotNull(approvalResult.exceptionOrNull()) { "Authorization failed" } preparedClaim?.let { claim -> @@ -296,6 +302,7 @@ class PubkyAuthApprovalViewModel @Inject constructor( data class PubkyAuthApprovalUiState( val authUrl: String = "", val state: ApprovalState = ApprovalState.Loading, + val clientId: String = "", val serviceName: String = "", val permissions: ImmutableList = persistentListOf(), val bitkitClaim: PubkyAuthClaim? = null, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 30c8a6ee5c..28d5312061 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -604,6 +604,7 @@ Authorizing… OK Requested permissions + Requested by %1$s Use Ring to manage authorizations A service is requesting permission to access and edit your <accent>%1$s</accent> data. Unknown service diff --git a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt index d94cce541f..1577dd834e 100644 --- a/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyAuthRequestTest.kt @@ -13,6 +13,7 @@ class PubkyAuthRequestTest { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES val request = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() @@ -25,6 +26,7 @@ class PubkyAuthRequestTest { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES.split(",").reversed().joinToString(",") val request = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() @@ -43,10 +45,12 @@ class PubkyAuthRequestTest { fun `parse preserves normal auth without Bitkit claim`() { val request = PubkyAuthRequest.parse( rawUrl = authUrl("/pub/bitkit.to/:rw"), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = "/pub/bitkit.to/:rw", ).getOrThrow() + assertEquals("paykit.test", request.clientId) assertNull(request.bitkitClaim) } @@ -56,6 +60,7 @@ class PubkyAuthRequestTest { val request = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() @@ -71,6 +76,7 @@ class PubkyAuthRequestTest { val request = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ).getOrThrow() @@ -84,6 +90,7 @@ class PubkyAuthRequestTest { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ) @@ -100,6 +107,7 @@ class PubkyAuthRequestTest { PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue, ), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ) @@ -112,6 +120,7 @@ class PubkyAuthRequestTest { val capabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, "unknown-v1"), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ) @@ -125,6 +134,7 @@ class PubkyAuthRequestTest { val capabilities = "/pub/paykit/v0/:rw" val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ) @@ -137,6 +147,7 @@ class PubkyAuthRequestTest { val capabilities = "/pub/paykit/v0/bitkit/server/:rw" val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ) @@ -149,6 +160,7 @@ class PubkyAuthRequestTest { val capabilities = "${PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES}," val result = PubkyAuthRequest.parse( rawUrl = authUrl(capabilities, PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue), + clientId = "paykit.test", relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, ) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index eeb69e2a9d..dc8ad84dea 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -209,29 +209,32 @@ class PubkyRepoTest : BaseUnitTest() { fun `approveAuth should forward requested capabilities`() = test { val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw" val capabilities = "/pub/bitkit.to/:rw" + val clientId = "paykit.test" val secretKey = "local_secret" whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - val result = sut.approveAuth(authUrl, capabilities) + val result = sut.approveAuth(authUrl, capabilities, clientId) assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { approveAuth(authUrl, capabilities, secretKey) } + verifyBlocking(pubkyService) { approveAuth(authUrl, capabilities, clientId, secretKey) } } @Test fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test { val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" + val clientId = "paykit.test" val secretKey = "local_secret" val payload = ByteArray(84) { it.toByte() } whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) - val result = sut.approveAuthWithCompanionClaim(authUrl, payload) + val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) assertTrue(result.isSuccess) verifyBlocking(pubkyService) { approveAuthWithCompanionClaim( authUrl = authUrl, expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES, + approvedClientId = clientId, secretKeyHex = secretKey, claim = PubkyAuthCompanionClaim( queryParameter = PubkyAuthClaim.QUERY_PARAMETER, 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 e5229d4bb9..2f7b57141f 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 @@ -34,6 +34,7 @@ import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class PubkyAuthApprovalViewModelTest : BaseUnitTest() { + private val clientId = "paykit.test" private val context: Context = mock() private val profileFlow = MutableStateFlow(null) private val publicKeyFlow = MutableStateFlow(null) @@ -80,7 +81,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Loading, sut.uiState.value.state) verifyBlocking(pubkyRepo, never()) { parseAuthUrl(authUrl) } - verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities, clientId) } } @Test @@ -100,7 +101,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(currentAuthUrl, sut.uiState.value.authUrl) assertEquals(ApprovalState.Authorize, sut.uiState.value.state) verifyBlocking(pubkyRepo, never()) { parseAuthUrl(staleAuthUrl) } - verifyBlocking(pubkyRepo, never()) { approveAuth(staleAuthUrl, "/pub/current/:rw") } + verifyBlocking(pubkyRepo, never()) { approveAuth(staleAuthUrl, "/pub/current/:rw", clientId) } } @Test @@ -120,7 +121,26 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Authorize, sut.uiState.value.state) verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } - verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities, clientId) } + } + + @Test + fun `confirmAuthorize rejects a requester change after user review`() = test { + val authUrl = "pubkyauth://signin?caps=/pub/current/:rw" + val capabilities = "/pub/current/:rw" + whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( + Result.success(authRequest(authUrl, capabilities)), + Result.success(authRequest(authUrl, capabilities, clientId = "changed.test")), + ) + val sut = createSut() + + sut.load(authUrl) + advanceUntilIdle() + sut.confirmAuthorize(authUrl) + advanceUntilIdle() + + assertEquals(ApprovalState.Authorize, sut.uiState.value.state) + verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } } @Test @@ -130,7 +150,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( Result.success(authRequest(authUrl, capabilities)), ) - whenever { pubkyRepo.approveAuth(authUrl, capabilities) }.thenReturn(Result.success(Unit)) + whenever { pubkyRepo.approveAuth(authUrl, capabilities, clientId) }.thenReturn(Result.success(Unit)) val sut = createSut() sut.load(authUrl) @@ -139,8 +159,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(ApprovalState.Success, sut.uiState.value.state) - verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities) } - verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any()) } + assertEquals(clientId, sut.uiState.value.clientId) + verifyBlocking(pubkyRepo) { approveAuth(authUrl, capabilities, clientId) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(any(), any(), any()) } verifyBlocking(watchOnlyAccountRepo, never()) { prepareUnsignedClaim(any(), any()) } } @@ -195,7 +216,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + }.thenReturn(Result.success(Unit)) whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) val sut = createSut() @@ -207,9 +230,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Success, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo) { prepareUnsignedClaim(authUrl, "paykit server") } - verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } verifyBlocking(pubkyRepo, times(2)) { parseAuthUrl(authUrl) } - verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities, clientId) } verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } verifyBlocking(watchOnlyAccountRepo) { markActive(prepared.account.id) } } @@ -227,7 +250,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + }.thenReturn(Result.success(Unit)) whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) val sut = createSut() @@ -241,7 +266,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Success, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } } @@ -264,7 +289,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } .doSuspendableAnswer { approvalResult.await() } whenever { watchOnlyAccountRepo.markActive(prepared.account.id) }.thenReturn(Unit) val sut = createSut() @@ -297,8 +322,8 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(pubkyRepo, times(1)) { parseAuthUrl(secondAuthUrl) } verifyBlocking(watchOnlyAccountRepo, times(1)) { prepareUnsignedClaim(authUrl, "paykit server") } verifyBlocking(watchOnlyAccountRepo, times(1)) { beginAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } - verifyBlocking(pubkyRepo, never()) { approveAuth(secondAuthUrl, secondCapabilities) } + verifyBlocking(pubkyRepo, times(1)) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { approveAuth(secondAuthUrl, secondCapabilities, clientId) } verifyBlocking(watchOnlyAccountRepo, times(1)) { markActive(prepared.account.id) } } @@ -318,7 +343,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } .thenReturn(Result.failure(AppError(authorizationError))) val sut = createSut() @@ -347,7 +372,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) val sut = createSut() @@ -358,7 +383,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { advanceUntilIdle() assertEquals(ApprovalState.Authorize, sut.uiState.value.state) - verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities) } + verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities, clientId) } verifyBlocking(watchOnlyAccountRepo) { beginAuthorization(prepared.account.id) } verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } @@ -380,7 +405,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { ) whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") }.thenReturn(prepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(true) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) } + whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } .thenReturn(Result.failure(IllegalStateException("Relay delivery failed"))) val sut = createSut() @@ -427,7 +452,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Authorize, sut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } verifyBlocking(watchOnlyAccountRepo, never()) { markActive(prepared.account.id) } } @@ -466,7 +491,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(watchOnlyAccountRepo) { cancelAuthorization(prepared.account.id, preserveAuthorizingState = true) } - verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, never()) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } } @Test @@ -494,7 +519,9 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { whenever { watchOnlyAccountRepo.prepareUnsignedClaim(authUrl, "paykit server") } .thenReturn(prepared, retryPrepared) whenever { watchOnlyAccountRepo.beginAuthorization(prepared.account.id) }.thenReturn(false, true) - whenever { pubkyRepo.approveAuthWithCompanionClaim(authUrl, prepared.payload) }.thenReturn(Result.success(Unit)) + whenever { + pubkyRepo.approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) + }.thenReturn(Result.success(Unit)) whenever { watchOnlyAccountRepo.markActive(prepared.account.id) } .thenThrow(IllegalStateException("Persistence failed")) .thenReturn(Unit) @@ -520,7 +547,7 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { assertEquals(ApprovalState.Success, restartedSut.uiState.value.state) verifyBlocking(watchOnlyAccountRepo, times(2)) { prepareUnsignedClaim(authUrl, "paykit server") } verifyBlocking(watchOnlyAccountRepo, times(2)) { beginAuthorization(prepared.account.id) } - verifyBlocking(pubkyRepo, times(2)) { approveAuthWithCompanionClaim(authUrl, prepared.payload) } + verifyBlocking(pubkyRepo, times(2)) { approveAuthWithCompanionClaim(authUrl, clientId, prepared.payload) } verifyBlocking(watchOnlyAccountRepo, times(2)) { markActive(prepared.account.id) } } @@ -534,8 +561,10 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { authUrl: String, capabilities: String, bitkitClaim: PubkyAuthClaim? = null, + clientId: String = this.clientId, ) = PubkyAuthRequest( rawUrl = authUrl, + clientId = clientId, relay = "https://httprelay.pubky.app/inbox/", capabilities = capabilities, permissions = listOf(PubkyAuthPermission(path = "/pub/paykit/v0/bitkit/server/", accessLevel = "rw")), diff --git a/changelog.d/next/1200.security.md b/changelog.d/next/1200.security.md index 5ab4d364f0..bab3444903 100644 --- a/changelog.d/next/1200.security.md +++ b/changelog.d/next/1200.security.md @@ -1 +1 @@ -Updated Pubky authentication to use app-scoped grants with secure sign-out. +Updated Pubky authentication to use app-scoped grants, authorize external services, and securely sign out. From 90df3c0efdb5dfc7e9ef7c16a2293cf0c86aba31 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 2 Sep 2026 13:42:12 -0500 Subject: [PATCH 06/16] fix: harden Pubky auth lifecycle --- .../java/to/bitkit/repositories/PubkyRepo.kt | 22 ++-- .../to/bitkit/services/PaykitSdkService.kt | 5 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 112 ++++++++++++++++-- .../bitkit/services/PaykitSdkServiceTest.kt | 4 +- 4 files changed, 118 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 9dab141e63..38f7c6c38b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -288,14 +288,14 @@ class PubkyRepo @Inject constructor( suspend fun completeAuthentication(): Result { val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) - var didCompleteAuth = false + var shouldRevokeSessionOnFailure = false return try { val result = runSuspendCatching { waitForAuthApproval(attemptId) withContext(ioDispatcher) { withContext(NonCancellable) { pubkyService.completeAuth() - didCompleteAuth = true + shouldRevokeSessionOnFailure = true } ensureAuthAttemptActive(attemptId) val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { @@ -311,7 +311,7 @@ class PubkyRepo @Inject constructor( } if (result.isFailure) { - revokeCompletedAuthSessionIfNeeded(didCompleteAuth) + revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure) if (_activeAuthAttemptId.value == attemptId) { _activeAuthAttemptId.update { null } } @@ -330,12 +330,13 @@ class PubkyRepo @Inject constructor( } _publicKey.update { pk } _authState.update { PubkyAuthState.Authenticated } + shouldRevokeSessionOnFailure = false Logger.info("Completed pubky auth for '${redacted(pk)}'", context = TAG) loadProfile() loadContacts() }.map { } } catch (e: CancellationException) { - revokeCompletedAuthSessionIfNeeded(didCompleteAuth) + revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure) if (_activeAuthAttemptId.value == attemptId) { _activeAuthAttemptId.update { null } } @@ -347,8 +348,8 @@ class PubkyRepo @Inject constructor( } } - private suspend fun revokeCompletedAuthSessionIfNeeded(didCompleteAuth: Boolean) { - if (!didCompleteAuth) return + private suspend fun revokeCompletedAuthSessionIfNeeded(shouldRevokeSession: Boolean) { + if (!shouldRevokeSession) return runSuspendCatching { withContext(NonCancellable + ioDispatcher) { pubkyService.signOut() @@ -556,6 +557,7 @@ class PubkyRepo @Inject constructor( val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } writeProfile(name, bio, links, tags, imageUrl) + shouldRevokeSessionOnFailure = false finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl) } } @@ -1079,13 +1081,13 @@ class PubkyRepo @Inject constructor( // region Sign out - suspend fun signOut(): Result { + suspend fun signOut(): Result = withContext(NonCancellable + ioDispatcher) { val hadPaykitState = settingsStore.data.first().hasPaykitState() val endpointCleanupResult = removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } val result = runSuspendCatching { - withContext(ioDispatcher) { pubkyService.signOut() } + pubkyService.signOut() }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } if (result.isFailure) { @@ -1096,11 +1098,11 @@ class PubkyRepo @Inject constructor( Logger.warn("Failed to mark Paykit state for reconciliation", it, context = TAG) } } - return result + return@withContext result } clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) - return result + result } suspend fun wipeLocalState() { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 65c5e1ccfb..31cf6d1dfa 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -801,6 +801,7 @@ class PaykitSdkService @Inject constructor( suspend fun forgetSessionAccess() { isSetup.await() operationMutex.withLock { + activeAuthRequest = null withStateRevisionTracking { handle -> handle.forgetSessionAccess() } @@ -1077,7 +1078,7 @@ internal class PaykitSdkSessionProvider( keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)?.isNotBlank() == true fun canDeferStaleSession(errorContext: String): Boolean = - errorContext == STALE_SESSION_IMPORT_CONTEXT && hasSessionAccess() + errorContext == STALE_SESSION_RESTORE_CONTEXT && hasSessionAccess() fun suspendStoredSessionAccess() = synchronized(lock) { liveSessionAccess = null @@ -1097,7 +1098,7 @@ internal class PaykitSdkSessionProvider( } private companion object { - const val STALE_SESSION_IMPORT_CONTEXT = "import Pubky session from platform provider" + const val STALE_SESSION_RESTORE_CONTEXT = "restore Pubky grant session from platform provider" } fun loadLocalSecretKey(): PubkyLocalSecretKey? { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index dc8ad84dea..4ef6e36193 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -284,6 +284,33 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { signOut() } } + @Test + fun `completeAuthentication should keep session when canceled during profile load`() = test { + val profileLoadStarted = CompletableDeferred() + val finishProfileLoad = CompletableDeferred() + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenReturn(Unit) + whenever(pubkyService.currentPublicKey()).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + profileLoadStarted.complete(Unit) + finishProfileLoad.await() + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) + } + + val authRequest = startAuthForTesting() + approveAuthForTesting(authRequest) + val result = async { sut.completeAuthentication() } + profileLoadStarted.await() + + assertTrue(sut.isAuthenticated.value) + result.cancel() + finishProfileLoad.complete(Unit) + result.join() + + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { signOut() } + } + @Test fun `cancelAuthentication should reset state to idle`() = test { whenever(pubkyService.startAuth()).thenReturn("auth_uri") @@ -461,17 +488,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `createIdentity should revoke session when profile publication fails`() = test { - val httpClient = HttpClient( - MockEngine { - respond( - content = """{"signupCode":"test-code","homeserverPubky":"test-homeserver"}""", - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - install(ContentNegotiation) { json() } - } + val httpClient = identityHttpClient() sut = createSut(httpClient) whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("test-secret") @@ -493,6 +510,46 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { signOut() } } + @Test + fun `createIdentity should keep session when canceled during contact load`() = test { + val contactsLoadStarted = CompletableDeferred() + val finishContactsLoad = CompletableDeferred() + val httpClient = identityHttpClient() + sut = createSut(httpClient) + whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("test-secret") + whenever(pubkyService.publicKeyFromSecret("test-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) + whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenReturn(Unit) + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) + .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) + whenever(pubkyService.contactRecords()).doSuspendableAnswer { + contactsLoadStarted.complete(Unit) + finishContactsLoad.await() + emptyList() + } + + val result = async { + sut.createIdentity( + name = "Test", + bio = "", + links = emptyList(), + tags = emptyList(), + avatarBytes = null, + ) + } + contactsLoadStarted.await() + + assertTrue(sut.isAuthenticated.value) + result.cancel() + finishContactsLoad.complete(Unit) + result.join() + httpClient.close() + + assertTrue(sut.isAuthenticated.value) + verifyBlocking(pubkyService, never()) { signOut() } + } + @Test fun `loadProfile should update profile on success`() = test { authenticateForTesting() @@ -764,6 +821,27 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(settingsFlow.value.publicPaykitCleanupPending) } + @Test + fun `signOut should finish clearing state after caller cancellation`() = test { + val revocationStarted = CompletableDeferred() + val finishRevocation = CompletableDeferred() + authenticateForTesting() + whenever(pubkyService.signOut()).doSuspendableAnswer { + revocationStarted.complete(Unit) + finishRevocation.await() + } + + val result = async { sut.signOut() } + revocationStarted.await() + result.cancel() + finishRevocation.complete(Unit) + result.join() + + assertFalse(sut.isAuthenticated.value) + assertNull(sut.publicKey.value) + verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } + } + @Test fun `clearPendingImport should only clear pending import state`() = test { authenticateForTesting() @@ -1394,6 +1472,18 @@ class PubkyRepoTest : BaseUnitTest() { sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = authRequest.callbackNonce)) } + private fun identityHttpClient() = HttpClient( + MockEngine { + respond( + content = """{"signupCode":"test-code","homeserverPubky":"test-homeserver"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json() } + } + private fun createPubkyProfile( name: String = "Test", bio: String = "", diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 1765a7a3fc..c55517c10e 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -152,7 +152,7 @@ class PaykitSdkServiceTest { val provider = PaykitSdkSessionProvider(keychain) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") - assertTrue(provider.canDeferStaleSession("import Pubky session from platform provider")) + assertTrue(provider.canDeferStaleSession("restore Pubky grant session from platform provider")) provider.suspendStoredSessionAccess() assertNull(provider.loadSessionAccess()) } @@ -163,7 +163,7 @@ class PaykitSdkServiceTest { val provider = PaykitSdkSessionProvider(keychain) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) - assertTrue(!provider.canDeferStaleSession("import Pubky session from platform provider")) + assertTrue(!provider.canDeferStaleSession("restore Pubky grant session from platform provider")) assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) } From 6ac14513e8ec1176669ac16bf694d1194542075b Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 07:42:09 -0500 Subject: [PATCH 07/16] fix: validate Pubky auth requester --- .../java/to/bitkit/models/PubkyAuthRequest.kt | 1 + .../to/bitkit/services/PaykitSdkService.kt | 13 +++++++++---- .../profile/PubkyAuthApprovalViewModel.kt | 4 ---- .../bitkit/ui/utils/PubkyAuthErrorMessage.kt | 1 + .../bitkit/services/PaykitSdkServiceTest.kt | 13 +++++++++++++ .../profile/PubkyAuthApprovalViewModelTest.kt | 19 ------------------- 6 files changed, 24 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt index fde0811148..86ce14a809 100644 --- a/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt +++ b/app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt @@ -38,6 +38,7 @@ enum class PubkyAuthClaim(val wireValue: String) { sealed class PubkyAuthRequestError(cause: Throwable? = null) : AppError(cause = cause) { class InvalidUrl(cause: Throwable) : PubkyAuthRequestError(cause) + data object RequesterChanged : PubkyAuthRequestError() data object MissingBitkitClaim : PubkyAuthRequestError() data object DuplicateBitkitClaim : PubkyAuthRequestError() data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError() diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 31cf6d1dfa..9bf81d2830 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -81,6 +81,7 @@ import to.bitkit.env.Env import to.bitkit.ext.fromHex import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.repositories.Endpoint import to.bitkit.repositories.PublicPaykitRepo @@ -925,11 +926,8 @@ class PaykitSdkService @Inject constructor( private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap { val requestClientId = parsePubkyAuthUrl(authUrl).clientId.orEmpty() - require(approvedClientId.isNotBlank() && approvedClientId == requestClientId) { - "Approved Pubky client ID does not match auth request" - } return PubkySessionBootstrap.withPubkyClientConfig( - clientId = approvedClientId, + clientId = validatedApprovalClientId(requestClientId, approvedClientId), pubkyClient = pubkyClientConfig, ) } @@ -1000,6 +998,13 @@ internal fun paykitPubkyClientConfig( baseConfig } +internal fun validatedApprovalClientId(requestClientId: String, approvedClientId: String): String { + if (approvedClientId.isBlank() || approvedClientId != requestClientId) { + throw PubkyAuthRequestError.RequesterChanged + } + return requestClientId +} + private class PaykitSdkStateBlobStore( private val keychain: Keychain, ) : SdkStateBlobStore { 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 7db6384578..516ac57055 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 @@ -168,10 +168,6 @@ class PubkyAuthApprovalViewModel @Inject constructor( } val approvalState = _uiState.value if (approvalState.authUrl != authUrl) return - if (approvalState.clientId != request.clientId) { - handleApprovalFailure(IllegalArgumentException("Pubky auth requester changed"), authUrl) - return - } if (!approveRequest(request, authUrl)) return Logger.info("Auth approved for '${request.serviceNames.firstOrNull().orEmpty()}'", context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt index e558c189d7..580cf89ff4 100644 --- a/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt +++ b/app/src/main/java/to/bitkit/ui/utils/PubkyAuthErrorMessage.kt @@ -10,6 +10,7 @@ fun Throwable.localizedPubkyAuthMessage(context: Context): String? { while (current != null) { val messageResource = when (current) { is PubkyAuthRequestError.InvalidUrl -> R.string.profile__auth_error_invalid_url + PubkyAuthRequestError.RequesterChanged -> R.string.profile__auth_error_invalid_url PubkyAuthRequestError.MissingBitkitClaim -> R.string.profile__auth_error_missing_claim PubkyAuthRequestError.DuplicateBitkitClaim -> R.string.profile__auth_error_duplicate_claim is PubkyAuthRequestError.UnsupportedBitkitClaim -> R.string.profile__auth_error_unsupported_claim diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index c55517c10e..5a2253748b 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -10,6 +10,7 @@ import org.mockito.kotlin.whenever import to.bitkit.data.keychain.Keychain import to.bitkit.ext.fromHex import to.bitkit.ext.toHex +import to.bitkit.models.PubkyAuthRequestError import to.bitkit.utils.AppError import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -54,6 +55,18 @@ class PaykitSdkServiceTest { assertEquals(basePubkyClientConfig.requestTimeoutSecs, config.requestTimeoutSecs) } + @Test + fun `approval uses external requester client id`() { + assertEquals("paykit.test", validatedApprovalClientId("paykit.test", "paykit.test")) + } + + @Test + fun `approval rejects a mismatched client id`() { + assertFailsWith { + validatedApprovalClientId("paykit.test", "different.test") + } + } + @Test fun `receiver noise derivation matches cross platform vector`() { val seed = ( 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 2f7b57141f..bc6617bd8e 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 @@ -124,25 +124,6 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() { verifyBlocking(pubkyRepo, never()) { approveAuth(authUrl, capabilities, clientId) } } - @Test - fun `confirmAuthorize rejects a requester change after user review`() = test { - val authUrl = "pubkyauth://signin?caps=/pub/current/:rw" - val capabilities = "/pub/current/:rw" - whenever { pubkyRepo.parseAuthUrl(authUrl) }.thenReturn( - Result.success(authRequest(authUrl, capabilities)), - Result.success(authRequest(authUrl, capabilities, clientId = "changed.test")), - ) - val sut = createSut() - - sut.load(authUrl) - advanceUntilIdle() - sut.confirmAuthorize(authUrl) - advanceUntilIdle() - - assertEquals(ApprovalState.Authorize, sut.uiState.value.state) - verifyBlocking(pubkyRepo, never()) { approveAuth(any(), any(), any()) } - } - @Test fun `ordinary authorization uses the requested capabilities`() = test { val authUrl = "pubkyauth://signin?caps=/pub/example/:rw" From 5606e1c5acb420a101914b0e311c64611596c1e2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 12:12:35 -0500 Subject: [PATCH 08/16] fix: remove stale paykit endpoints --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 8 ++++---- .../java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 727598884b..91dfb48792 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -996,10 +996,10 @@ class AppViewModel @Inject constructor( private suspend fun retryPendingPaykitEndpointRemoval(contactKeys: Collection, reason: String) { val settings = settingsStore.data.first() if (settings.publicPaykitCleanupPending) { - val reconciliationResult = when { - settings.sharesPublicPaykitEndpoints -> publicPaykitRepo.syncCurrentPublishedEndpoints() - settings.sharesPrivatePaykitEndpoints -> publicPaykitRepo.syncLocalReceiverMarker() - else -> publicPaykitRepo.syncPublishedEndpoints(publish = false) + val reconciliationResult = if (settings.sharesPublicPaykitEndpoints) { + publicPaykitRepo.syncCurrentPublishedEndpoints() + } else { + publicPaykitRepo.syncPublishedEndpoints(publish = false) } reconciliationResult .onSuccess { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2053130097..75eb224dda 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -4914,17 +4914,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `private Paykit refresh reconciles pending private-only receiver marker`() = test { + fun `private Paykit refresh removes pending public endpoints from private-only state`() = test { settingsData.value = SettingsData( sharesPrivatePaykitEndpoints = true, publicPaykitCleanupPending = true, ) - whenever(publicPaykitRepo.syncLocalReceiverMarker()).thenReturn(Result.success(Unit)) + whenever(publicPaykitRepo.syncPublishedEndpoints(publish = false)).thenReturn(Result.success(Unit)) sut.refreshPrivatePaykitEndpoints() advanceUntilIdle() - verify(publicPaykitRepo).syncLocalReceiverMarker() + verify(publicPaykitRepo).syncPublishedEndpoints(publish = false) + verify(publicPaykitRepo, never()).syncLocalReceiverMarker() assertFalse(settingsData.value.publicPaykitCleanupPending) verify(privatePaykitRepo).retryPendingEndpointRemoval(emptyList()) } From ecb0499e9991766834d3958434596bedadb9cd30 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 12:59:56 -0500 Subject: [PATCH 09/16] fix: bound auth requester label --- .../to/bitkit/ui/screens/profile/PubkyAuthApprovalSheet.kt | 3 +++ app/src/main/res/values/strings.xml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 e0810af68c..8cb85ac8a7 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -384,6 +385,8 @@ private fun ColumnScope.ApprovalDetails( BodyS( text = stringResource(R.string.profile__auth_approval_requester, uiState.clientId), color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) VerticalSpacer(32.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28d5312061..10f3e82627 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -604,7 +604,7 @@ Authorizing… OK Requested permissions - Requested by %1$s + Requester ID: %1$s Use Ring to manage authorizations A service is requesting permission to access and edit your <accent>%1$s</accent> data. Unknown service From b735fd55bb5274346040719000fc9f911f8d4dd8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 3 Sep 2026 17:12:48 -0500 Subject: [PATCH 10/16] fix: clear abandoned paykit sessions --- .../java/to/bitkit/repositories/PubkyRepo.kt | 23 +++++++++++-------- .../to/bitkit/repositories/PubkyRepoTest.kt | 9 ++++++-- 2 files changed, 21 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 38f7c6c38b..c24700d553 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -350,12 +350,23 @@ class PubkyRepo @Inject constructor( private suspend fun revokeCompletedAuthSessionIfNeeded(shouldRevokeSession: Boolean) { if (!shouldRevokeSession) return - runSuspendCatching { + discardAbandonedSession() + } + + private suspend fun discardAbandonedSession() { + val revocationError = runSuspendCatching { withContext(NonCancellable + ioDispatcher) { pubkyService.signOut() } + }.exceptionOrNull() ?: return + + Logger.warn("Failed to revoke abandoned Pubky session", revocationError, context = TAG) + runSuspendCatching { + withContext(NonCancellable + ioDispatcher) { + pubkyService.forgetSessionAccess() + } }.onFailure { - Logger.warn("Failed to revoke canceled Pubky auth session", it, context = TAG) + Logger.warn("Failed to forget abandoned Pubky session access", it, context = TAG) } } @@ -598,13 +609,7 @@ class PubkyRepo @Inject constructor( private suspend fun revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSession: Boolean) { if (!shouldRevokeSession) return - runSuspendCatching { - withContext(NonCancellable + ioDispatcher) { - pubkyService.signOut() - } - }.onFailure { - Logger.warn("Failed to revoke incomplete Pubky profile session", it, context = TAG) - } + discardAbandonedSession() } suspend fun uploadAvatar(imageBytes: ByteArray): Result = runSuspendCatching { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 4ef6e36193..1d01186d2f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -246,12 +246,13 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `completeAuthentication should revoke session when auth is canceled after completion`() = test { + fun `completeAuthentication should forget session when canceled session revocation fails`() = test { whenever(pubkyService.startAuth()).thenReturn("auth_uri") whenever(pubkyService.completeAuth()).thenAnswer { runBlocking { sut.cancelAuthentication() } Unit } + whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val authRequest = startAuthForTesting() approveAuthForTesting(authRequest) @@ -259,6 +260,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isFailure) verifyBlocking(pubkyService) { signOut() } + verifyBlocking(pubkyService) { forgetSessionAccess() } } @Test @@ -487,7 +489,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `createIdentity should revoke session when profile publication fails`() = test { + fun `createIdentity should forget session when incomplete session revocation fails`() = test { val httpClient = identityHttpClient() sut = createSut(httpClient) whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") @@ -495,6 +497,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyService.publicKeyFromSecret("test-secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenReturn(Unit) whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } val result = sut.createIdentity( name = "Test", @@ -506,8 +509,10 @@ class PubkyRepoTest : BaseUnitTest() { httpClient.close() assertTrue(result.isFailure) + assertEquals("Publish failed", result.exceptionOrNull()?.message) verifyBlocking(pubkyService) { signUp("test-secret", "test-homeserver", "test-code") } verifyBlocking(pubkyService) { signOut() } + verifyBlocking(pubkyService) { forgetSessionAccess() } } @Test From a681dfc6c2d469fa4f0beebb22c0953e9f65f413 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 4 Sep 2026 07:23:39 -0500 Subject: [PATCH 11/16] fix: harden Paykit session recovery --- .../java/to/bitkit/repositories/PubkyRepo.kt | 13 +++++- .../to/bitkit/services/PaykitSdkService.kt | 10 ++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 40 +++++++++++++++++++ .../bitkit/services/PaykitSdkServiceTest.kt | 17 ++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index c24700d553..7bd294ee0e 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain +import to.bitkit.data.paykitDisabled import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching @@ -294,8 +295,8 @@ class PubkyRepo @Inject constructor( waitForAuthApproval(attemptId) withContext(ioDispatcher) { withContext(NonCancellable) { - pubkyService.completeAuth() shouldRevokeSessionOnFailure = true + pubkyService.completeAuth() } ensureAuthAttemptActive(attemptId) val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { @@ -675,6 +676,7 @@ class PubkyRepo @Inject constructor( Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG) } } + settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPaykitState()) } signOut().getOrThrow() } @@ -1023,7 +1025,14 @@ class PubkyRepo @Inject constructor( ensureServiceInitialized() initializeMutex.withLock { - pubkyService.forgetSessionAccess() + runSuspendCatching { pubkyService.forgetSessionAccess() } + .onFailure { + Logger.warn( + "Failed to forget existing Pubky session before restore", + it, + context = TAG, + ) + } clearAuthenticatedState() runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 9bf81d2830..35d8d7b37f 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1097,8 +1097,7 @@ internal class PaykitSdkSessionProvider( override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { - delete(Keychain.Key.PUBKY_SECRET_KEY.name) - delete(Keychain.Key.PAYKIT_SESSION.name) + clearPubkySessionCredentials(::delete) } } @@ -1157,6 +1156,13 @@ internal object PaykitReceiverNoiseKeyDerivation { } } +internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { + val sessionResult = runCatching { deleteKeychainValue(Keychain.Key.PAYKIT_SESSION.name) } + val localSecretResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_SECRET_KEY.name) } + sessionResult.getOrThrow() + localSecretResult.getOrThrow() +} + internal class PaykitReceiverNoiseKeyStore( private val loadBytes: () -> ByteArray?, private val upsertBytes: (ByteArray) -> Unit, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 1d01186d2f..7c97d4296e 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -183,6 +183,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isFailure) assertFalse(sut.isAuthenticated.value) assertNull(sut.publicKey.value) + verifyBlocking(pubkyService) { signOut() } } @Test @@ -765,12 +766,19 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `deleteProfile should fail when signOut fails`() = test { authenticateForTesting() + settingsFlow.value = SettingsData( + sharesPublicPaykitEndpoints = true, + sharesPrivatePaykitEndpoints = true, + ) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("test_secret") whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Sign out failed") } val result = sut.deleteProfile() assertTrue(result.isFailure) + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) } @Test @@ -1083,6 +1091,38 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } } + @Test + fun `restoreSessionBackupState should import external session when forgetting current session fails`() = test { + whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Forget failed") } + whenever(pubkyService.importExternalSession("external_session")).thenReturn(VALID_SELF_KEY) + + val result = sut.restoreSessionBackupState( + PubkySessionBackupV1( + kind = PubkySessionBackupKind.ExternalSession, + sessionSecret = "external_session", + ), + ) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + + @Test + fun `restore without backup clears credentials when forgetting current session fails`() = test { + authenticateForTesting(publicKey = VALID_SELF_KEY) + whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Forget failed") } + + val result = sut.restoreSessionBackupState(null) + + assertTrue(result.isSuccess) + assertFalse(sut.isAuthenticated.value) + assertNull(sut.publicKey.value) + verifyBlocking(keychain) { delete(Keychain.Key.PAYKIT_SESSION.name) } + verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + @Test fun `loadContacts should populate contacts on success`() = test { authenticateForTesting() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 5a2253748b..433b856c62 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -180,6 +180,23 @@ class PaykitSdkServiceTest { assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) } + @Test + fun `session teardown attempts both credentials with session first`() { + val attemptedKeys = mutableListOf() + + assertFailsWith { + clearPubkySessionCredentials { + attemptedKeys += it + if (it == Keychain.Key.PAYKIT_SESSION.name) throw AppError("Delete failed") + } + } + + assertEquals( + listOf(Keychain.Key.PAYKIT_SESSION.name, Keychain.Key.PUBKY_SECRET_KEY.name), + attemptedKeys, + ) + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, From d2634de17d602be4adda61cd1acef62beebdc773 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 4 Sep 2026 07:33:55 -0500 Subject: [PATCH 12/16] fix: reset forgotten Paykit sessions --- app/src/main/java/to/bitkit/services/PaykitSdkService.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 35d8d7b37f..39bfef26d6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -803,10 +803,13 @@ class PaykitSdkService @Inject constructor( isSetup.await() operationMutex.withLock { activeAuthRequest = null - withStateRevisionTracking { handle -> - handle.forgetSessionAccess() + try { + withStateRevisionTracking { handle -> + handle.forgetSessionAccess() + } + } finally { + resetRuntime() } - resetRuntime() } } From 580b042fd0fb719b2a24c6da70ceb36bec97d783 Mon Sep 17 00:00:00 2001 From: benk10 Date: Fri, 4 Sep 2026 17:11:24 -0500 Subject: [PATCH 13/16] fix: preserve payment request history --- changelog.d/next/1200.security.md | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/next/1200.security.md b/changelog.d/next/1200.security.md index bab3444903..1088411f6d 100644 --- a/changelog.d/next/1200.security.md +++ b/changelog.d/next/1200.security.md @@ -1 +1 @@ -Updated Pubky authentication to use app-scoped grants, authorize external services, and securely sign out. +Added app-scoped Pubky authorization and secure sign-out, and fixed missing payment requests in history. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4a8bf6446..499e78316e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.14" } -paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc50" } +paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc51" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From b748938b6147dfb738d50860f0f49d78a04772b1 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 6 Sep 2026 17:00:36 +0200 Subject: [PATCH 14/16] fix: clear abandoned pubky credentials --- .../java/to/bitkit/repositories/PubkyRepo.kt | 3 +++ .../to/bitkit/repositories/PubkyRepoTest.kt | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 7bd294ee0e..a16592b224 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -368,6 +368,9 @@ class PubkyRepo @Inject constructor( } }.onFailure { Logger.warn("Failed to forget abandoned Pubky session access", it, context = TAG) + withContext(NonCancellable + ioDispatcher) { + clearLocalState() + } } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7c97d4296e..2ba1cd898f 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -264,6 +264,26 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { forgetSessionAccess() } } + @Test + fun `completeAuthentication clears credentials when abandoned session cleanup fails`() = test { + whenever(pubkyService.startAuth()).thenReturn("auth_uri") + whenever(pubkyService.completeAuth()).thenAnswer { + runBlocking { sut.cancelAuthentication() } + Unit + } + whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } + whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Cleanup error") } + + val authRequest = startAuthForTesting() + approveAuthForTesting(authRequest) + val result = sut.completeAuthentication() + + assertTrue(result.isFailure) + assertFalse(sut.isAuthenticated.value) + verify(keychain).delete(Keychain.Key.PAYKIT_SESSION.name) + verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + @Test fun `completeAuthentication should revoke session when canceled during completion`() = test { val completionStarted = CompletableDeferred() From c4611067d7869be41a47457b3a05cff657ed1b7d Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 20:10:12 +0300 Subject: [PATCH 15/16] fix: recover legacy sessions without dropping cleanup state --- app/src/main/java/to/bitkit/repositories/PubkyRepo.kt | 2 +- .../main/java/to/bitkit/services/PaykitSdkService.kt | 6 +++++- .../test/java/to/bitkit/repositories/PubkyRepoTest.kt | 1 + .../java/to/bitkit/services/PaykitSdkServiceTest.kt | 11 ++++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index a16592b224..0de1109d58 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -369,7 +369,7 @@ class PubkyRepo @Inject constructor( }.onFailure { Logger.warn("Failed to forget abandoned Pubky session access", it, context = TAG) withContext(NonCancellable + ioDispatcher) { - clearLocalState() + clearLocalState(publicPaykitCleanupPending = true) } } } diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 39bfef26d6..632e4387f1 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1086,7 +1086,11 @@ internal class PaykitSdkSessionProvider( keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)?.isNotBlank() == true fun canDeferStaleSession(errorContext: String): Boolean = - errorContext == STALE_SESSION_RESTORE_CONTEXT && hasSessionAccess() + hasSessionAccess() && ( + errorContext == STALE_SESSION_RESTORE_CONTEXT || + errorContext == "Pubky session must be grant-backed" || + errorContext.startsWith("Pubky grant client ID `") + ) fun suspendStoredSessionAccess() = synchronized(lock) { liveSessionAccess = null diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 2ba1cd898f..444a811df9 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -282,6 +282,7 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) verify(keychain).delete(Keychain.Key.PAYKIT_SESSION.name) verify(keychain).delete(Keychain.Key.PUBKY_SECRET_KEY.name) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 433b856c62..604f95f4f8 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -165,7 +165,12 @@ class PaykitSdkServiceTest { val provider = PaykitSdkSessionProvider(keychain) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") - assertTrue(provider.canDeferStaleSession("restore Pubky grant session from platform provider")) + listOf( + "restore Pubky grant session from platform provider", + "Pubky session must be grant-backed", + "Pubky grant client ID `old.bitkit.to` did not match `staging.bitkit.to`", + ).forEach { assertTrue(provider.canDeferStaleSession(it)) } + assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) provider.suspendStoredSessionAccess() assertNull(provider.loadSessionAccess()) } @@ -177,6 +182,10 @@ class PaykitSdkServiceTest { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) assertTrue(!provider.canDeferStaleSession("restore Pubky grant session from platform provider")) + assertTrue(!provider.canDeferStaleSession("Pubky session must be grant-backed")) + assertTrue( + !provider.canDeferStaleSession("Pubky grant client ID `old.bitkit.to` did not match `staging.bitkit.to`"), + ) assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) } From d8882611f316169117c9e3cb984c15d205a72ab3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 7 Sep 2026 21:22:13 +0300 Subject: [PATCH 16/16] refactor: remove paykit migration handling --- .../main/java/to/bitkit/services/PaykitSdkService.kt | 6 +----- .../java/to/bitkit/services/PaykitSdkServiceTest.kt | 10 +--------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 632e4387f1..39bfef26d6 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1086,11 +1086,7 @@ internal class PaykitSdkSessionProvider( keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)?.isNotBlank() == true fun canDeferStaleSession(errorContext: String): Boolean = - hasSessionAccess() && ( - errorContext == STALE_SESSION_RESTORE_CONTEXT || - errorContext == "Pubky session must be grant-backed" || - errorContext.startsWith("Pubky grant client ID `") - ) + errorContext == STALE_SESSION_RESTORE_CONTEXT && hasSessionAccess() fun suspendStoredSessionAccess() = synchronized(lock) { liveSessionAccess = null diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 604f95f4f8..5a74b7f34c 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -165,11 +165,7 @@ class PaykitSdkServiceTest { val provider = PaykitSdkSessionProvider(keychain) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved-session") - listOf( - "restore Pubky grant session from platform provider", - "Pubky session must be grant-backed", - "Pubky grant client ID `old.bitkit.to` did not match `staging.bitkit.to`", - ).forEach { assertTrue(provider.canDeferStaleSession(it)) } + assertTrue(provider.canDeferStaleSession("restore Pubky grant session from platform provider")) assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) provider.suspendStoredSessionAccess() assertNull(provider.loadSessionAccess()) @@ -182,10 +178,6 @@ class PaykitSdkServiceTest { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) assertTrue(!provider.canDeferStaleSession("restore Pubky grant session from platform provider")) - assertTrue(!provider.canDeferStaleSession("Pubky session must be grant-backed")) - assertTrue( - !provider.canDeferStaleSession("Pubky grant client ID `old.bitkit.to` did not match `staging.bitkit.to`"), - ) assertTrue(!provider.canDeferStaleSession("local Pubky secret key does not match session public key")) }