From 938406502b6bd2a2661e864d52ec89b18b1084d8 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 15:44:28 +0200 Subject: [PATCH 01/23] feat: share pubky identities with ring --- app/src/main/AndroidManifest.xml | 15 +- .../main/java/to/bitkit/data/PubkyStore.kt | 2 + .../java/to/bitkit/data/keychain/Keychain.kt | 2 + .../data/sharing/SharedPubkyContract.kt | 107 ++++ .../data/sharing/SharedPubkyDiscovery.kt | 137 +++++ .../data/sharing/SharedPubkyProvider.kt | 192 ++++++ .../java/to/bitkit/repositories/PubkyRepo.kt | 569 +++++++++++++++--- .../to/bitkit/services/PaykitSdkService.kt | 120 +++- .../java/to/bitkit/services/PubkyService.kt | 8 + app/src/main/java/to/bitkit/ui/ContentView.kt | 3 - .../main/java/to/bitkit/ui/MainActivity.kt | 5 + .../main/java/to/bitkit/ui/components/Text.kt | 4 + .../ui/screens/profile/PubkyChoiceScreen.kt | 307 ++++++---- .../screens/profile/PubkyChoiceViewModel.kt | 221 +++---- .../main/java/to/bitkit/ui/theme/Colors.kt | 2 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 9 + app/src/main/res/drawable/ic_key.xml | 17 + app/src/main/res/values/strings.xml | 17 +- .../data/sharing/SharedPubkyContractTest.kt | 82 +++ .../data/sharing/SharedPubkyManifestTest.kt | 41 ++ .../data/sharing/SharedPubkyProviderTest.kt | 78 +++ .../to/bitkit/repositories/PubkyRepoTest.kt | 274 ++++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 55 +- .../profile/PubkyChoiceViewModelTest.kt | 172 +++--- .../viewmodels/AppViewModelSendFlowTest.kt | 10 + changelog.d/next/shared-pubky-ring.added.md | 1 + 26 files changed, 1967 insertions(+), 483 deletions(-) create mode 100644 app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt create mode 100644 app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt create mode 100644 app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt create mode 100644 app/src/main/res/drawable/ic_key.xml create mode 100644 app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt create mode 100644 app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt create mode 100644 app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt create mode 100644 changelog.d/next/shared-pubky-ring.added.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 518327d9ae..5e40fc8d5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> - + @@ -13,6 +13,12 @@ + + + + @@ -227,6 +233,13 @@ android:exported="false" tools:node="remove" /> + + diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 3dcc132236..7eb5c9053b 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import to.bitkit.data.serializers.PubkyStoreSerializer +import to.bitkit.data.sharing.ExternalPubkyIdentityRef import to.bitkit.models.PubkyProfileData import javax.inject.Inject import javax.inject.Singleton @@ -38,4 +39,5 @@ data class PubkyStoreData( val cachedName: String? = null, val cachedImageUri: String? = null, val contactProfileOverrides: Map = emptyMap(), + val externalIdentityRef: ExternalPubkyIdentityRef? = null, ) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index fefa721567..8b95d35bb4 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -236,6 +236,8 @@ class Keychain @Inject constructor( PAYKIT_SDK_STATE, PAYKIT_PENDING_PAYMENT_PROOFS, PAYKIT_PRESENTED_PAYMENT_REQUESTS, + PUBKY_MANAGED_SECRET_QUARANTINED, + PUBKY_SHARED_EXPORT_ENABLED, PUBKY_SECRET_KEY, } } diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt new file mode 100644 index 0000000000..a5f49a13b2 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt @@ -0,0 +1,107 @@ +package to.bitkit.data.sharing + +import android.net.Uri +import kotlinx.serialization.Serializable +import to.bitkit.utils.AppError +import java.util.Locale + +object SharedPubkyContract { + const val PROTOCOL_VERSION = 1 + const val BITKIT_SOURCE = "to.bitkit" + const val RING_SOURCE = "app.pubkyring" + const val RING_AUTHORITY = "app.pubkyring.sharedpubky" + const val RING_READ_PERMISSION = "app.pubkyring.permission.READ_SHARED_PUBKY" + const val IDENTITIES_PATH = "v1/identities" + const val RING_IDENTITIES_URI = "content://$RING_AUTHORITY/$IDENTITIES_PATH" + + const val COLUMN_PROTOCOL_VERSION = "protocol_version" + const val COLUMN_SOURCE_PACKAGE = "source_package" + const val COLUMN_PUBKY = "pubky" + const val COLUMN_SECRET_KEY = "secret_key" + + private const val BITKIT_PUBKY_PREFIX = "pubky" + private const val WIRE_PUBKY_LENGTH = 52 + private const val CREDENTIAL_SEGMENT = "credential" + private val wirePubkyPattern = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$") + private val secretKeyPattern = Regex("^[0-9a-f]{64}$") + + val publicColumns = arrayOf( + COLUMN_PROTOCOL_VERSION, + COLUMN_SOURCE_PACKAGE, + COLUMN_PUBKY, + ) + val credentialColumns = publicColumns + COLUMN_SECRET_KEY + + val ringIdentitiesUri: Uri + get() = Uri.parse(RING_IDENTITIES_URI) + + fun ringCredentialUri(pubky: String): Uri = Uri.parse(ringCredentialUriString(pubky)) + + internal fun ringCredentialUriString(pubky: String): String = + "$RING_IDENTITIES_URI/${canonicalPubky(pubky)}/$CREDENTIAL_SEGMENT" + + fun canonicalPubky(value: String): String { + val normalizedPubky = value.trim().lowercase(Locale.US) + val barePubky = if ( + normalizedPubky.length == WIRE_PUBKY_LENGTH + BITKIT_PUBKY_PREFIX.length && + normalizedPubky.startsWith(BITKIT_PUBKY_PREFIX) + ) { + normalizedPubky.removePrefix(BITKIT_PUBKY_PREFIX) + } else { + normalizedPubky + } + require(wirePubkyPattern.matches(barePubky)) { "Invalid shared Pubky public key" } + return barePubky + } + + fun requireWirePubky(value: String): String { + require(wirePubkyPattern.matches(value)) { "Invalid shared Pubky wire public key" } + return value + } + + fun toBitkitPubky(value: String): String = "$BITKIT_PUBKY_PREFIX${canonicalPubky(value)}" + + fun canonicalSecretKeyHex(value: String): String { + require(secretKeyPattern.matches(value)) { "Invalid shared Pubky secret key" } + return value + } +} + +data class SharedPubkyIdentity( + val protocolVersion: Int, + val sourcePackage: String, + val pubky: String, +) + +class SharedPubkyCredential( + val identity: SharedPubkyIdentity, + secretKeyHex: String, +) { + val secretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) +} + +@Serializable +data class ExternalPubkyIdentityRef( + val protocolVersion: Int, + val sourcePackage: String, + val pubky: String, +) { + fun validated(): ExternalPubkyIdentityRef { + if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) { + throw SharedPubkyError.UnsupportedVersion(protocolVersion) + } + if (sourcePackage != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(sourcePackage) + } + return copy(pubky = SharedPubkyContract.requireWirePubky(pubky)) + } +} + +sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) { + data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable") + class UntrustedSource(source: String) : SharedPubkyError("Untrusted Pubky identity source '$source'") + class UnsupportedVersion(version: Int) : SharedPubkyError("Unsupported Pubky sharing version '$version'") + data object InvalidResponse : SharedPubkyError("Pubky Ring returned an invalid shared identity") + data object IdentityUnavailable : SharedPubkyError("The selected Pubky Ring identity is unavailable") + data object IdentityConflict : SharedPubkyError("Another Pubky profile is already connected") +} diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt new file mode 100644 index 0000000000..9dfd10bb10 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt @@ -0,0 +1,137 @@ +package to.bitkit.data.sharing + +import android.content.Context +import android.content.pm.PackageManager +import android.database.Cursor +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SharedPubkyDiscovery @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + suspend fun discoverRingIdentities(): Result> = runSuspendCatching { + withContext(ioDispatcher) { + verifyRingProvider() + context.contentResolver.query( + SharedPubkyContract.ringIdentitiesUri, + SharedPubkyContract.publicColumns, + null, + null, + null, + )?.use(::readPublicIdentities) ?: throw SharedPubkyError.SourceUnavailable + } + } + + suspend fun readRingCredential(pubky: String): Result = runSuspendCatching { + withContext(ioDispatcher) { + verifyRingProvider() + val expectedPubky = SharedPubkyContract.canonicalPubky(pubky) + context.contentResolver.query( + SharedPubkyContract.ringCredentialUri(expectedPubky), + SharedPubkyContract.credentialColumns, + null, + null, + null, + )?.use { readCredential(it, expectedPubky) } ?: throw SharedPubkyError.IdentityUnavailable + } + } + + @Suppress("ThrowsCount") + private fun verifyRingProvider() { + val packageManager = context.packageManager + val provider = packageManager.resolveContentProvider( + SharedPubkyContract.RING_AUTHORITY, + PackageManager.MATCH_ALL, + ) ?: throw SharedPubkyError.SourceUnavailable + if (provider.packageName != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(provider.packageName) + } + if ( + provider.authority != SharedPubkyContract.RING_AUTHORITY || + provider.readPermission != SharedPubkyContract.RING_READ_PERMISSION + ) { + throw SharedPubkyError.UntrustedSource(provider.packageName) + } + if ( + packageManager.checkSignatures(context.packageName, provider.packageName) != + PackageManager.SIGNATURE_MATCH + ) { + throw SharedPubkyError.UntrustedSource(provider.packageName) + } + } + + private fun readPublicIdentities(cursor: Cursor): List { + val columns = cursor.requiredPublicColumns() + val identities = buildList { + while (cursor.moveToNext()) { + add(cursor.readIdentity(columns)) + } + } + return identities.distinctBy { it.pubky } + } + + @Suppress("ThrowsCount") + private fun readCredential(cursor: Cursor, expectedPubky: String): SharedPubkyCredential { + val publicColumns = cursor.requiredPublicColumns() + val secretKeyColumn = cursor.getColumnIndex(SharedPubkyContract.COLUMN_SECRET_KEY) + if (secretKeyColumn < 0 || !cursor.moveToFirst()) throw SharedPubkyError.IdentityUnavailable + + val identity = cursor.readIdentity(publicColumns) + if (identity.pubky != expectedPubky || cursor.moveToNext()) { + throw SharedPubkyError.InvalidResponse + } + return runCatching { + SharedPubkyCredential( + identity = identity, + secretKeyHex = cursor.getString(secretKeyColumn).orEmpty(), + ) + }.getOrElse { + throw SharedPubkyError.InvalidResponse + } + } + + private fun Cursor.requiredPublicColumns() = PublicColumnIndexes( + protocolVersion = getColumnIndex(SharedPubkyContract.COLUMN_PROTOCOL_VERSION), + sourcePackage = getColumnIndex(SharedPubkyContract.COLUMN_SOURCE_PACKAGE), + pubky = getColumnIndex(SharedPubkyContract.COLUMN_PUBKY), + ).also { + if (it.protocolVersion < 0 || it.sourcePackage < 0 || it.pubky < 0) { + throw SharedPubkyError.InvalidResponse + } + } + + @Suppress("ThrowsCount") + private fun Cursor.readIdentity(columns: PublicColumnIndexes): SharedPubkyIdentity { + val version = getInt(columns.protocolVersion) + if (version != SharedPubkyContract.PROTOCOL_VERSION) { + throw SharedPubkyError.UnsupportedVersion(version) + } + val sourcePackage = getString(columns.sourcePackage).orEmpty() + if (sourcePackage != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(sourcePackage) + } + val pubky = runCatching { + SharedPubkyContract.requireWirePubky(getString(columns.pubky).orEmpty()) + }.getOrElse { + throw SharedPubkyError.InvalidResponse + } + return SharedPubkyIdentity( + protocolVersion = version, + sourcePackage = sourcePackage, + pubky = pubky, + ) + } +} + +private data class PublicColumnIndexes( + val protocolVersion: Int, + val sourcePackage: Int, + val pubky: Int, +) diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt new file mode 100644 index 0000000000..c7290a92e0 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt @@ -0,0 +1,192 @@ +package to.bitkit.data.sharing + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.PackageManager +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Binder +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.Logger + +class SharedPubkyProvider : ContentProvider() { + private companion object { + const val TAG = "SharedPubkyProvider" + const val EXPORT_ENABLED = "1" + const val QUARANTINED = "1" + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Dependencies { + fun keychain(): Keychain + } + + private val keychain: Keychain by lazy { + val applicationContext = requireNotNull(context?.applicationContext) { + "SharedPubkyProvider context is unavailable" + } + EntryPointAccessors.fromApplication(applicationContext, Dependencies::class.java).keychain() + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + enforceCaller() + require(selection == null && selectionArgs == null && sortOrder == null) { + "Selection and sorting are unsupported" + } + + val route = ProviderRoute.parse(uri, requireNotNull(context).packageName) + val expectedColumns = when (route) { + ProviderRoute.Identities -> SharedPubkyContract.publicColumns + is ProviderRoute.Credential -> SharedPubkyContract.credentialColumns + } + require(projection == null || projection.contentEquals(expectedColumns)) { + "Unsupported shared Pubky projection" + } + + val cursor = MatrixCursor(expectedColumns) + val localIdentity = readLocalIdentity() ?: return cursor + when (route) { + ProviderRoute.Identities -> cursor.addRow(localIdentity.publicRow()) + is ProviderRoute.Credential -> { + if (route.pubky == localIdentity.pubky) { + cursor.addRow(localIdentity.credentialRow()) + } + } + } + return cursor + } + + override fun getType(uri: Uri): String { + val packageName = requireNotNull(context).packageName + return when (ProviderRoute.parse(uri, packageName)) { + ProviderRoute.Identities -> "vnd.android.cursor.dir/vnd.$packageName.sharedpubky.identity" + is ProviderRoute.Credential -> "vnd.android.cursor.item/vnd.$packageName.sharedpubky.credential" + } + } + + override fun insert(uri: Uri, values: ContentValues?): Uri = + throw UnsupportedOperationException("Shared Pubky provider is read-only") + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException("Shared Pubky provider is read-only") + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = throw UnsupportedOperationException("Shared Pubky provider is read-only") + + private fun enforceCaller() { + val providerContext = requireNotNull(context) + val caller = callingPackage + val isCallerPackage = caller == SharedPubkyContract.RING_SOURCE && + caller in providerContext.packageManager.getPackagesForUid(Binder.getCallingUid()).orEmpty() + val isCallerSignedByBitkit = caller != null && + providerContext.packageManager.checkSignatures(providerContext.packageName, caller) == + PackageManager.SIGNATURE_MATCH + if (!isCallerPackage || !isCallerSignedByBitkit) { + throw SecurityException("Caller is not trusted for shared Pubky access") + } + } + + private fun readLocalIdentity(): LocalIdentity? { + val isExportEnabled = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) + }.onFailure { + Logger.warn("Failed to read shared Pubky export state", it, context = TAG) + }.getOrNull() == EXPORT_ENABLED + if (!isExportEnabled) return null + + val isManagedSecretQuarantined = runCatching { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + }.onFailure { + Logger.warn("Failed to read managed Pubky secret quarantine", it, context = TAG) + }.getOrNull() == QUARANTINED + + val secretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.onFailure { + Logger.warn("Failed to read local shared Pubky identity", it, context = TAG) + }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null + + return runCatching { + localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = isManagedSecretQuarantined, + secretKeyHex = secretKeyHex, + publicKeyFromSecret = PaykitSdkService::publicKeyFromSecret, + ) + }.onFailure { + Logger.warn("Failed to validate local shared Pubky identity", it, context = TAG) + }.getOrNull() + } + + private sealed interface ProviderRoute { + data object Identities : ProviderRoute + data class Credential(val pubky: String) : ProviderRoute + + companion object { + fun parse(uri: Uri, packageName: String): ProviderRoute { + require(uri.scheme == "content" && uri.authority == "$packageName.sharedpubky") { + "Unsupported shared Pubky URI" + } + val segments = uri.pathSegments + if (segments == listOf("v1", "identities")) return Identities + if ( + segments.size == 4 && + segments.take(2) == listOf("v1", "identities") && + segments.last() == "credential" + ) { + return Credential(SharedPubkyContract.requireWirePubky(segments[2])) + } + throw IllegalArgumentException("Unsupported shared Pubky URI") + } + } + } +} + +internal fun localSharedPubkyIdentity( + exportEnabled: Boolean, + managedSecretQuarantined: Boolean, + secretKeyHex: String?, + publicKeyFromSecret: (String) -> String, +): LocalIdentity? { + if (!exportEnabled || managedSecretQuarantined || secretKeyHex.isNullOrBlank()) return null + val canonicalSecretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) + val pubky = SharedPubkyContract.canonicalPubky(publicKeyFromSecret(canonicalSecretKeyHex)) + return LocalIdentity(pubky = pubky, secretKeyHex = canonicalSecretKeyHex) +} + +internal class LocalIdentity( + val pubky: String, + private val secretKeyHex: String, +) { + fun publicRow(): Array = arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + pubky, + ) + + fun credentialRow(): Array = arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + pubky, + secretKeyHex, + ) +} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 5654b5aa7d..2ded76ae9b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -38,6 +38,12 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain import to.bitkit.data.paykitDisabled +import to.bitkit.data.sharing.ExternalPubkyIdentityRef +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyCredential +import to.bitkit.data.sharing.SharedPubkyDiscovery +import to.bitkit.data.sharing.SharedPubkyError +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching @@ -90,6 +96,7 @@ class PubkyRepo @Inject constructor( private val pubkyStore: PubkyStore, private val settingsStore: SettingsStore, private val httpClient: HttpClient, + private val sharedPubkyDiscovery: SharedPubkyDiscovery, ) { companion object { private const val TAG = "PubkyRepo" @@ -97,11 +104,13 @@ class PubkyRepo @Inject constructor( private const val PUBKY_SCHEME = "pubky://" private const val AVATAR_MAX_SIZE = 400 private const val AVATAR_QUALITY = 80 + private const val MANAGED_SECRET_QUARANTINED = "1" + private const val SHARED_EXPORT_ENABLED = "1" } private val scope = appScope(ioDispatcher, TAG) private val serviceInitializeMutex = Mutex() - private val initializeMutex = Mutex() + private val identityLifecycleMutex = Mutex() private val loadProfileMutex = Mutex() private val loadContactsMutex = Mutex() private var isServiceInitialized = false @@ -157,6 +166,7 @@ class PubkyRepo @Inject constructor( data object NoSession : InitResult data class Restored(val publicKey: String) : InitResult data object RestorationFailed : InitResult + data object ExternalSourceUnavailable : InitResult } private val initializationReady = CompletableDeferred() @@ -184,39 +194,15 @@ class PubkyRepo @Inject constructor( Logger.error("Failed to initialize paykit", it, context = TAG) }.getOrNull() ?: return@withContext - initializeMutex.withLock { + identityLifecycleMutex.withLock { _sessionRestorationFailed.update { false } val result = runSuspendCatching { - val savedSessionSecret = runCatching { - keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - }.getOrNull() - val storedSecretKeyHex = runCatching { - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - }.getOrNull() - - resolveSessionInitialization( - savedSessionSecret = savedSessionSecret, - storedSecretKeyHex = storedSecretKeyHex, - ) + resolveStoredSessionInitialization() }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) }.getOrNull() ?: return@withLock - when (result) { - is InitResult.NoSession -> { - clearAuthenticatedState() - Logger.debug("Found no saved paykit session", context = TAG) - } - is InitResult.Restored -> { - _publicKey.update { result.publicKey } - _authState.update { PubkyAuthState.Authenticated } - Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) - } - is InitResult.RestorationFailed -> { - clearAuthenticatedState() - _sessionRestorationFailed.update { true } - } - } + applySessionInitialization(result) initializationReady.complete(Unit) if (result is InitResult.Restored) { @@ -226,6 +212,70 @@ class PubkyRepo @Inject constructor( } } + private suspend fun resolveStoredSessionInitialization(): InitResult { + val savedSessionSecret = runCatching { + keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + }.getOrNull() + val isManagedSecretQuarantined = runCatching { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + }.getOrNull() == MANAGED_SECRET_QUARANTINED + val storedSecretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.getOrNull().takeUnless { isManagedSecretQuarantined } + val externalIdentityRef = pubkyStore.data.first().externalIdentityRef?.let { identityRef -> + runCatching { identityRef.validated() }.getOrElse { + return InitResult.ExternalSourceUnavailable + } + } + if (isManagedSecretQuarantined && externalIdentityRef != null) { + return InitResult.ExternalSourceUnavailable + } + + return resolveSessionInitialization( + savedSessionSecret = savedSessionSecret.takeUnless { + isManagedSecretQuarantined && externalIdentityRef == null + }, + storedSecretKeyHex = storedSecretKeyHex, + externalIdentityRef = externalIdentityRef, + ) + } + + private suspend fun applySessionInitialization(result: InitResult) { + when (result) { + is InitResult.NoSession -> { + disableLocalIdentityExport() + clearAuthenticatedState() + Logger.debug("Found no saved paykit session", context = TAG) + } + is InitResult.Restored -> restoreInitializedSession(result.publicKey) + is InitResult.RestorationFailed -> { + disableLocalIdentityExport() + if (pubkyStore.data.first().externalIdentityRef == null) { + clearAuthenticatedState() + } else { + clearAuthenticatedRuntimeState() + } + _sessionRestorationFailed.update { true } + } + is InitResult.ExternalSourceUnavailable -> { + clearUnavailableExternalIdentityLocked() + Logger.warn("Disconnected unavailable Pubky Ring identity", context = TAG) + } + } + } + + private suspend fun restoreInitializedSession(publicKey: String) { + val hasLocalSecret = !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank() + if (pubkyStore.data.first().externalIdentityRef == null && hasLocalSecret) { + enableLocalIdentityExport(publicKey) + } else { + disableLocalIdentityExport() + } + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Restored paykit session for '${redacted(publicKey)}'", context = TAG) + } + private suspend fun ensureServiceInitialized() = withContext(ioDispatcher) { serviceInitializeMutex.withLock { if (!isServiceInitialized) { @@ -238,10 +288,22 @@ class PubkyRepo @Inject constructor( private suspend fun resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, + externalIdentityRef: ExternalPubkyIdentityRef?, ): InitResult = withContext(ioDispatcher) { + if (externalIdentityRef != null) { + return@withContext resolveExternalSession( + savedSessionSecret = savedSessionSecret, + identityRef = externalIdentityRef, + ) + } + if (!savedSessionSecret.isNullOrEmpty()) { runSuspendCatching { - val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() + val publicKey = if (storedSecretKeyHex.isNullOrBlank()) { + pubkyService.importExternalSession(savedSessionSecret) + } else { + pubkyService.importSession(savedSessionSecret) + }.ensurePubkyPrefix() InitResult.Restored(publicKey) }.getOrElse { Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) @@ -252,6 +314,42 @@ class PubkyRepo @Inject constructor( } } + private suspend fun resolveExternalSession( + savedSessionSecret: String?, + identityRef: ExternalPubkyIdentityRef, + ): InitResult = withContext(ioDispatcher) { + val sourceIdentity = sharedPubkyDiscovery.discoverRingIdentities().getOrElse { + return@withContext InitResult.ExternalSourceUnavailable + }.firstOrNull { it.matches(identityRef) } + ?: return@withContext InitResult.ExternalSourceUnavailable + + if (!savedSessionSecret.isNullOrBlank()) { + runSuspendCatching { + val restored = canonicalBitkitPubky(pubkyService.importExternalSession(savedSessionSecret)) + if (wirePubky(restored) != identityRef.pubky) throw SharedPubkyError.InvalidResponse + InitResult.Restored(restored) + }.onSuccess { + return@withContext it + }.onFailure { + Logger.warn("Failed to restore external paykit session, attempting re-sign-in", it, context = TAG) + } + } + + val credential = sharedPubkyDiscovery.readRingCredential(sourceIdentity.pubky).getOrElse { + return@withContext InitResult.ExternalSourceUnavailable + } + if (!credential.matches(identityRef)) return@withContext InitResult.ExternalSourceUnavailable + + runSuspendCatching { + val publicKey = signInWithExternalCredential(credential) + Logger.info("Re-signed in with Pubky Ring identity '${redacted(publicKey)}'", context = TAG) + InitResult.Restored(publicKey) + }.getOrElse { + Logger.error("Failed external re-sign-in recovery", it, context = TAG) + InitResult.RestorationFailed + } + } + private suspend fun resolveSignedInSession( savedSessionSecret: String?, storedSecretKeyHex: String?, @@ -305,10 +403,11 @@ class PubkyRepo @Inject constructor( } } - suspend fun completeAuthentication(): Result { - val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) + suspend fun completeAuthentication(): Result = identityLifecycleMutex.withLock { + val attemptId = _activeAuthAttemptId.value + ?: return@withLock Result.failure(PubkyAuthAttemptInactive()) var shouldRevokeSessionOnFailure = false - return try { + try { val result = runSuspendCatching { waitForAuthApproval(attemptId) withContext(ioDispatcher) { @@ -323,6 +422,7 @@ class PubkyRepo @Inject constructor( ensureAuthAttemptActive(attemptId) settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + disableLocalIdentityExport() notifyBackupStateChanged() pk @@ -570,9 +670,15 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, - ): Result { + ): Result = identityLifecycleMutex.withLock { + runSuspendCatching { + if (pubkyStore.data.first().externalIdentityRef != null) { + throw SharedPubkyError.IdentityConflict + } + }.exceptionOrNull()?.let { return@withLock Result.failure(it) } + if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) { - return runSuspendCatching { + return@withLock runSuspendCatching { withContext(ioDispatcher) { val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) @@ -582,7 +688,7 @@ class PubkyRepo @Inject constructor( } var shouldRevokeSessionOnFailure = false - return try { + try { val result = runSuspendCatching { withContext(ioDispatcher) { settingsStore.setPubkyProfileSetupPending(false) @@ -625,7 +731,7 @@ class PubkyRepo @Inject constructor( tags: List, avatarBytes: ByteArray?, ): String? { - val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } + val imageUrl = avatarBytes?.let { runSuspendCatching { uploadAvatarInternal(it) }.getOrNull() } writeProfile(name, bio, links, tags, imageUrl) return imageUrl } @@ -647,6 +753,7 @@ class PubkyRepo @Inject constructor( tags = tags, status = null, ) + enableLocalIdentityExport(publicKey) _publicKey.update { publicKey } _authState.update { PubkyAuthState.Authenticated } _profile.update { createdProfile } @@ -665,14 +772,19 @@ class PubkyRepo @Inject constructor( suspend fun uploadAvatar(imageBytes: ByteArray): Result = runSuspendCatching { withContext(ioDispatcher) { - requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { - "No session available" - } - val compressed = compressAvatar(imageBytes) - pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg") + requireExternalIdentitySource() + uploadAvatarInternal(imageBytes) } } + private suspend fun uploadAvatarInternal(imageBytes: ByteArray): String { + requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { + "No session available" + } + val compressed = compressAvatar(imageBytes) + return pubkyService.uploadProfileAvatar(compressed, contentType = "image/jpeg") + } + suspend fun saveProfile( name: String, bio: String, @@ -681,6 +793,7 @@ class PubkyRepo @Inject constructor( imageUrl: String?, ): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } @@ -713,6 +826,8 @@ class PubkyRepo @Inject constructor( suspend fun deleteProfile(): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() + disableLocalIdentityExport() requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } @@ -858,6 +973,7 @@ class PubkyRepo @Inject constructor( existingProfile: PubkyProfile? = null, ): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = requireAddableContactPublicKey( publicKey = publicKey, allowExisting = existingProfile != null, @@ -877,6 +993,7 @@ class PubkyRepo @Inject constructor( suspend fun refreshContactReceiverPaths(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = requireAddableContactPublicKey(publicKey = publicKey, allowExisting = true) val contact = _contacts.value.firstOrNull { PubkyPublicKeyFormat.matches(it.publicKey, prefixedKey) } ?: return@withContext @@ -895,6 +1012,7 @@ class PubkyRepo @Inject constructor( tags: List, ): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = publicKey.ensurePubkyPrefix() val updatedProfile = PubkyProfile( publicKey = prefixedKey, @@ -918,6 +1036,7 @@ class PubkyRepo @Inject constructor( suspend fun removeContact(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = publicKey.ensurePubkyPrefix() pubkyService.removeContact(prefixedKey) removeContactProfileOverride(prefixedKey) @@ -929,6 +1048,7 @@ class PubkyRepo @Inject constructor( suspend fun importContacts(publicKeys: List): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val imported = coroutineScope { publicKeys.map { contactPk -> val prefixedKey = contactPk.ensurePubkyPrefix() @@ -986,11 +1106,94 @@ class PubkyRepo @Inject constructor( // endregion + // region Shared Pubky identities + + suspend fun discoverRingIdentities(): Result> = + sharedPubkyDiscovery.discoverRingIdentities() + + suspend fun adoptRingIdentity(identity: SharedPubkyIdentity): Result = + identityLifecycleMutex.withLock { + var didPersistExternalIdentity = false + runSuspendCatching { + withContext(ioDispatcher) { + ensureServiceInitialized() + val canonicalIdentity = identity.validatedRingIdentity() + val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated() + val currentPublicKey = _publicKey.value + val isAlreadyActive = currentIdentityRef?.pubky == canonicalIdentity.pubky && + currentPublicKey?.let(::wirePubky) == canonicalIdentity.pubky + if (isAlreadyActive) { + return@withContext + } + if ( + currentPublicKey != null || + !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank() + ) { + throw SharedPubkyError.IdentityConflict + } + + val credential = sharedPubkyDiscovery.readRingCredential(canonicalIdentity.pubky).getOrThrow() + if (!credential.identity.matches(canonicalIdentity)) throw SharedPubkyError.InvalidResponse + + disableLocalIdentityExport() + val identityRef = canonicalIdentity.toExternalRef() + pubkyStore.update { it.copy(externalIdentityRef = identityRef) } + didPersistExternalIdentity = true + _authState.update { PubkyAuthState.Authenticating } + + val publicKey = signInWithExternalCredential(credential) + + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG) + loadProfile() + loadContacts() + } + }.onFailure { + if (didPersistExternalIdentity) { + withContext(NonCancellable) { + runSuspendCatching { clearUnavailableExternalIdentityLocked() } + .onFailure { + Logger.error("Failed to roll back Pubky Ring identity connection", it, context = TAG) + } + } + } + restoreAuthStateAfterAuthFlow() + } + } + + suspend fun validateExternalIdentitySource(): Boolean = identityLifecycleMutex.withLock { + validateExternalIdentitySourceLocked() + } + + private suspend fun validateExternalIdentitySourceLocked(): Boolean = withContext(ioDispatcher) { + val identityRef = runSuspendCatching { + pubkyStore.data.first().externalIdentityRef?.validated() + }.getOrElse { + clearUnavailableExternalIdentityLocked() + return@withContext false + } ?: return@withContext true + + val available = sharedPubkyDiscovery.discoverRingIdentities() + .getOrNull() + ?.any { it.matches(identityRef) } + ?: false + if (available) return@withContext true + + clearUnavailableExternalIdentityLocked() + Logger.warn("Disconnected missing Pubky Ring identity '${redacted(identityRef.pubky)}'", context = TAG) + false + } + + // endregion + // region Auth approval suspend fun hasSecretKey(): Boolean = runSuspendCatching { val publicKey = _publicKey.value ?: return@runSuspendCatching false - managedSecretKeyFor(publicKey) != null + activeIdentitySecretKey(publicKey) != null }.getOrDefault(false) suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) { @@ -1020,7 +1223,7 @@ class PubkyRepo @Inject constructor( } } - suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = initializeMutex.withLock { + suspend fun approveSignupAuth(request: PubkyAuthRequest): Result = identityLifecycleMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { require(request.isSignup) { "Not a Pubky signup request" } @@ -1076,8 +1279,9 @@ class PubkyRepo @Inject constructor( 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" + val publicKey = requireNotNull(_publicKey.value) { "No active Pubky identity" } + val secretKeyHex = requireNotNull(activeIdentitySecretKey(publicKey)) { + "No active Pubky secret key is available" } pubkyService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex) } @@ -1089,8 +1293,9 @@ class PubkyRepo @Inject constructor( unsignedPayload: ByteArray, ): Result = runSuspendCatching { withContext(ioDispatcher) { - val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { - "No secret key available — use Ring to manage authorizations" + val publicKey = requireNotNull(_publicKey.value) { "No active Pubky identity" } + val secretKeyHex = requireNotNull(activeIdentitySecretKey(publicKey)) { + "No active Pubky secret key is available" } pubkyService.approveAuthWithCompanionClaim( authUrl = authUrl, @@ -1112,6 +1317,14 @@ class PubkyRepo @Inject constructor( suspend fun snapshotSessionBackupState(): Result = runSuspendCatching { withContext(ioDispatcher) { + if (pubkyStore.data.first().externalIdentityRef != null) return@withContext null + if ( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + return@withContext null + } + val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) if (!secretKeyHex.isNullOrEmpty()) { return@withContext PubkySessionBackupV1(kind = PubkySessionBackupKind.LocalSeed) @@ -1139,7 +1352,8 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { ensureServiceInitialized() - initializeMutex.withLock { + identityLifecycleMutex.withLock { + disableLocalIdentityExport() runSuspendCatching { pubkyService.forgetSessionAccess() } .onFailure { Logger.warn( @@ -1149,8 +1363,8 @@ class PubkyRepo @Inject constructor( ) } clearAuthenticatedState() - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } when (backup?.kind) { null -> Unit @@ -1160,6 +1374,7 @@ class PubkyRepo @Inject constructor( keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) pubkyService.signIn(secretKeyHex) val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() + enableLocalIdentityExport(publicKey) _publicKey.update { publicKey } _authState.update { PubkyAuthState.Authenticated } } @@ -1169,6 +1384,7 @@ class PubkyRepo @Inject constructor( "Missing session secret in backup" } val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix() + disableLocalIdentityExport() _publicKey.update { publicKey } _authState.update { PubkyAuthState.Authenticated } } @@ -1190,19 +1406,46 @@ class PubkyRepo @Inject constructor( } } - suspend fun refreshSessionIfPossible(): Result = runSuspendCatching { - withContext(ioDispatcher) { - val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?: return@withContext false + suspend fun refreshSessionIfPossible(): Result = identityLifecycleMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + val identityRef = pubkyStore.data.first().externalIdentityRef?.validated() + if (identityRef != null) { + if (!validateExternalIdentitySourceLocked()) return@withContext false + val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse { + clearUnavailableExternalIdentityLocked() + return@withContext false + } + val publicKey = signInWithExternalCredential(credential) + if (wirePubky(publicKey) != identityRef.pubky) { + clearUnavailableExternalIdentityLocked() + return@withContext false + } + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + return@withContext true + } - pubkyService.signIn(storedSecretKeyHex) - val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() + val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + ?: return@withContext false + if ( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + return@withContext false + } - notifyBackupStateChanged() - _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } + pubkyService.signIn(storedSecretKeyHex) + val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() + enableLocalIdentityExport(publicKey) - true + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + + true + } } } @@ -1210,31 +1453,38 @@ class PubkyRepo @Inject constructor( // region Sign out - 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 { - pubkyService.signOut() - }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } - - 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) + suspend fun signOut(): Result = identityLifecycleMutex.withLock { + withContext(NonCancellable + ioDispatcher) { + runSuspendCatching { disableLocalIdentityExport() } + .onFailure { Logger.error("Failed to disable shared Pubky export", it, context = TAG) } + .exceptionOrNull() + ?.let { return@withContext Result.failure(it) } + + val hadPaykitState = settingsStore.data.first().hasPaykitState() + val endpointCleanupResult = removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + + val result = runSuspendCatching { + pubkyService.signOut() + }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) } + + 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@withContext result } - return@withContext result - } - clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) - result + clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) + result + } } - suspend fun wipeLocalState() { + suspend fun wipeLocalState() = identityLifecycleMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { pubkyService.forgetSessionAccess() } }.onFailure { @@ -1362,7 +1612,112 @@ class PubkyRepo @Inject constructor( } } + private suspend fun signInWithExternalCredential(credential: SharedPubkyCredential): String = + withContext(ioDispatcher) { + val identity = credential.identity.validatedRingIdentity() + val derivedWirePubky = wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex)) + if (derivedWirePubky != identity.pubky) throw SharedPubkyError.InvalidResponse + + val signedInPubky = canonicalBitkitPubky(pubkyService.signInExternal(credential.secretKeyHex)) + if (wirePubky(signedInPubky) != identity.pubky) throw SharedPubkyError.InvalidResponse + if (!keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()) { + throw SharedPubkyError.InvalidResponse + } + signedInPubky + } + + private suspend fun enableLocalIdentityExport(publicKey: String) = withContext(ioDispatcher) { + val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { + "Local Pubky secret is unavailable" + } + val derivedPublicKey = canonicalBitkitPubky(pubkyService.publicKeyFromSecret(secretKeyHex)) + if (!PubkyPublicKeyFormat.matches(derivedPublicKey, publicKey)) { + throw SharedPubkyError.InvalidResponse + } + keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + check(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == null) { + "Failed to release managed local Pubky secret quarantine" + } + keychain.upsertString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name, SHARED_EXPORT_ENABLED) + check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == SHARED_EXPORT_ENABLED) { + "Failed to verify shared Pubky export state" + } + } + + private suspend fun disableLocalIdentityExport() = withContext(ioDispatcher) { + keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) + check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) { + "Failed to disable shared Pubky export" + } + } + + private suspend fun activeIdentitySecretKey(publicKey: String): String? = identityLifecycleMutex.withLock { + activeIdentitySecretKeyLocked(publicKey) + } + + private suspend fun activeIdentitySecretKeyLocked(publicKey: String): String? = withContext(ioDispatcher) { + val identityRef = pubkyStore.data.first().externalIdentityRef?.validated() + ?: return@withContext managedSecretKeyFor(publicKey) + if (wirePubky(publicKey) != identityRef.pubky || !validateExternalIdentitySourceLocked()) { + return@withContext null + } + + val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse { + clearUnavailableExternalIdentityLocked() + return@withContext null + } + val isValid = runSuspendCatching { + credential.matches(identityRef) && + wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex)) == identityRef.pubky + }.getOrDefault(false) + if (!isValid) { + clearUnavailableExternalIdentityLocked() + return@withContext null + } + credential.secretKeyHex + } + + private suspend fun requireExternalIdentitySource() { + if (!validateExternalIdentitySource()) throw SharedPubkyError.SourceUnavailable + } + + private suspend fun clearUnavailableExternalIdentityLocked() = withContext(ioDispatcher) { + val externalIdentityRef = pubkyStore.data.first().externalIdentityRef ?: return@withContext + disableLocalIdentityExport() + + val managedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + if (!managedSecretKeyHex.isNullOrBlank()) { + keychain.upsertString( + Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, + MANAGED_SECRET_QUARANTINED, + ) + check( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + "Failed to quarantine conflicting managed local Pubky secret" + } + Logger.error( + "Quarantined managed local secret while clearing external identity " + + "'${redacted(externalIdentityRef.pubky)}'", + context = TAG, + ) + } + + pubkyService.clearExternalSessionAccess() + clearPublicPaykitSharingState(publicPaykitCleanupPending = false) + clearAuthenticatedRuntimeState() + pubkyStore.reset() + notifyBackupStateChanged() + } + private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) { + if ( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + return@withContext null + } val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: return@withContext null @@ -1379,7 +1734,10 @@ class PubkyRepo @Inject constructor( if (derivedPublicKey != null) { Logger.warn("Ignoring stale managed secret key for '${redacted(publicKey)}'", context = TAG) } - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + runSuspendCatching { + disableLocalIdentityExport() + keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } .onSuccess { notifyBackupStateChanged() } null } @@ -1396,8 +1754,12 @@ class PubkyRepo @Inject constructor( } private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { - evictPubkyImages() runSuspendCatching { pubkyStore.reset() } + clearAuthenticatedRuntimeState() + } + + private suspend fun clearAuthenticatedRuntimeState() = withContext(ioDispatcher) { + evictPubkyImages() _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } @@ -1412,8 +1774,10 @@ class PubkyRepo @Inject constructor( } private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) { - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + disableLocalIdentityExport() + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } runSuspendCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) } .onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) } notifyBackupStateChanged() @@ -1453,6 +1817,43 @@ class PubkyRepo @Inject constructor( private fun String.ensurePubkyPrefix(): String = if (startsWith(PUBKY_PREFIX)) this else "$PUBKY_PREFIX$this" + private fun canonicalBitkitPubky(value: String): String = + SharedPubkyContract.toBitkitPubky(value) + + private fun wirePubky(value: String): String = + SharedPubkyContract.canonicalPubky(value) + + private fun SharedPubkyIdentity.validatedRingIdentity(): SharedPubkyIdentity { + if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) { + throw SharedPubkyError.UnsupportedVersion(protocolVersion) + } + if (sourcePackage != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(sourcePackage) + } + return copy(pubky = SharedPubkyContract.requireWirePubky(pubky)) + } + + private fun SharedPubkyIdentity.toExternalRef() = ExternalPubkyIdentityRef( + protocolVersion = protocolVersion, + sourcePackage = sourcePackage, + pubky = SharedPubkyContract.requireWirePubky(pubky), + ) + + private fun SharedPubkyIdentity.matches(identityRef: ExternalPubkyIdentityRef): Boolean = + protocolVersion == identityRef.protocolVersion && + sourcePackage == identityRef.sourcePackage && + SharedPubkyContract.requireWirePubky(pubky) == + SharedPubkyContract.requireWirePubky(identityRef.pubky) + + private fun SharedPubkyIdentity.matches(other: SharedPubkyIdentity): Boolean = + protocolVersion == other.protocolVersion && + sourcePackage == other.sourcePackage && + SharedPubkyContract.requireWirePubky(pubky) == + SharedPubkyContract.requireWirePubky(other.pubky) + + private fun SharedPubkyCredential.matches(identityRef: ExternalPubkyIdentityRef): Boolean = + identity.matches(identityRef) + private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) private fun Throwable.isMissingPubkyData(): Boolean { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 15a47473a1..0a2a9f53ad 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -388,7 +388,16 @@ class PaykitSdkService @Inject constructor( notifyBackupStateChanged() } - suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult { + suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult = + signIn(secretKeyHex = secretKeyHex, shouldStoreLocalSecret = true) + + suspend fun signInExternal(secretKeyHex: String): String = + signIn(secretKeyHex = secretKeyHex, shouldStoreLocalSecret = false).publicKey + + private suspend fun signIn( + secretKeyHex: String, + shouldStoreLocalSecret: Boolean, + ): PubkySessionBootstrapResult { isSetup.await() val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() } val result = bootstrap().signIn( @@ -400,7 +409,7 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = true, + shouldStoreLocalSecret = shouldStoreLocalSecret, ) } notifyBackupStateChanged() @@ -966,6 +975,28 @@ class PaykitSdkService @Inject constructor( } } + suspend fun clearExternalSessionAccess() { + operationMutex.withLock { + val managedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + disableSharedPubkyExport() + sessionProvider.clearLiveSessionAccess() + keychain.delete(Keychain.Key.PAYKIT_SESSION.name) + keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) + check(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) == null) { + "Failed to clear external Pubky session" + } + check(keychain.load(Keychain.Key.PAYKIT_SDK_STATE.name) == null) { + "Failed to clear external Pubky SDK state" + } + check(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) == managedSecretKeyHex) { + "Managed local Pubky secret changed during external session cleanup" + } + activeAuthRequest = null + resetRuntime() + notifyBackupStateChanged() + } + } + suspend fun clearState() { operationMutex.withLock { clearStateLocked() @@ -992,13 +1023,30 @@ class PaykitSdkService @Inject constructor( access: PubkySessionAccess, shouldStoreLocalSecret: Boolean, ) { + val localSecretKeyHex = managedSecretForSessionPersistence( + shouldStoreLocalSecret = shouldStoreLocalSecret, + exportedLocalSecretKeyHex = access.exportLocalSecretKey()?.let(::secretKeyHex), + existingManagedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name), + ) + disableSharedPubkyExport() keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, access.exportSessionSecret()) sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey()) - val localSecret = access.exportLocalSecretKey() - if (shouldStoreLocalSecret && localSecret != null) { - keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex(localSecret)) - } else { - keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + if (localSecretKeyHex != null) { + keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, localSecretKeyHex) + check(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) == localSecretKeyHex) { + "Failed to persist managed local Pubky secret" + } + keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + check(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == null) { + "Failed to release managed local Pubky secret quarantine" + } + } + } + + private suspend fun disableSharedPubkyExport() { + keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) + check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) { + "Failed to disable shared Pubky export" } } @@ -1008,7 +1056,12 @@ class PaykitSdkService @Inject constructor( shouldStoreLocalSecret: Boolean, ) { persistSessionAccess(result.sessionAccess, shouldStoreLocalSecret) - sessionProvider.setLiveSessionAccess(result.sessionAccess) + sessionProvider.setLiveSessionAccess( + liveSessionAccess( + access = result.sessionAccess, + retainLocalSecret = shouldStoreLocalSecret, + ), + ) if (!PubkyPublicKeyFormat.matches(previousPublicKey, result.publicKey)) { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) } @@ -1220,6 +1273,11 @@ private class PaykitSdkStateBlobStore( internal class PaykitSdkSessionProvider( private val keychain: Keychain, ) : SdkPubkySessionProvider { + private companion object { + const val STALE_SESSION_RESTORE_CONTEXT = "restore Pubky grant session from platform provider" + const val QUARANTINED = "1" + } + private val lock = Any() private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) private var liveSessionAccess: PubkySessionAccess? = null @@ -1274,14 +1332,16 @@ internal class PaykitSdkSessionProvider( clearLiveSessionAccess() keychain.accessBlocking { clearPubkySessionCredentials(::delete) + check(load(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) { + "Failed to disable shared Pubky export" + } } } - private companion object { - const val STALE_SESSION_RESTORE_CONTEXT = "restore Pubky grant session from platform provider" - } - fun loadLocalSecretKey(): PubkyLocalSecretKey? { + if (keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == QUARANTINED) { + return null + } val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?.takeIf { it.isNotBlank() } ?: return null @@ -1296,6 +1356,38 @@ internal class PaykitSdkSessionProvider( } } +internal fun managedSecretForSessionPersistence( + shouldStoreLocalSecret: Boolean, + exportedLocalSecretKeyHex: String?, + existingManagedSecretKeyHex: String?, +): String? { + if (shouldStoreLocalSecret) { + val exportedSecret = requireNotNull(exportedLocalSecretKeyHex) { + "Owned Pubky session did not export its local secret" + } + check(existingManagedSecretKeyHex.isNullOrBlank() || existingManagedSecretKeyHex == exportedSecret) { + "Refusing to replace a different managed local Pubky secret" + } + return exportedSecret + } + check(existingManagedSecretKeyHex.isNullOrBlank()) { + "Refusing to activate an external Pubky session over a managed local secret" + } + return null +} + +private fun liveSessionAccess( + access: PubkySessionAccess, + retainLocalSecret: Boolean, +): PubkySessionAccess { + if (retainLocalSecret) return access + return PubkySessionAccess( + sessionSecret = access.exportSessionSecret(), + localSecretKey = null, + receiverNoiseSecretKey = access.exportReceiverNoiseSecretKey(), + ) +} + internal object PaykitReceiverNoiseKeyDerivation { private const val DOMAIN = "bitkit/paykit/receiver-noise-key" private const val VERSION = "v1" @@ -1333,10 +1425,14 @@ internal object PaykitReceiverNoiseKeyDerivation { } internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) { + val exportResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) } val sessionResult = runCatching { deleteKeychainValue(Keychain.Key.PAYKIT_SESSION.name) } val localSecretResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_SECRET_KEY.name) } + val quarantineResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + exportResult.getOrThrow() sessionResult.getOrThrow() localSecretResult.getOrThrow() + quarantineResult.getOrThrow() } internal class PaykitReceiverNoiseKeyStore( diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index b0d468aa50..8f33f58247 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -56,6 +56,10 @@ class PubkyService @Inject constructor( paykitSdkService.forgetSessionAccess() } + suspend fun clearExternalSessionAccess() = ServiceQueue.CORE.background { + paykitSdkService.clearExternalSessionAccess() + } + suspend fun removeBitkitPaymentEndpoints() = ServiceQueue.CORE.background { val endpointError = runSuspendCatching { val report = paykitSdkService.syncPublicEndpoints(emptyList()) @@ -109,6 +113,10 @@ class PubkyService @Inject constructor( Unit } + suspend fun signInExternal(secretKeyHex: String): String = ServiceQueue.CORE.background { + paykitSdkService.signInExternal(secretKeyHex) + } + // endregion // region Auth flow (Ring) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b2f5710c4f..da41f46e4c 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1539,9 +1539,6 @@ private fun NavGraphBuilder.profile( onNavigateToPayContacts = { navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } }, - onNavigateToProfile = { - navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) } - }, onBackClick = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index cb122b7244..f052595aef 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -230,6 +230,11 @@ class MainActivity : FragmentActivity() { handleLaunchIntent(intent) } + override fun onResume() { + super.onResume() + appViewModel.onAppResumed() + } + private fun handleLaunchIntent(intent: Intent) { if (intent.getBooleanExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE, false)) { intent.removeExtra(EXTRA_PAYKIT_SUBSCRIPTION_PAYMENT_DUE) diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index e8a089ecf1..f98596e5d7 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -182,6 +182,7 @@ fun BodyM( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, + letterSpacing: TextUnit = 0.4.sp, ) { BodyM( text = AnnotatedString(text), @@ -191,6 +192,7 @@ fun BodyM( maxLines = maxLines, minLines = minLines, overflow = overflow, + letterSpacing = letterSpacing, ) } @@ -203,12 +205,14 @@ fun BodyM( maxLines: Int = Int.MAX_VALUE, minLines: Int = 1, overflow: TextOverflow = if (maxLines == 1) TextOverflow.Ellipsis else TextOverflow.Clip, + letterSpacing: TextUnit = 0.4.sp, ) { Text( text = text, style = AppTextStyles.BodyM.merge( color = color, textAlign = textAlign, + letterSpacing = letterSpacing, ), maxLines = maxLines, minLines = minLines, diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index 854541911d..c1d9224116 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -1,7 +1,5 @@ package to.bitkit.ui.screens.profile -import android.content.Intent -import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -14,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,23 +26,25 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.HorizontalSpacer -import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.shared.util.screen @@ -50,13 +52,13 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring" -private const val BG_IMAGE_WIDTH_FRACTION = 0.83f -private const val TAG_OFFSET_X = -0.179f -private const val TAG_OFFSET_Y = 0.13f -private const val KEYRING_OFFSET_X = 0.341f -private const val KEYRING_OFFSET_Y = 0.06f -private const val TAG_ALPHA = 0.6f +private const val TAG_IMAGE_WIDTH_FRACTION = 0.64f +private const val KEYRING_IMAGE_WIDTH_FRACTION = 0.83f +private const val TAG_OFFSET_X = -0.313f +private const val TAG_OFFSET_Y = 0.336f +private const val KEYRING_OFFSET_X = 0.251f +private const val KEYRING_OFFSET_Y = 0.27f +private const val TAG_ALPHA = 1f private const val KEYRING_ALPHA = 0.9f @Composable @@ -65,45 +67,24 @@ fun PubkyChoiceScreen( onNavigateToCreateProfile: () -> Unit, onNavigateToContactImportOverview: () -> Unit, onNavigateToPayContacts: () -> Unit, - onNavigateToProfile: () -> Unit, onBackClick: () -> Unit, ) { - val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - is PubkyChoiceEffect.OpenRingAuth -> runCatching { - context.startActivity(it.intent) - }.onFailure { - viewModel.onRingLaunchFailed() - } - PubkyChoiceEffect.NavigateToCreateProfile -> onNavigateToCreateProfile() PubkyChoiceEffect.NavigateToContactImportOverview -> onNavigateToContactImportOverview() PubkyChoiceEffect.NavigateToPayContacts -> onNavigateToPayContacts() } } } - LaunchedEffect(uiState.navigateToProfile) { - if (!uiState.navigateToProfile) return@LaunchedEffect - - viewModel.clearProfileNavigation() - onNavigateToProfile() - } - Content( uiState = uiState, onBackClick = onBackClick, onCreateProfile = onNavigateToCreateProfile, - onImportWithRing = { viewModel.startRingAuth() }, - onCancelAuth = { viewModel.cancelAuth() }, - onDownloadRing = { - viewModel.dismissRingNotInstalledDialog() - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PUBKY_RING_PLAY_STORE_URL))) - }, - onDismissDialog = { viewModel.dismissRingNotInstalledDialog() }, + onSelectRingIdentity = viewModel::selectRingIdentity, ) } @@ -112,10 +93,7 @@ private fun Content( uiState: PubkyChoiceUiState, onBackClick: () -> Unit, onCreateProfile: () -> Unit, - onImportWithRing: () -> Unit, - onCancelAuth: () -> Unit, - onDownloadRing: () -> Unit, - onDismissDialog: () -> Unit, + onSelectRingIdentity: (SharedPubkyChoice) -> Unit, ) { Box( modifier = Modifier @@ -128,7 +106,7 @@ private fun Content( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION) + .fillMaxWidth(TAG_IMAGE_WIDTH_FRACTION) .align(Alignment.Center) .offset(x = maxWidth * TAG_OFFSET_X, y = maxHeight * TAG_OFFSET_Y) .alpha(TAG_ALPHA) @@ -139,7 +117,7 @@ private fun Content( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION) + .fillMaxWidth(KEYRING_IMAGE_WIDTH_FRACTION) .align(Alignment.Center) .offset(x = maxWidth * KEYRING_OFFSET_X, y = maxHeight * KEYRING_OFFSET_Y) .alpha(KEYRING_ALPHA) @@ -151,66 +129,141 @@ private fun Content( titleText = stringResource(R.string.profile__nav_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, + modifier = Modifier.offset(y = (-10).dp), ) - Column(modifier = Modifier.padding(horizontal = 32.dp)) { - VerticalSpacer(24.dp) - - Display( - text = stringResource(R.string.profile__choice_title) - .withAccent(accentColor = Colors.PubkyGreen), - color = Colors.White, - ) - VerticalSpacer(8.dp) - - BodyM( - text = stringResource(R.string.profile__choice_description), - color = Colors.White64, - ) - VerticalSpacer(24.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .offset(y = (-6.5).dp) + ) { + Display( + text = stringResource(R.string.profile__choice_title) + .withAccent(accentColor = Colors.PubkyGreen), + color = Colors.White, + ) + VerticalSpacer(4.dp) + BodyM( + text = stringResource(R.string.profile__choice_description), + color = Colors.White64, + letterSpacing = 0.sp, + ) + VerticalSpacer(33.dp) - if (uiState.isLoadingAfterAuth) { - LoadingState(text = stringResource(R.string.profile__choice_loading_profile)) - } else if (uiState.isWaitingForRing) { - WaitingForRingState(onCancel = onCancelAuth) - } else { - OptionCard( - iconResId = R.drawable.ic_user_plus, - text = stringResource(R.string.profile__choice_create), + CreateProfileCard( + enabled = uiState.selectedPubky == null, onClick = onCreateProfile, - modifier = Modifier.testTag("PubkyChoiceCreate") - ) - VerticalSpacer(8.dp) - OptionCard( - iconResId = R.drawable.ic_lock_key, - text = stringResource(R.string.profile__choice_import), - onClick = onImportWithRing, - modifier = Modifier.testTag("PubkyChoiceImport") ) + + uiState.identities.forEach { + VerticalSpacer(8.dp) + RingIdentityCard( + choice = it, + isLoading = uiState.selectedPubky == it.pubky, + enabled = uiState.selectedPubky == null, + onClick = { onSelectRingIdentity(it) }, + ) + } + + if (uiState.isDiscovering) { + VerticalSpacer(20.dp) + GradientCircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterHorizontally) + .testTag("PubkyChoiceDiscovering") + ) + } + VerticalSpacer(32.dp) } } - - FillHeight() } } +} - if (uiState.showRingNotInstalledDialog) { - AppAlertDialog( - title = stringResource(R.string.profile__ring_not_installed_title), - text = stringResource(R.string.profile__ring_not_installed_description), - confirmText = stringResource(R.string.profile__ring_download), - onConfirm = onDownloadRing, - onDismiss = onDismissDialog, - ) - } +@Composable +private fun CreateProfileCard( + enabled: Boolean, + onClick: () -> Unit, +) { + ChoiceCard( + enabled = enabled, + onClick = onClick, + leading = { + ChoiceIcon(iconResId = R.drawable.ic_user_plus) + }, + content = { + Text13Up( + text = stringResource(R.string.profile__choice_new_pubky), + color = Colors.White64, + ) + BodyMSB( + text = stringResource(R.string.profile__choice_create), + color = Colors.White, + ) + }, + modifier = Modifier.testTag("PubkyChoiceCreate") + ) +} + +@Composable +private fun RingIdentityCard( + choice: SharedPubkyChoice, + isLoading: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + ChoiceCard( + enabled = enabled, + onClick = onClick, + leading = { + if (isLoading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(40.dp) + ) { + GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + } else { + ChoiceIcon(iconResId = R.drawable.ic_key) + } + }, + content = { + Text13Up( + text = choice.profile.truncatedPublicKey, + color = Colors.White64, + ) + BodyMSB( + text = choice.profile.name, + color = Colors.White, + ) + }, + trailing = { + PubkyContactAvatar( + profile = choice.profile, + size = 32.dp, + testTag = "PubkyChoiceRingAvatar", + ) + }, + modifier = Modifier.testTag("PubkyChoiceRing_${choice.pubky}") + ) } @Composable -private fun OptionCard( - iconResId: Int, - text: String, +private fun ChoiceCard( + leading: @Composable () -> Unit, + content: @Composable () -> Unit, + enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -218,71 +271,61 @@ private fun OptionCard( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(Colors.Gray6) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(16.dp) ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(40.dp) - .background(Colors.Black, CircleShape) - ) { - Icon( - painter = painterResource(iconResId), - contentDescription = null, - tint = Colors.PubkyGreen, - modifier = Modifier.size(20.dp) - ) - } + leading() HorizontalSpacer(16.dp) - BodyMSB(text = text, color = Colors.White) + Column(modifier = Modifier.weight(1f)) { + content() + } + trailing?.let { + HorizontalSpacer(12.dp) + it() + } } } @Composable -private fun WaitingForRingState(onCancel: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() +private fun ChoiceIcon(iconResId: Int) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .background(Colors.Black, CircleShape) ) { - GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) - HorizontalSpacer(12.dp) - BodyM( - text = stringResource(R.string.profile__choice_waiting_ring), - color = Colors.White64, + Icon( + painter = painterResource(iconResId), + contentDescription = null, + tint = Colors.PubkyGreen, + modifier = Modifier.size(20.dp) ) } - VerticalSpacer(16.dp) - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onCancel, - ) -} - -@Composable -private fun LoadingState(text: String) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) - HorizontalSpacer(12.dp) - BodyM(text = text, color = Colors.White64) - } } @Preview(showBackground = true) @Composable private fun Preview() { + val pubky = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" AppThemeSurface { Content( - uiState = PubkyChoiceUiState(), + uiState = PubkyChoiceUiState( + identities = persistentListOf( + SharedPubkyChoice( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = pubky, + profile = PubkyProfile.forDisplay( + publicKey = SharedPubkyContract.toBitkitPubky(pubky), + name = "Satoshi Nakamoto", + imageUrl = null, + ), + ), + ), + ), onBackClick = {}, onCreateProfile = {}, - onImportWithRing = {}, - onCancelAuth = {}, - onDownloadRing = {}, - onDismissDialog = {}, + onSelectRingIdentity = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 5414de9a61..00624aaaee 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -1,24 +1,27 @@ package to.bitkit.ui.screens.profile import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Job +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.models.PubkyRingAuthUrlBuilder +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyIdentity +import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -32,7 +35,6 @@ class PubkyChoiceViewModel @Inject constructor( ) : ViewModel() { companion object { private const val TAG = "PubkyChoiceViewModel" - internal const val PUBKY_RING_PACKAGE = "to.pubky.ring" } private val _uiState = MutableStateFlow(PubkyChoiceUiState()) @@ -41,179 +43,102 @@ class PubkyChoiceViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() - private var approvalJob: Job? = null - init { - viewModelScope.launch { - pubkyRepo.authCancelEvents.collect { - approvalJob?.cancel() - approvalJob = null - _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } - } - } - viewModelScope.launch { - pubkyRepo.isAuthenticated.collectLatest { - if (it && approvalJob?.isActive != true && !_uiState.value.isLoadingAfterAuth) { - _uiState.update { state -> state.copy(navigateToProfile = true) } - } - } - } + refreshRingIdentities() } - override fun onCleared() { - super.onCleared() - if (_uiState.value.isWaitingForRing) { - pubkyRepo.cancelAuthenticationSync() - } - } - - fun startRingAuth() { + fun refreshRingIdentities() { viewModelScope.launch { - if (_uiState.value.isWaitingForRing) { - approvalJob?.cancel() - approvalJob = null - _uiState.update { it.copy(isWaitingForRing = false) } - pubkyRepo.cancelAuthentication() - } - - if (!isRingInstalled()) { - showRingNotInstalledDialog() - return@launch - } - - pubkyRepo.startAuthentication() - .onSuccess { authRequest -> - val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks( - authUrl = authRequest.authUrl, - nonce = authRequest.callbackNonce, - ) ?: authRequest.authUrl - val ringIntent = createRingAuthIntent(callbackAuthUrl) - if (!canOpenWithRing(ringIntent)) { - cancelAuthAndShowRingDialog() - return@launch + _uiState.update { it.copy(isDiscovering = true) } + val choices = pubkyRepo.discoverRingIdentities() + .map { identities -> + coroutineScope { + identities.map { identity -> + async { + val bitkitPubky = SharedPubkyContract.toBitkitPubky(identity.pubky) + val profile = pubkyRepo.fetchRemoteProfile(bitkitPubky) + .getOrNull() + ?: PubkyProfile.placeholder(bitkitPubky) + SharedPubkyChoice( + protocolVersion = identity.protocolVersion, + sourcePackage = identity.sourcePackage, + pubky = identity.pubky, + profile = profile, + ) + } + }.awaitAll() } - - _uiState.update { it.copy(isWaitingForRing = true) } - _effects.emit(PubkyChoiceEffect.OpenRingAuth(ringIntent)) - waitForApproval() } .onFailure { - Logger.error("Starting Ring auth failed", it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, - ) + Logger.info("Found no available Pubky Ring identities", context = TAG) } + .getOrDefault(emptyList()) + .sortedWith(compareBy({ it.profile.name.lowercase() }, { it.pubky })) + .toImmutableList() + _uiState.update { it.copy(isDiscovering = false, identities = choices) } } } - fun onRingLaunchFailed() { - viewModelScope.launch { - cancelAuthAndShowRingDialog() - } - } - - @VisibleForTesting - internal fun waitForApproval() { - if (approvalJob?.isActive == true) return + fun selectRingIdentity(choice: SharedPubkyChoice) { + if (_uiState.value.selectedPubky != null) return + _uiState.update { it.copy(selectedPubky = choice.pubky) } - approvalJob = viewModelScope.launch { - pubkyRepo.completeAuthentication() + viewModelScope.launch { + pubkyRepo.adoptRingIdentity(choice.toIdentity()) .onSuccess { - _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = true) } pubkyRepo.prepareImport() .onSuccess { - _uiState.update { state -> state.copy(isLoadingAfterAuth = false) } - val hasContacts = pubkyRepo.pendingImportContacts.value.isNotEmpty() - if (hasContacts) { - _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview) - } else { + _uiState.update { state -> state.copy(selectedPubky = null) } + if (pubkyRepo.pendingImportContacts.value.isEmpty()) { _effects.emit(PubkyChoiceEffect.NavigateToPayContacts) + } else { + _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview) } } .onFailure { - Logger.error("Preparing contact import failed", it, context = TAG) - _uiState.update { state -> state.copy(isLoadingAfterAuth = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = it.message, - ) + handleSelectionFailure("Preparing shared profile failed", it) } } .onFailure { - Logger.error("Auth approval failed", it, context = TAG) - _uiState.update { it.copy(isWaitingForRing = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, - ) + handleSelectionFailure("Connecting shared profile failed", it) } } } - fun cancelAuth() { - viewModelScope.launch { - approvalJob?.cancel() - approvalJob = null - pubkyRepo.cancelAuthentication() - _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } - } - } - - fun dismissRingNotInstalledDialog() { - _uiState.update { it.copy(showRingNotInstalledDialog = false) } - } - - fun clearProfileNavigation() { - _uiState.update { it.copy(navigateToProfile = false) } - } - - @VisibleForTesting - internal fun createRingAuthIntent(authUrl: String): Intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)).apply { - setPackage(PUBKY_RING_PACKAGE) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - @VisibleForTesting - internal fun isRingInstalled(): Boolean = - context.packageManager.getLaunchIntentForPackage(PUBKY_RING_PACKAGE) != null - - @VisibleForTesting - internal fun canOpenWithRing(intent: Intent): Boolean = - intent.resolveActivity(context.packageManager) != null - - private suspend fun cancelAuthAndShowRingDialog() { - approvalJob?.cancel() - approvalJob = null - pubkyRepo.cancelAuthentication() - showRingNotInstalledDialog() - } - - private fun showRingNotInstalledDialog() { - _uiState.update { - it.copy( - isWaitingForRing = false, - isLoadingAfterAuth = false, - showRingNotInstalledDialog = true, - ) - } + private suspend fun handleSelectionFailure(message: String, error: Throwable) { + Logger.error(message, error, context = TAG) + _uiState.update { it.copy(selectedPubky = null) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__choice_error), + description = error.message, + ) + refreshRingIdentities() } } -@Immutable +@Stable data class PubkyChoiceUiState( - val isWaitingForRing: Boolean = false, - val isLoadingAfterAuth: Boolean = false, - val showRingNotInstalledDialog: Boolean = false, - val navigateToProfile: Boolean = false, + val isDiscovering: Boolean = false, + val identities: ImmutableList = persistentListOf(), + val selectedPubky: String? = null, ) +@Stable +data class SharedPubkyChoice( + val protocolVersion: Int, + val sourcePackage: String, + val pubky: String, + val profile: PubkyProfile, +) { + fun toIdentity() = SharedPubkyIdentity( + protocolVersion = protocolVersion, + sourcePackage = sourcePackage, + pubky = pubky, + ) +} + sealed interface PubkyChoiceEffect { - data class OpenRingAuth(val intent: Intent) : PubkyChoiceEffect - data object NavigateToCreateProfile : PubkyChoiceEffect data object NavigateToContactImportOverview : PubkyChoiceEffect data object NavigateToPayContacts : PubkyChoiceEffect } diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt index 58a6d4917f..a588b28d40 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt @@ -10,7 +10,7 @@ object Colors { val Purple = Color(0xFFB95CE8) val Red = Color(0xFFE95164) val Yellow = Color(0xFFFFD200) - val PubkyGreen = Color(0xFFBEFF00) + val PubkyGreen = Color(0xFFC8FF00) val Bitcoin = Color(0xFFF7931A) // Base diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c912066536..c87b10fad0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5581,6 +5581,15 @@ class AppViewModel @Inject constructor( } } + fun onAppResumed() { + viewModelScope.launch(bgDispatcher) { + runSuspendCatching { pubkyRepo.validateExternalIdentitySource() } + .onFailure { + Logger.error("Failed to clear unavailable shared Pubky identity", it, context = TAG) + } + } + } + fun onLeftHome() = timedSheetManager.onHomeScreenExited() fun dismissTimedSheet() = timedSheetManager.dismissCurrentSheet() diff --git a/app/src/main/res/drawable/ic_key.xml b/app/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000000..1b1fd1e6d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_key.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80e2fbddc7..0c3f02f63a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -628,11 +628,10 @@ This Bitkit claim is not supported. Failed to read selected image Create profile with Bitkit - Create a new pubky and profile in Bitkit, or import an existing profile with Pubky Ring. - Import with Pubky Ring - Loading your profile… - Join the\n<accent>pubky web</accent> - Waiting for Pubky Ring… + Create a new pubky and profile in Bitkit, or use an existing pubky from Pubky Ring. + Couldn\'t use this pubky + New pubky + ENTER THE\n<accent>FREEDOM WEB</accent> Failed to create profile Create Profile Restoring your existing profile… @@ -669,14 +668,6 @@ Scan to add {name} Restore Profile Try Again - Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web. - Join the\n<accent>pubky web</accent> - Authorize - Download - Loading your profile… - Pubky Ring is required to authorize your profile. Would you like to download it? - Pubky Ring Not Installed - Waiting for authorization from Pubky Ring… Your profile session has expired. Please reconnect to restore your profile. Disconnect This will disconnect your Pubky profile from Bitkit. You can reconnect at any time. diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt new file mode 100644 index 0000000000..d4d09f03b4 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt @@ -0,0 +1,82 @@ +package to.bitkit.data.sharing + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SharedPubkyContractTest { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + @Test + fun `wire format is always bare lowercase z-base32`() { + assertEquals(WIRE_PUBKY, SharedPubkyContract.canonicalPubky(" PUBKY${WIRE_PUBKY.uppercase()} ")) + assertEquals("pubky$WIRE_PUBKY", SharedPubkyContract.toBitkitPubky(WIRE_PUBKY)) + } + + @Test + fun `bare wire key may itself begin with pubky`() { + val wirePubky = "pubky" + "y".repeat(47) + + assertEquals(wirePubky, SharedPubkyContract.canonicalPubky(wirePubky)) + assertEquals("pubky$wirePubky", SharedPubkyContract.toBitkitPubky(wirePubky)) + } + + @Test + fun `wire format rejects wrong length and alphabet`() { + assertFailsWith { + SharedPubkyContract.canonicalPubky(WIRE_PUBKY.dropLast(1)) + } + assertFailsWith { + SharedPubkyContract.canonicalPubky("${WIRE_PUBKY.dropLast(1)}0") + } + assertFailsWith { + SharedPubkyContract.requireWirePubky("pubky$WIRE_PUBKY") + } + assertFailsWith { + SharedPubkyContract.requireWirePubky(WIRE_PUBKY.uppercase()) + } + } + + @Test + fun `credential Uri encodes the selected bare pubky in the v1 path`() { + assertEquals( + "content://app.pubkyring.sharedpubky/v1/identities/$WIRE_PUBKY/credential", + SharedPubkyContract.ringCredentialUriString("pubky$WIRE_PUBKY"), + ) + } + + @Test + fun `secret key wire format requires exactly 64 lowercase hex characters`() { + assertEquals(SECRET_KEY, SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY)) + assertFailsWith { + SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY.dropLast(1)) + } + assertFailsWith { + SharedPubkyContract.canonicalSecretKeyHex("${SECRET_KEY.dropLast(1)}z") + } + assertFailsWith { + SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY.uppercase()) + } + } + + @Test + fun `external reference rejects unsupported sources and versions`() { + assertFailsWith { + ExternalPubkyIdentityRef( + protocolVersion = 2, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = WIRE_PUBKY, + ).validated() + } + assertFailsWith { + ExternalPubkyIdentityRef( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = "other.app", + pubky = WIRE_PUBKY, + ).validated() + } + } +} diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt new file mode 100644 index 0000000000..641cfa8112 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt @@ -0,0 +1,41 @@ +package to.bitkit.data.sharing + +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.PermissionInfo +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.BuildConfig +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class, sdk = [34]) +class SharedPubkyManifestTest { + private val context = ApplicationProvider.getApplicationContext() + private val packageManager = context.packageManager + + @Test + fun `provider authority and permissions expand for the application variant`() { + val applicationId = BuildConfig.APPLICATION_ID + val permissionName = "$applicationId.permission.READ_SHARED_PUBKY" + val provider = requireNotNull( + packageManager.resolveContentProvider("$applicationId.sharedpubky", PackageManager.MATCH_ALL) + ) + + assertEquals(applicationId, provider.packageName) + assertEquals(permissionName, provider.readPermission) + assertEquals(permissionName, provider.writePermission) + assertTrue(provider.exported) + + val permission = packageManager.getPermissionInfo(permissionName, PackageManager.GET_META_DATA) + assertEquals( + PermissionInfo.PROTECTION_SIGNATURE, + permission.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE, + ) + } +} diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt new file mode 100644 index 0000000000..c83e0af6f4 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt @@ -0,0 +1,78 @@ +package to.bitkit.data.sharing + +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SharedPubkyProviderTest { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + @Test + fun `borrowed active identity without a local secret is never exported`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = false, + secretKeyHex = null, + publicKeyFromSecret = { error("Must not derive a borrowed identity") }, + ) + + assertNull(identity) + } + + @Test + fun `disabled local identity is never exported`() { + val identity = localSharedPubkyIdentity( + exportEnabled = false, + managedSecretQuarantined = false, + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { WIRE_PUBKY }, + ) + + assertNull(identity) + } + + @Test + fun `quarantined managed identity is never exported`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = true, + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { error("Must not derive a quarantined identity") }, + ) + + assertNull(identity) + } + + @Test + fun `public discovery row excludes the local secret`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = false, + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { "pubky$WIRE_PUBKY" }, + ) + + assertEquals(WIRE_PUBKY, identity?.pubky) + assertContentEquals( + arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + WIRE_PUBKY, + ), + identity?.publicRow(), + ) + assertContentEquals( + arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + WIRE_PUBKY, + SECRET_KEY, + ), + identity?.credentialRow(), + ) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index d2d9ac791b..7520f24315 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -20,18 +20,25 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -43,6 +50,11 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.data.sharing.ExternalPubkyIdentityRef +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyCredential +import to.bitkit.data.sharing.SharedPubkyDiscovery +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthRequest @@ -64,12 +76,15 @@ import kotlin.time.Duration.Companion.milliseconds import com.synonym.paykit.PubkyProfile as SdkPubkyProfile @Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class) class PubkyRepoTest : BaseUnitTest() { companion object { // Valid 52-char z-base-32 key (+ "pubky" prefix = 57 chars) private const val VALID_CONTACT_KEY_A = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val VALID_CONTACT_KEY_B = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" private const val VALID_SELF_KEY = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val SHARED_SECRET_KEY = + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" } private lateinit var sut: PubkyRepo @@ -79,13 +94,18 @@ class PubkyRepoTest : BaseUnitTest() { private val imageLoader = mock() private val pubkyStore = mock() private val settingsStore = mock() + private val sharedPubkyDiscovery = mock() private val settingsFlow = MutableStateFlow(SettingsData()) private val profileSetupPending = MutableStateFlow(false) + private val pubkyDataFlow = MutableStateFlow(PubkyStoreData()) + private var sharedExportEnabled: String? = null @Before fun setUp() = runBlocking { settingsFlow.value = SettingsData() - whenever(pubkyStore.data).thenReturn(flowOf(PubkyStoreData())) + pubkyDataFlow.value = PubkyStoreData() + sharedExportEnabled = null + whenever(pubkyStore.data).thenReturn(pubkyDataFlow) whenever(settingsStore.data).thenReturn(settingsFlow) whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(profileSetupPending) whenever { settingsStore.setPubkyProfileSetupPending(any()) }.thenAnswer { @@ -93,12 +113,34 @@ class PubkyRepoTest : BaseUnitTest() { Unit } whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + whenever(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name)) + .thenAnswer { sharedExportEnabled } + whenever(keychain.upsertString(eq(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name), any())) + .thenAnswer { + sharedExportEnabled = it.getArgument(1) + Unit + } + whenever(keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name)) + .thenAnswer { + sharedExportEnabled = null + Unit + } + whenever { pubkyStore.update(any()) }.thenAnswer { + val transform = it.getArgument<(PubkyStoreData) -> PubkyStoreData>(0) + pubkyDataFlow.value = transform(pubkyDataFlow.value) + Unit + } + whenever { pubkyStore.reset() }.thenAnswer { + pubkyDataFlow.value = PubkyStoreData() + Unit + } whenever { settingsStore.update(any()) }.thenAnswer { val transform = it.getArgument<(SettingsData) -> SettingsData>(0) settingsFlow.value = transform(settingsFlow.value) Unit } sut = createSut() + Unit } private fun createSut(httpClient: HttpClient = mock()) = PubkyRepo( @@ -109,6 +151,7 @@ class PubkyRepoTest : BaseUnitTest() { pubkyStore = pubkyStore, settingsStore = settingsStore, httpClient = httpClient, + sharedPubkyDiscovery = sharedPubkyDiscovery, ) @Test @@ -248,6 +291,151 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(runSuspendCatching { sut.hasIdentity() }.isFailure) } + @Test + fun `adopt Ring identity persists source reference and never stores shared secret`() = test { + val identity = stubRingIdentity() + + val result = sut.adoptRingIdentity(identity) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService) { signInExternal(SHARED_SECRET_KEY) } + verifyBlocking(keychain, never()) { + upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY) + } + assertNull(sut.snapshotSessionBackupState().getOrThrow()) + } + + @Test + fun `adopt Ring identity rejects credential whose secret derives another pubky`() = test { + val identity = stubRingIdentity(derivedPublicKey = VALID_CONTACT_KEY_B) + + val result = sut.adoptRingIdentity(identity) + + assertTrue(result.isFailure) + assertNull(sut.publicKey.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { signInExternal(SHARED_SECRET_KEY) } + } + + @Test + fun `Ring managed identity reads credential just in time for auth approval`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + + val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw") + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + approveAuth("pubkyauth://signin", "/pub/example/:rw", SHARED_SECRET_KEY) + } + verifyBlocking(keychain, never()) { + upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY) + } + } + + @Test + fun `missing Ring source clears borrowed reference and local session`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + clearInvocations(pubkyService, pubkyStore) + + val available = sut.validateExternalIdentitySource() + + assertFalse(available) + assertNull(sut.publicKey.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + verifyBlocking(pubkyService, never()) { forceSignOut() } + } + + @Test + fun `source cleanup preserves marker when external session cleanup fails`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + val cleanupError = RuntimeException("cleanup failed") + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { pubkyService.clearExternalSessionAccess() }.thenThrow(cleanupError) + clearInvocations(pubkyStore) + + val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() + + assertEquals(cleanupError.message, thrown?.message) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + verify(pubkyStore, never()).reset() + } + + @Test + fun `source cleanup keeps marker after reset failure and succeeds on retry`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + val resetError = RuntimeException("reset failed") + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + doThrow(resetError).whenever(pubkyStore).reset() + clearInvocations(pubkyService, pubkyStore) + + val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() + + assertEquals(resetError.message, thrown?.message) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + + doAnswer { + pubkyDataFlow.value = PubkyStoreData() + Unit + }.whenever(pubkyStore).reset() + assertFalse(sut.validateExternalIdentitySource()) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, times(2)) { clearExternalSessionAccess() } + verifyBlocking(pubkyStore, times(2)) { reset() } + } + + @Test + fun `source cleanup quarantines but never deletes a conflicting managed secret`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed-secret") + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)) + .thenReturn("1") + + assertFalse(sut.validateExternalIdentitySource()) + + verifyBlocking(keychain) { + upsertString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, "1") + } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + + @Test + fun `Ring adoption cannot interleave with local identity restore`() = test { + val identity = stubRingIdentity() + val releaseExternalSignIn = CompletableDeferred() + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).doSuspendableAnswer { + releaseExternalSignIn.await() + VALID_SELF_KEY + } + + val adoption = async { sut.adoptRingIdentity(identity) } + runCurrent() + val restore = async { sut.restoreSessionBackupState(null) } + runCurrent() + + assertFalse(restore.isCompleted) + verifyBlocking(pubkyService, never()) { clearSessionAccess() } + + releaseExternalSignIn.complete(Unit) + advanceUntilIdle() + + assertTrue(adoption.await().isSuccess) + assertTrue(restore.await().isSuccess) + verifyBlocking(pubkyService) { clearSessionAccess() } + } + @Test fun `startAuthentication should return auth uri on success`() = test { val authUri = "pubky://auth?capabilities=..." @@ -355,7 +543,9 @@ class PubkyRepoTest : BaseUnitTest() { val capabilities = "/pub/bitkit.to/:rw" val clientId = "paykit.test" val secretKey = "local_secret" + authenticateForTesting(publicKey = VALID_SELF_KEY) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY) val result = sut.approveAuth(authUrl, capabilities, clientId) @@ -369,7 +559,9 @@ class PubkyRepoTest : BaseUnitTest() { val clientId = "paykit.test" val secretKey = "local_secret" val payload = ByteArray(84) { it.toByte() } + authenticateForTesting(publicKey = VALID_SELF_KEY) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY) val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload) @@ -1353,7 +1545,7 @@ class PubkyRepoTest : BaseUnitTest() { val pubkyProfile = createPubkyProfile(name = "Restored User") whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) - whenever(pubkyService.importSession(session)).thenReturn(unprefixedPublicKey) + whenever(pubkyService.importExternalSession(session)).thenReturn(unprefixedPublicKey) whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) @@ -1386,7 +1578,7 @@ class PubkyRepoTest : BaseUnitTest() { val session = "stale_session" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) - whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") } + whenever(pubkyService.importExternalSession(session)).thenAnswer { throw TestAppError("Expired") } sut.initialize() @@ -1395,6 +1587,49 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } + @Test + fun `initialize retries quarantined external cleanup before removing its marker`() = test { + val identity = stubRingIdentity() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity.toExternalRefForTest()) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed_secret") + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)).thenReturn("1") + clearInvocations(pubkyService, pubkyStore) + + sut.initialize() + + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + verifyBlocking(pubkyService, never()) { importExternalSession(any()) } + verifyBlocking(pubkyService, never()) { signInExternal(any()) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + + @Test + fun `initialize preserves external marker when source exists but sign in cannot recover`() = test { + val identity = stubRingIdentity() + val identityRef = identity.toExternalRefForTest() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identityRef) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("stale_session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(pubkyService.importExternalSession("stale_session")) + .thenAnswer { throw TestAppError("Expired") } + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)) + .thenAnswer { throw TestAppError("Offline") } + clearInvocations(pubkyService, pubkyStore) + + sut.initialize() + + assertTrue(sut.sessionRestorationFailed.value) + assertFalse(sut.isAuthenticated.value) + assertEquals(identityRef, pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verifyBlocking(pubkyStore, never()) { reset() } + } + @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" @@ -1423,6 +1658,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `restoreSessionBackupState should derive local secret key for local seed backups`() = test { whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("derived_secret") whenever(pubkyService.deriveSecretKey("test mnemonic")).thenReturn("derived_secret") whenever(pubkyService.signIn("derived_secret")).thenReturn(Unit) whenever(pubkyService.publicKeyFromSecret("derived_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky")) @@ -1883,6 +2119,32 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals("pubky://avatar", contact.imageUrl) } + private suspend fun stubRingIdentity( + derivedPublicKey: String = VALID_SELF_KEY, + ): SharedPubkyIdentity { + val identity = SharedPubkyIdentity( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = VALID_SELF_KEY.removePrefix("pubky"), + ) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)).thenReturn( + Result.success( + SharedPubkyCredential( + identity = identity, + secretKeyHex = SHARED_SECRET_KEY, + ), + ), + ) + whenever(pubkyService.publicKeyFromSecret(SHARED_SECRET_KEY)).thenReturn(derivedPublicKey) + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn( + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Satoshi")), + ) + whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + return identity + } + private suspend fun authenticateForTesting( publicKey: String = "test_pk_12345", secret: String = "test_secret", @@ -2008,3 +2270,9 @@ private class TestAppError(message: String) : AppError(message) private fun String.ensurePubkyPrefixForTest(): String = if (startsWith("pubky")) this else "pubky$this" + +private fun SharedPubkyIdentity.toExternalRefForTest() = ExternalPubkyIdentityRef( + protocolVersion = protocolVersion, + sourcePackage = sourcePackage, + pubky = pubky, +) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 005e78b71e..e06e146149 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -255,7 +255,7 @@ class PaykitSdkServiceTest { } @Test - fun `session teardown attempts both credentials with session first`() { + fun `session teardown attempts every credential with shared export disabled first`() { val attemptedKeys = mutableListOf() assertFailsWith { @@ -266,11 +266,62 @@ class PaykitSdkServiceTest { } assertEquals( - listOf(Keychain.Key.PAYKIT_SESSION.name, Keychain.Key.PUBKY_SECRET_KEY.name), + listOf( + Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name, + Keychain.Key.PAYKIT_SESSION.name, + Keychain.Key.PUBKY_SECRET_KEY.name, + Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, + ), attemptedKeys, ) } + @Test + fun `owned session requires and persists its exported local secret`() { + val secretKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + assertEquals( + secretKeyHex, + managedSecretForSessionPersistence( + shouldStoreLocalSecret = true, + exportedLocalSecretKeyHex = secretKeyHex, + existingManagedSecretKeyHex = null, + ), + ) + assertFailsWith { + managedSecretForSessionPersistence( + shouldStoreLocalSecret = true, + exportedLocalSecretKeyHex = null, + existingManagedSecretKeyHex = null, + ) + } + assertFailsWith { + managedSecretForSessionPersistence( + shouldStoreLocalSecret = true, + exportedLocalSecretKeyHex = secretKeyHex, + existingManagedSecretKeyHex = "different-secret", + ) + } + } + + @Test + fun `external session refuses to replace a managed local secret`() { + assertNull( + managedSecretForSessionPersistence( + shouldStoreLocalSecret = false, + exportedLocalSecretKeyHex = null, + existingManagedSecretKeyHex = null, + ), + ) + assertFailsWith { + managedSecretForSessionPersistence( + shouldStoreLocalSecret = false, + exportedLocalSecretKeyHex = null, + existingManagedSecretKeyHex = "managed-secret", + ) + } + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index d338e9a84f..765db672d9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -1,19 +1,19 @@ package to.bitkit.ui.screens.profile import android.content.Context -import android.content.pm.PackageManager import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo @@ -21,124 +21,128 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PubkyChoiceViewModelTest : BaseUnitTest() { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + private val context: Context = mock() - private val packageManager: PackageManager = mock() private val pubkyRepo: PubkyRepo = mock() private val pendingImportContacts = MutableStateFlow>(emptyList()) - private val isAuthenticated = MutableStateFlow(false) - private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) - - private lateinit var sut: PubkyChoiceViewModel @Before - fun setUp() { - whenever(context.packageManager).thenReturn(packageManager) - whenever(context.getString(R.string.common__error)).thenReturn("Error") - whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") + fun setUp() = runBlocking { + whenever(context.getString(R.string.profile__choice_error)).thenReturn("Couldn't use this pubky") whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) - whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) - whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents) - } - - private fun createSut() { - sut = PubkyChoiceViewModel( - context = context, - pubkyRepo = pubkyRepo, - ) + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + Unit } @Test - fun `session restoration redirects to profile when already authenticated`() = test { - isAuthenticated.value = true - createSut() + fun `discovery resolves safe public profile data for Ring choices`() = test { + val identity = ringIdentity() + val profile = PubkyProfile.forDisplay( + publicKey = SharedPubkyContract.toBitkitPubky(WIRE_PUBKY), + name = "Satoshi", + imageUrl = "pubky://avatar", + ) + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile(profile.publicKey)).thenReturn(Result.success(profile)) + val sut = createSut() advanceUntilIdle() - assertTrue(sut.uiState.value.navigateToProfile) + assertFalse(sut.uiState.value.isDiscovering) + assertEquals(1, sut.uiState.value.identities.size) + assertEquals(WIRE_PUBKY, sut.uiState.value.identities.single().pubky) + assertEquals(profile, sut.uiState.value.identities.single().profile) } @Test - fun `clearProfileNavigation clears profile redirect`() = test { - createSut() - isAuthenticated.value = true + fun `selection adopts exact Ring identity then opens contact overview`() = test { + val identity = ringIdentity() + pendingImportContacts.value = listOf(PubkyProfile.placeholder("pubky$WIRE_PUBKY")) + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.prepareImport()).thenReturn(Result.success(Unit)) + val sut = createSut() advanceUntilIdle() - sut.clearProfileNavigation() + val effects = mutableListOf() + val job = launch { sut.effects.collect(effects::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) + advanceUntilIdle() - assertFalse(sut.uiState.value.navigateToProfile) + verify(pubkyRepo).adoptRingIdentity(identity) + verify(pubkyRepo).prepareImport() + assertEquals( + listOf(PubkyChoiceEffect.NavigateToContactImportOverview), + effects, + ) + assertNull(sut.uiState.value.selectedPubky) + job.cancel() } @Test - fun `waitForApproval prepareImport failure clears loading and emits no navigation`() = test { - createSut() - whenever(pubkyRepo.completeAuthentication()).thenReturn(Result.success(Unit)) - whenever(pubkyRepo.prepareImport()).thenReturn(Result.failure(RuntimeException("Import failed"))) + fun `selection with no contacts opens Pay Contacts`() = test { + val identity = ringIdentity() + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.prepareImport()).thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() val effects = mutableListOf() - val toasts = mutableListOf() - val effectsJob = launch { sut.effects.collect { effects.add(it) } } - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - - sut.waitForApproval() + val job = launch { sut.effects.collect(effects::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) advanceUntilIdle() - assertFalse(sut.uiState.value.isLoadingAfterAuth) - assertFalse(sut.uiState.value.isWaitingForRing) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isNotEmpty()) - assertEquals(Toast.ToastType.ERROR, toasts.last().type) - assertEquals("Error", toasts.last().title) - assertEquals("Import failed", toasts.last().description) - - effectsJob.cancel() - toastJob.cancel() + assertEquals( + listOf(PubkyChoiceEffect.NavigateToPayContacts), + effects, + ) + job.cancel() } @Test - fun `startRingAuth shows dialog when Ring is not installed`() = test { - createSut() - whenever(packageManager.getLaunchIntentForPackage(PubkyChoiceViewModel.PUBKY_RING_PACKAGE)) - .thenReturn(null) + fun `failed selection clears loading and reports error`() = test { + val identity = ringIdentity() + val error = IllegalStateException("Credential mismatch") + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.failure(error)) + val sut = createSut() + advanceUntilIdle() - val effects = mutableListOf() val toasts = mutableListOf() - val effectsJob = launch { sut.effects.collect { effects.add(it) } } - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - - sut.startRingAuth() + val job = launch { ToastEventBus.events.collect(toasts::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) advanceUntilIdle() - assertTrue(sut.uiState.value.showRingNotInstalledDialog) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isEmpty()) - verify(pubkyRepo, never()).startAuthentication() - - effectsJob.cancel() - toastJob.cancel() + assertNull(sut.uiState.value.selectedPubky) + assertTrue(toasts.isNotEmpty()) + assertEquals("Couldn't use this pubky", toasts.last().title) + assertEquals(error.message, toasts.last().description) + job.cancel() } - @Test - fun `onRingLaunchFailed shows dialog without toast`() = test { - createSut() - val effects = mutableListOf() - val toasts = mutableListOf() - val effectsJob = launch { sut.effects.collect { effects.add(it) } } - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + private fun createSut() = PubkyChoiceViewModel( + context = context, + pubkyRepo = pubkyRepo, + ) - sut.onRingLaunchFailed() - advanceUntilIdle() - - assertFalse(sut.uiState.value.isWaitingForRing) - assertTrue(sut.uiState.value.showRingNotInstalledDialog) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isEmpty()) - verify(pubkyRepo).cancelAuthentication() - - effectsJob.cancel() - toastJob.cancel() - } + private fun ringIdentity() = SharedPubkyIdentity( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = WIRE_PUBKY, + ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 829cd2bf29..f316194c7a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2201,6 +2201,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(isPresentingPaymentRequest()) } + @Test + fun `onAppResumed validates an external Pubky identity source`() = test { + whenever(pubkyRepo.validateExternalIdentitySource()).thenReturn(true) + + sut.onAppResumed() + advanceUntilIdle() + + verify(pubkyRepo).validateExternalIdentitySource() + } + @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" diff --git a/changelog.d/next/shared-pubky-ring.added.md b/changelog.d/next/shared-pubky-ring.added.md new file mode 100644 index 0000000000..632fbf2f1d --- /dev/null +++ b/changelog.d/next/shared-pubky-ring.added.md @@ -0,0 +1 @@ +Added secure reuse of Pubky Ring profiles in Bitkit and sharing of Bitkit-owned Pubky identities with Ring. From 6ec59bf9d7067e30ebd8714849acdc9dd0263ff7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 15:46:47 +0200 Subject: [PATCH 02/23] chore: rename changelog fragment --- changelog.d/next/{shared-pubky-ring.added.md => 1109.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{shared-pubky-ring.added.md => 1109.added.md} (100%) diff --git a/changelog.d/next/shared-pubky-ring.added.md b/changelog.d/next/1109.added.md similarity index 100% rename from changelog.d/next/shared-pubky-ring.added.md rename to changelog.d/next/1109.added.md From 71769c2597dcf7a5837cf6f70d496d1d12be06a7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 24 Jul 2026 16:48:40 +0200 Subject: [PATCH 03/23] fix: align Pubky import overview with designs --- .../main/java/to/bitkit/ui/components/Button.kt | 6 ++++++ .../main/java/to/bitkit/ui/components/Text.kt | 4 ++++ .../contacts/ContactImportOverviewScreen.kt | 16 ++++++++++++---- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index cda8c1eaf4..99950de605 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -33,7 +33,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeTint @@ -102,6 +104,7 @@ fun PrimaryButton( color: Color? = null, enableGradient: Boolean = true, contentColor: Color = Colors.White, + letterSpacing: TextUnit = 0.4.sp, ) { val contentPadding = PaddingValues(horizontal = size.primaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val buttonShape = MaterialTheme.shapes.extraLarge @@ -154,6 +157,7 @@ fun PrimaryButton( text?.let { Text( text = text, + style = MaterialTheme.typography.labelLarge.copy(letterSpacing = letterSpacing), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -174,6 +178,7 @@ fun SecondaryButton( enabled: Boolean = true, fullWidth: Boolean = true, hazeState: HazeState? = null, + letterSpacing: TextUnit = 0.4.sp, ) { val contentPadding = PaddingValues(horizontal = size.secondaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val border = size.secondaryBorder(enabled) @@ -238,6 +243,7 @@ fun SecondaryButton( text?.let { Text( text = text, + style = MaterialTheme.typography.labelLarge.copy(letterSpacing = letterSpacing), maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index f98596e5d7..b5dbbd4e93 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -229,6 +229,7 @@ fun BodyMSB( maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, textAlign: TextAlign = TextAlign.Start, + letterSpacing: TextUnit = 0.4.sp, ) { BodyMSB( text = AnnotatedString(text), @@ -237,6 +238,7 @@ fun BodyMSB( overflow = overflow, modifier = modifier, textAlign = textAlign, + letterSpacing = letterSpacing, ) } @@ -248,12 +250,14 @@ fun BodyMSB( maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, textAlign: TextAlign = TextAlign.Start, + letterSpacing: TextUnit = 0.4.sp, ) { Text( text = text, style = AppTextStyles.BodyMSB.merge( color = color, textAlign = textAlign, + letterSpacing = letterSpacing, ), maxLines = maxLines, overflow = overflow, diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt index 35aebff72c..32ff74c815 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactImportOverviewScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -113,6 +114,9 @@ private fun Content( titleText = stringResource(R.string.contacts__import_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, + modifier = Modifier + .height(48.dp) + .offset(y = (-2).dp), ) Column( @@ -120,27 +124,28 @@ private fun Content( .fillMaxSize() .padding(horizontal = 16.dp) ) { - VerticalSpacer(24.dp) + VerticalSpacer(10.dp) Display( text = stringResource(R.string.contacts__import_overview_headline) .withAccent(accentColor = Colors.PubkyGreen), ) - VerticalSpacer(8.dp) + VerticalSpacer(4.dp) val truncatedKey = uiState.profile?.truncatedPublicKey.orEmpty() BodyM( text = stringResource(R.string.contacts__import_overview_subtitle, truncatedKey) .withAccentBoldBright(), color = Colors.White64, + letterSpacing = 0.sp, ) VerticalSpacer(32.dp) if (uiState.profile != null) { ProfileRow(profile = uiState.profile) - VerticalSpacer(24.dp) + VerticalSpacer(31.dp) } if (uiState.contacts.isNotEmpty()) { @@ -157,15 +162,17 @@ private fun Content( text = stringResource(R.string.contacts__import_select), onClick = onClickSelect, modifier = Modifier.weight(1f), + letterSpacing = 0.sp, ) PrimaryButton( text = stringResource(R.string.contacts__import_all), onClick = onClickImportAll, isLoading = uiState.isImporting, modifier = Modifier.weight(1f), + letterSpacing = 0.sp, ) } - VerticalSpacer(16.dp) + VerticalSpacer(10.dp) } } } @@ -211,6 +218,7 @@ private fun ContactCountRow(contacts: ImmutableList) { ) { BodyMSB( text = stringResource(R.string.contacts__import_friends_count, contacts.size), + letterSpacing = 0.sp, ) AvatarStack(contacts = contacts) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c3f02f63a..b22db2d879 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,7 +131,7 @@ Failed to save contact Import All %1$d friends - Found\n<accent>profile & contacts</accent> + Found\n<accent>contacts</accent> Bitkit found profile and contacts data connected to pubky <accent>%1$s</accent> Select Select all From eeefb23a6de6ce96b5c7ecefec756058cac8a2b5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 10:32:58 +0200 Subject: [PATCH 04/23] fix: keep the grant client id when rebuilding external session access --- app/src/main/java/to/bitkit/services/PaykitSdkService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 0a2a9f53ad..57e5c4e91b 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1382,6 +1382,7 @@ private fun liveSessionAccess( ): PubkySessionAccess { if (retainLocalSecret) return access return PubkySessionAccess( + clientId = access.clientId(), sessionSecret = access.exportSessionSecret(), localSecretKey = null, receiverNoiseSecretKey = access.exportReceiverNoiseSecretKey(), From 2d7b4bddd80e557ff40a28e4a10235b2af192ca2 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 10:33:44 +0200 Subject: [PATCH 05/23] fix: fail closed on shared pubky invariants in new identity paths --- app/src/main/java/to/bitkit/repositories/PubkyRepo.kt | 11 +++++++++++ .../main/java/to/bitkit/usecases/WipeWalletUseCase.kt | 1 + 2 files changed, 12 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 2ded76ae9b..826cc398b4 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -693,6 +693,10 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { settingsStore.setPubkyProfileSetupPending(false) val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + .takeUnless { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + } val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) { pubkyService.signIn(storedSecretKeyHex) pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() @@ -1227,6 +1231,9 @@ class PubkyRepo @Inject constructor( runSuspendCatching { withContext(ioDispatcher) { require(request.isSignup) { "Not a Pubky signup request" } + if (pubkyStore.data.first().externalIdentityRef != null) { + throw SharedPubkyError.IdentityConflict + } if (hasIdentity()) throw PubkyAlreadySignedInError val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() @@ -1493,6 +1500,10 @@ class PubkyRepo @Inject constructor( clearLocalState() } + suspend fun disableSharedIdentityExport(): Result = identityLifecycleMutex.withLock { + runSuspendCatching { disableLocalIdentityExport() } + } + // endregion // region Private helpers diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index 215fc565d7..c42e0bb763 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -58,6 +58,7 @@ class WipeWalletUseCase @Inject constructor( lightningRepo.setWiping(true) val result = try { runSuspendCatching { + step("disable shared Pubky export") { pubkyRepo.disableSharedIdentityExport() } stopNode().getOrThrow() cleanupRemote() wipeLocal(walletIndex, resetWalletState).getOrThrow() From 0cc46f7c305c01389967871c0c8d9df2af078540 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 10:35:11 +0200 Subject: [PATCH 06/23] test: align shared pubky tests with upstream session and auth apis --- .../test/java/to/bitkit/repositories/PubkyRepoTest.kt | 11 ++++++----- .../java/to/bitkit/services/PaykitSdkServiceTest.kt | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7520f24315..ffaf148602 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -324,11 +324,11 @@ class PubkyRepoTest : BaseUnitTest() { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) - val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw") + val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw", "paykit.test") assertTrue(result.isSuccess) verifyBlocking(pubkyService) { - approveAuth("pubkyauth://signin", "/pub/example/:rw", SHARED_SECRET_KEY) + approveAuth("pubkyauth://signin", "/pub/example/:rw", "paykit.test", SHARED_SECRET_KEY) } verifyBlocking(keychain, never()) { upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY) @@ -351,7 +351,8 @@ class PubkyRepoTest : BaseUnitTest() { verify(pubkyService).clearExternalSessionAccess() verify(pubkyStore).reset() } - verifyBlocking(pubkyService, never()) { forceSignOut() } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } } @Test @@ -426,14 +427,14 @@ class PubkyRepoTest : BaseUnitTest() { runCurrent() assertFalse(restore.isCompleted) - verifyBlocking(pubkyService, never()) { clearSessionAccess() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } releaseExternalSignIn.complete(Unit) advanceUntilIdle() assertTrue(adoption.await().isSuccess) assertTrue(restore.await().isSuccess) - verifyBlocking(pubkyService) { clearSessionAccess() } + verifyBlocking(pubkyService) { forgetSessionAccess() } } @Test diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index e06e146149..cd7f411855 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -42,6 +42,7 @@ class PaykitSdkServiceTest { } val bytes = ByteArray(32) { 1 } whenever(blocking.load(Keychain.Key.PAYKIT_RECEIVER_NOISE_SECRET_KEY.name)).thenReturn(bytes) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(bytes.toHex()) val sdk = mock() whenever(sdk.contactRecords()).thenReturn(emptyList()) val access = mock() From f07e09efb0e9f7eef3f0a82107ba9a6fdb131fa5 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 10:57:53 +0200 Subject: [PATCH 07/23] test: model persisted pubky secret in upstream identity fixtures --- .../to/bitkit/repositories/PubkyRepoTest.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index ffaf148602..1c81d61169 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1015,8 +1015,13 @@ class PubkyRepoTest : BaseUnitTest() { val httpClient = identityHttpClient() sut = createSut(httpClient) stubSignupKeys() + var storedSecretKeyHex: String? = "" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("ring-session") - whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer { storedSecretKeyHex } + whenever(pubkyService.signUp("secret", "test-homeserver", "test-code")).thenAnswer { + storedSecretKeyHex = "secret" + Unit + } whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) @@ -1062,7 +1067,12 @@ class PubkyRepoTest : BaseUnitTest() { 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) + var storedSecretKeyHex: String? = null + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer { storedSecretKeyHex } + whenever(pubkyService.signUp("test-secret", "test-homeserver", "test-code")).thenAnswer { + storedSecretKeyHex = "test-secret" + Unit + } whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) @@ -1492,7 +1502,7 @@ class PubkyRepoTest : BaseUnitTest() { fun `awaitInitialization shares startup and preserves it when a waiter is cancelled`() = test { val imported = CompletableDeferred() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") - whenever(pubkyService.importSession("saved_session")).doSuspendableAnswer { imported.await() } + whenever(pubkyService.importExternalSession("saved_session")).doSuspendableAnswer { imported.await() } val repo = createSut() val cancelledWaiter = async { repo.awaitInitialization() } val waiter = async { repo.awaitInitialization() } @@ -1504,14 +1514,14 @@ class PubkyRepoTest : BaseUnitTest() { waiter.await() assertEquals(VALID_SELF_KEY, repo.publicKey.value) - verify(pubkyService).importSession("saved_session") + verify(pubkyService).importExternalSession("saved_session") } @Test fun `awaitInitialization completes before startup profile loading`() = test { val profileLoad = CompletableDeferred() whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") - whenever(pubkyService.importSession("saved_session")).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.importExternalSession("saved_session")).thenReturn(VALID_SELF_KEY) whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { profileLoad.await() createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile()) From b3a73fdf300ca9d899e2e31204008733a6215dc4 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 12:33:14 +0200 Subject: [PATCH 08/23] fix: abort the wallet wipe when shared Pubky export cannot be disabled The first wipe step ran through the best-effort `step` helper, which logged and swallowed both the thrown read-back check and the returned Result. The shared mirror therefore stayed readable by Ring through node shutdown and the network-bound remote cleanup whenever the guard failed. Call disableSharedIdentityExport() directly and propagate its failure so the wipe stops before anything else is torn down. --- .../to/bitkit/usecases/WipeWalletUseCase.kt | 4 +++- .../bitkit/usecases/WipeWalletUseCaseTest.kt | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt index c42e0bb763..846bf6acd1 100644 --- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -58,7 +58,9 @@ class WipeWalletUseCase @Inject constructor( lightningRepo.setWiping(true) val result = try { runSuspendCatching { - step("disable shared Pubky export") { pubkyRepo.disableSharedIdentityExport() } + // Fail closed: everything after this widens the window in which the shared mirror stays + // readable by Ring, so an unverifiable export-disable must abort the wipe. + pubkyRepo.disableSharedIdentityExport().getOrThrow() stopNode().getOrThrow() cleanupRemote() wipeLocal(walletIndex, resetWalletState).getOrThrow() diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt index 10385b9708..aa951397e7 100644 --- a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -36,6 +36,7 @@ import to.bitkit.services.CoreService import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest import javax.inject.Provider +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs @@ -72,6 +73,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { whenever { lightningRepo.stop() }.thenReturn(Result.success(Unit)) whenever { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever { pubkyRepo.disableSharedIdentityExport() }.thenReturn(Result.success(Unit)) whenever { pubkyRepo.removeBitkitPaymentEndpoints() }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) @@ -127,6 +129,7 @@ class WipeWalletUseCaseTest : BaseUnitTest() { ) inOrder.verify(backupRepo).setWiping(true) inOrder.verify(lightningRepo).setWiping(true) + inOrder.verify(pubkyRepo).disableSharedIdentityExport() inOrder.verify(backupRepo).reset() inOrder.verify(lightningRepo).stop() inOrder.verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any()) @@ -171,6 +174,26 @@ class WipeWalletUseCaseTest : BaseUnitTest() { verify(lightningRepo).stop() } + @Test + fun `invoke should fail before touching anything when shared pubky export cannot be disabled`() = runTest { + val error = RuntimeException("export disable failed") + whenever { pubkyRepo.disableSharedIdentityExport() }.thenReturn(Result.failure(error)) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertEquals(error, result.exceptionOrNull()) + verify(lightningRepo, never()).stop() + verify(pubkyRepo, never()).wipeLocalState() + verify(keychain, never()).wipe() + verify(db, never()).clearAllTables() + assertFalse(onWipeCalled) + assertFalse(onSetWalletExistsStateCalled) + verify(backupRepo).setWiping(false) + } + @Test fun `invoke should fail without wiping and restart observers when node stop fails while running`() = runTest { whenever(lightningRepo.lightningState) From b84bde85e93e2db2d3d3df913601672d6c05e6c6 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 12:33:20 +0200 Subject: [PATCH 09/23] fix: keep the managed secret quarantine marker when its secret survives Session teardown attempted all four credential deletes independently, so a failed PUBKY_SECRET_KEY delete could still be followed by a successful quarantine-marker delete. The suspect secret would then be restored on the next start and re-exported to Ring. Only clear the marker once the secret it guards is actually gone. --- .../to/bitkit/services/PaykitSdkService.kt | 8 ++++++- .../bitkit/services/PaykitSdkServiceTest.kt | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 57e5c4e91b..4dbc1b63f0 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -1429,7 +1429,13 @@ internal fun clearPubkySessionCredentials(deleteKeychainValue: (String) -> Unit) val exportResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) } val sessionResult = runCatching { deleteKeychainValue(Keychain.Key.PAYKIT_SESSION.name) } val localSecretResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_SECRET_KEY.name) } - val quarantineResult = runCatching { deleteKeychainValue(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + // The quarantine marker must never outlive the secret it guards: releasing it while the secret + // survives would let a suspect managed secret be signed back in and re-exported to Ring. + val quarantineResult = if (localSecretResult.isSuccess) { + runCatching { deleteKeychainValue(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + } else { + Result.success(Unit) + } exportResult.getOrThrow() sessionResult.getOrThrow() localSecretResult.getOrThrow() diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index cd7f411855..92ac914c42 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -277,6 +277,27 @@ class PaykitSdkServiceTest { ) } + @Test + fun `session teardown keeps the quarantine marker when the local secret delete fails`() { + val attemptedKeys = mutableListOf() + + assertFailsWith { + clearPubkySessionCredentials { + attemptedKeys += it + if (it == Keychain.Key.PUBKY_SECRET_KEY.name) throw AppError("Delete failed") + } + } + + assertEquals( + listOf( + Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name, + Keychain.Key.PAYKIT_SESSION.name, + Keychain.Key.PUBKY_SECRET_KEY.name, + ), + attemptedKeys, + ) + } + @Test fun `owned session requires and persists its exported local secret`() { val secretKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" From 4c8d7993ea9eb5447b2af34f4fc369444868c86c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 13:39:23 +0200 Subject: [PATCH 10/23] fix: roll back the adopted Pubky Ring identity when adoption is cancelled --- .../java/to/bitkit/repositories/PubkyRepo.kt | 92 ++++++++++--------- .../to/bitkit/repositories/PubkyRepoTest.kt | 39 ++++++++ 2 files changed, 90 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 826cc398b4..4550a9a280 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1117,57 +1117,67 @@ class PubkyRepo @Inject constructor( suspend fun adoptRingIdentity(identity: SharedPubkyIdentity): Result = identityLifecycleMutex.withLock { - var didPersistExternalIdentity = false - runSuspendCatching { - withContext(ioDispatcher) { - ensureServiceInitialized() - val canonicalIdentity = identity.validatedRingIdentity() - val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated() - val currentPublicKey = _publicKey.value - val isAlreadyActive = currentIdentityRef?.pubky == canonicalIdentity.pubky && - currentPublicKey?.let(::wirePubky) == canonicalIdentity.pubky - if (isAlreadyActive) { - return@withContext - } - if ( - currentPublicKey != null || - !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank() - ) { - throw SharedPubkyError.IdentityConflict - } + var shouldRollBackAdoption = false + try { + runSuspendCatching { + withContext(ioDispatcher) { + ensureServiceInitialized() + val canonicalIdentity = identity.validatedRingIdentity() + val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated() + val currentPublicKey = _publicKey.value + val isAlreadyActive = currentIdentityRef?.pubky == canonicalIdentity.pubky && + currentPublicKey?.let(::wirePubky) == canonicalIdentity.pubky + if (isAlreadyActive) { + return@withContext + } + if ( + currentPublicKey != null || + !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank() + ) { + throw SharedPubkyError.IdentityConflict + } - val credential = sharedPubkyDiscovery.readRingCredential(canonicalIdentity.pubky).getOrThrow() - if (!credential.identity.matches(canonicalIdentity)) throw SharedPubkyError.InvalidResponse + val credential = sharedPubkyDiscovery.readRingCredential(canonicalIdentity.pubky).getOrThrow() + if (!credential.identity.matches(canonicalIdentity)) throw SharedPubkyError.InvalidResponse - disableLocalIdentityExport() - val identityRef = canonicalIdentity.toExternalRef() - pubkyStore.update { it.copy(externalIdentityRef = identityRef) } - didPersistExternalIdentity = true - _authState.update { PubkyAuthState.Authenticating } + disableLocalIdentityExport() + val identityRef = canonicalIdentity.toExternalRef() + pubkyStore.update { it.copy(externalIdentityRef = identityRef) } + shouldRollBackAdoption = true + _authState.update { PubkyAuthState.Authenticating } - val publicKey = signInWithExternalCredential(credential) + val publicKey = signInWithExternalCredential(credential) - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } - notifyBackupStateChanged() - _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } - Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG) - loadProfile() - loadContacts() - } - }.onFailure { - if (didPersistExternalIdentity) { - withContext(NonCancellable) { - runSuspendCatching { clearUnavailableExternalIdentityLocked() } - .onFailure { - Logger.error("Failed to roll back Pubky Ring identity connection", it, context = TAG) - } + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + shouldRollBackAdoption = false + Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG) + loadProfile() + loadContacts() } + }.onFailure { + rollBackAdoptedRingIdentityIfNeeded(shouldRollBackAdoption) + restoreAuthStateAfterAuthFlow() } + } catch (e: CancellationException) { + rollBackAdoptedRingIdentityIfNeeded(shouldRollBackAdoption) restoreAuthStateAfterAuthFlow() + throw e } } + private suspend fun rollBackAdoptedRingIdentityIfNeeded(shouldRollBack: Boolean) { + if (!shouldRollBack) return + withContext(NonCancellable) { + runSuspendCatching { clearUnavailableExternalIdentityLocked() } + .onFailure { + Logger.error("Failed to roll back Pubky Ring identity connection", it, context = TAG) + } + } + } + suspend fun validateExternalIdentitySource(): Boolean = identityLifecycleMutex.withLock { validateExternalIdentitySourceLocked() } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 1c81d61169..3144a8a195 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -319,6 +319,45 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { signInExternal(SHARED_SECRET_KEY) } } + @Test + fun `adopt Ring identity rolls back borrowed reference when cancelled before activation`() = test { + val identity = stubRingIdentity() + val signInStarted = CompletableDeferred() + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).doSuspendableAnswer { + signInStarted.complete(Unit) + awaitCancellation() + } + + val adoption = async { sut.adoptRingIdentity(identity) } + signInStarted.await() + adoption.cancelAndJoin() + + assertTrue(adoption.isCancelled) + assertNull(sut.publicKey.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + assertNull(sharedExportEnabled) + verifyBlocking(pubkyService) { clearExternalSessionAccess() } + } + + @Test + fun `adopt Ring identity keeps adopted identity when cancelled after activation`() = test { + val identity = stubRingIdentity() + val profileLoadStarted = CompletableDeferred() + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).doSuspendableAnswer { + profileLoadStarted.complete(Unit) + awaitCancellation() + } + + val adoption = async { sut.adoptRingIdentity(identity) } + profileLoadStarted.await() + adoption.cancelAndJoin() + + assertTrue(adoption.isCancelled) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + } + @Test fun `Ring managed identity reads credential just in time for auth approval`() = test { val identity = stubRingIdentity() From f6ccdde45e1d0309f9f44173d45ec3b81377a09f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 14:12:57 +0200 Subject: [PATCH 11/23] fix: remove published payment endpoints when a Ring source disappears Disconnecting an unavailable Pubky Ring identity dropped the session and cleared the sharing flags with cleanup marked done, so endpoints and the receiver marker published under that identity stayed live and the reconciliation loop had nothing left to retry. Remove them while the borrowed session still works, mirroring sign out, and keep the cleanup pending when the removal fails. --- .../java/to/bitkit/repositories/PubkyRepo.kt | 14 +++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 49 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 4550a9a280..2c20652b75 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1725,8 +1725,20 @@ class PubkyRepo @Inject constructor( ) } + // Published endpoints outlive the borrowed identity, so drop them while the session still + // works and keep the cleanup pending when that fails. + val hadPaykitState = settingsStore.data.first().hasPaykitState() + val endpointCleanupResult = if (hadPaykitState) { + removeBitkitPaymentEndpoints() + .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } + } else { + Result.success(Unit) + } + pubkyService.clearExternalSessionAccess() - clearPublicPaykitSharingState(publicPaykitCleanupPending = false) + clearPublicPaykitSharingState( + publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState, + ) clearAuthenticatedRuntimeState() pubkyStore.reset() notifyBackupStateChanged() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 3144a8a195..e3721bddda 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -394,6 +394,55 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { forgetSessionAccess() } } + @Test + fun `missing Ring source removes published payment endpoints before dropping the session`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + settingsFlow.value = SettingsData( + hasConfirmedPublicPaykitEndpoints = true, + sharesPublicPaykitEndpoints = true, + ) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + clearInvocations(pubkyService) + + assertFalse(sut.validateExternalIdentitySource()) + + inOrder(pubkyService) { + verify(pubkyService).removeBitkitPaymentEndpoints() + verify(pubkyService).clearExternalSessionAccess() + } + assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + } + + @Test + fun `missing Ring source keeps cleanup pending when endpoint removal fails`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever(pubkyService.removeBitkitPaymentEndpoints()).thenAnswer { throw TestAppError("Cleanup failed") } + + assertFalse(sut.validateExternalIdentitySource()) + + assertTrue(settingsFlow.value.publicPaykitCleanupPending) + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertNull(pubkyDataFlow.value.externalIdentityRef) + } + + @Test + fun `missing Ring source skips endpoint removal without Paykit state`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + clearInvocations(pubkyService) + + assertFalse(sut.validateExternalIdentitySource()) + + verifyBlocking(pubkyService, never()) { removeBitkitPaymentEndpoints() } + assertFalse(settingsFlow.value.publicPaykitCleanupPending) + } + @Test fun `source cleanup preserves marker when external session cleanup fails`() = test { val identity = stubRingIdentity() From ac168bb9f670b053b3286f59ad3a699f229490c7 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Thu, 17 Sep 2026 14:16:00 +0200 Subject: [PATCH 12/23] fix: keep the quarantine marker when local state teardown cannot delete its secret Local state teardown deleted the managed secret and its quarantine marker independently, so a failed PUBKY_SECRET_KEY delete during sign out or a wallet wipe could still be followed by a successful marker delete. The surviving suspect secret would then be treated as a managed local identity on the next start, signed in with and re-exported to Ring. Only release the marker once the secret it guards is gone, matching the guarded credential teardown path. --- .../java/to/bitkit/repositories/PubkyRepo.kt | 14 ++++++++++++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 2c20652b75..0acad2f6cd 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1809,8 +1809,18 @@ class PubkyRepo @Inject constructor( private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) { disableLocalIdentityExport() runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } - runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + val localSecretResult = runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + // The quarantine marker must never outlive the secret it guards: releasing it while the secret + // survives would let a suspect managed secret be signed back in and re-exported to Ring. + if (localSecretResult.isSuccess) { + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + } else { + Logger.error( + "Kept managed local Pubky secret quarantine after failed secret deletion", + localSecretResult.exceptionOrNull(), + context = TAG, + ) + } runSuspendCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) } .onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) } notifyBackupStateChanged() diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index e3721bddda..c92d996375 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2181,6 +2181,25 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyStore) { reset() } } + @Test + fun `wipeLocalState should release the quarantine marker once its secret is deleted`() = test { + sut.wipeLocalState() + + verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + } + + @Test + fun `wipeLocalState should keep the quarantine marker when its secret delete fails`() = test { + whenever { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + .thenAnswer { throw TestAppError("Delete failed") } + + sut.wipeLocalState() + + verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + } + @Test fun `loadContacts should use contact label when profile is unavailable`() = test { authenticateForTesting() From 99338a03d688e4d2d34a1a77ce9e482c6b3779ca Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 18 Sep 2026 08:16:47 +0200 Subject: [PATCH 13/23] refactor: remove obsolete ring requester flow --- .../to/bitkit/models/PubkyRingAuthCallback.kt | 36 -- .../java/to/bitkit/repositories/PubkyRepo.kt | 228 ---------- .../to/bitkit/services/PaykitSdkService.kt | 48 -- .../java/to/bitkit/services/PubkyService.kt | 17 - .../java/to/bitkit/viewmodels/AppViewModel.kt | 22 +- .../models/PubkyRingAuthCallbackTest.kt | 31 +- .../to/bitkit/repositories/PubkyRepoTest.kt | 418 ++---------------- .../viewmodels/AppViewModelSendFlowTest.kt | 43 +- 8 files changed, 73 insertions(+), 770 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt index 7f57732ac8..9b8588d872 100644 --- a/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt +++ b/app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt @@ -32,39 +32,3 @@ sealed interface PubkyRingAuthCallback { data class Cancel(override val nonce: String?) : PubkyRingAuthCallback data class Error(val message: String?, override val nonce: String?) : PubkyRingAuthCallback } - -sealed interface PubkyRingAuthCallbackHandlingResult { - data object Ignored : PubkyRingAuthCallbackHandlingResult - data object Handled : PubkyRingAuthCallbackHandlingResult - data class TrustedError(val message: String?) : PubkyRingAuthCallbackHandlingResult -} - -object PubkyRingAuthUrlBuilder { - const val SUCCESS_CALLBACK = "bitkit://pubky-auth/success" - const val CANCEL_CALLBACK = "bitkit://pubky-auth/cancel" - const val ERROR_CALLBACK = "bitkit://pubky-auth/error" - const val SOURCE = "Bitkit" - - fun addCallbacks(authUrl: String, nonce: String? = null): String? { - val uri = Uri.parse(authUrl) - if (uri.scheme.isNullOrBlank()) return null - - return uri.buildUpon() - .appendQueryParameter("x-success", callbackUrl(SUCCESS_CALLBACK, nonce)) - .appendQueryParameter("x-cancel", callbackUrl(CANCEL_CALLBACK, nonce)) - .appendQueryParameter("x-error", callbackUrl(ERROR_CALLBACK, nonce)) - .appendQueryParameter("x-source", SOURCE) - .build() - .toString() - } - - private fun callbackUrl(baseUrl: String, nonce: String?): String { - if (nonce.isNullOrBlank()) return baseUrl - - return Uri.parse(baseUrl) - .buildUpon() - .appendQueryParameter(NONCE_PARAM, nonce) - .build() - .toString() - } -} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0acad2f6cd..890b2a2c90 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -17,11 +17,9 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -54,8 +52,6 @@ import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyProfileData import to.bitkit.models.PubkyProfileLink import to.bitkit.models.PubkyPublicKeyFormat -import to.bitkit.models.PubkyRingAuthCallback -import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PaykitReceiverPaths @@ -63,29 +59,18 @@ import to.bitkit.services.PubkyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.ByteArrayOutputStream -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.math.min -enum class PubkyAuthState { Idle, Authenticating, Authenticated } - -data class PubkyRingAuthRequest( - val authUrl: String, - val callbackNonce: String, -) - sealed class PubkyContactError(message: String) : AppError(message) { data object AlreadyExists : PubkyContactError("Contact already exists") data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact") data object InvalidFormat : PubkyContactError("Invalid pubky key format") } -private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active") data object PubkyAlreadySignedInError : AppError("Already signed in") -private enum class AuthAttemptWaitResult { Approved, Inactive } - @Suppress("TooManyFunctions", "LargeClass", "LongParameterList") @Singleton class PubkyRepo @Inject constructor( @@ -115,12 +100,6 @@ class PubkyRepo @Inject constructor( private val loadContactsMutex = Mutex() private var isServiceInitialized = false - private val _authState = MutableStateFlow(PubkyAuthState.Idle) - private val _activeAuthAttemptId = MutableStateFlow(null) - private val _approvedAuthAttemptId = MutableStateFlow(null) - private val _authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) - val authCancelEvents = _authCancelEvents.asSharedFlow() - private val _profile = MutableStateFlow(null) val profile: StateFlow = _profile.asStateFlow() @@ -272,7 +251,6 @@ class PubkyRepo @Inject constructor( disableLocalIdentityExport() } _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } Logger.info("Restored paykit session for '${redacted(publicKey)}'", context = TAG) } @@ -381,97 +359,6 @@ class PubkyRepo @Inject constructor( // endregion - // region Ring auth flow - - suspend fun startAuthentication(): Result { - val attemptId = UUID.randomUUID().toString() - _activeAuthAttemptId.update { attemptId } - _approvedAuthAttemptId.update { null } - _authState.update { PubkyAuthState.Authenticating } - return try { - runSuspendCatching { - val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() } - PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId) - }.onFailure { - _activeAuthAttemptId.update { null } - restoreAuthStateAfterAuthFlow() - } - } catch (e: CancellationException) { - _activeAuthAttemptId.update { null } - restoreAuthStateAfterAuthFlow() - throw e - } - } - - suspend fun completeAuthentication(): Result = identityLifecycleMutex.withLock { - val attemptId = _activeAuthAttemptId.value - ?: return@withLock Result.failure(PubkyAuthAttemptInactive()) - var shouldRevokeSessionOnFailure = false - try { - val result = runSuspendCatching { - waitForAuthApproval(attemptId) - withContext(ioDispatcher) { - withContext(NonCancellable) { - shouldRevokeSessionOnFailure = true - pubkyService.completeAuth() - } - ensureAuthAttemptActive(attemptId) - val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) { - "No active Pubky session" - } - ensureAuthAttemptActive(attemptId) - - settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } - disableLocalIdentityExport() - notifyBackupStateChanged() - - pk - } - } - - if (result.isFailure) { - revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure) - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - if (_approvedAuthAttemptId.value == attemptId) { - _approvedAuthAttemptId.update { null } - } - restoreAuthStateAfterAuthFlow() - } - - result.onSuccess { pk -> - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - if (_approvedAuthAttemptId.value == attemptId) { - _approvedAuthAttemptId.update { null } - } - _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(shouldRevokeSessionOnFailure) - if (_activeAuthAttemptId.value == attemptId) { - _activeAuthAttemptId.update { null } - } - if (_approvedAuthAttemptId.value == attemptId) { - _approvedAuthAttemptId.update { null } - } - restoreAuthStateAfterAuthFlow() - throw e - } - } - - private suspend fun revokeCompletedAuthSessionIfNeeded(shouldRevokeSession: Boolean) { - if (!shouldRevokeSession) return - discardAbandonedSession() - } - private suspend fun discardAbandonedSession() { val revocationError = runSuspendCatching { withContext(NonCancellable + ioDispatcher) { @@ -492,110 +379,6 @@ class PubkyRepo @Inject constructor( } } - suspend fun cancelAuthentication() { - try { - runSuspendCatching { - withContext(ioDispatcher) { pubkyService.cancelAuth() } - }.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) } - } finally { - endAuthAttempt() - } - } - - fun cancelAuthenticationSync() { - scope.launch { cancelAuthentication() } - } - - suspend fun handleAuthCallback(callback: PubkyRingAuthCallback): PubkyRingAuthCallbackHandlingResult { - if (!isCurrentAuthCallback(callback)) { - return handleInvalidAuthCallback(callback) - } - - return when (callback) { - is PubkyRingAuthCallback.Success -> { - Logger.info("Received Pubky Ring auth success callback", context = TAG) - _activeAuthAttemptId.value?.let { attemptId -> - _approvedAuthAttemptId.update { attemptId } - } - PubkyRingAuthCallbackHandlingResult.Handled - } - is PubkyRingAuthCallback.Cancel -> { - Logger.info("Received Pubky Ring auth cancel callback", context = TAG) - cancelAuthentication() - PubkyRingAuthCallbackHandlingResult.Handled - } - is PubkyRingAuthCallback.Error -> { - Logger.warn("Received Pubky Ring auth error callback", context = TAG) - cancelAuthentication() - PubkyRingAuthCallbackHandlingResult.TrustedError(callback.message) - } - } - } - - private fun handleInvalidAuthCallback( - callback: PubkyRingAuthCallback, - ): PubkyRingAuthCallbackHandlingResult { - if (_activeAuthAttemptId.value == null) { - Logger.warn("Ignoring Pubky Ring auth callback with missing or invalid nonce", context = TAG) - return PubkyRingAuthCallbackHandlingResult.Ignored - } - - return when (callback) { - is PubkyRingAuthCallback.Success -> { - Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context = TAG) - PubkyRingAuthCallbackHandlingResult.Ignored - } - is PubkyRingAuthCallback.Cancel -> { - Logger.warn("Ignoring Pubky Ring auth cancel callback with missing or invalid nonce", context = TAG) - PubkyRingAuthCallbackHandlingResult.Ignored - } - is PubkyRingAuthCallback.Error -> { - Logger.warn("Ignoring Pubky Ring auth error callback with missing or invalid nonce", context = TAG) - PubkyRingAuthCallbackHandlingResult.Ignored - } - } - } - - private fun isCurrentAuthCallback(callback: PubkyRingAuthCallback): Boolean { - val activeAuthAttemptId = _activeAuthAttemptId.value ?: return false - return callback.nonce == activeAuthAttemptId || - (callback is PubkyRingAuthCallback.Success && callback.nonce == null) - } - - private suspend fun waitForAuthApproval(attemptId: String) { - if (_approvedAuthAttemptId.value == attemptId) return - - val result = combine(_approvedAuthAttemptId, _activeAuthAttemptId) { approvedAttemptId, activeAttemptId -> - when { - approvedAttemptId == attemptId -> AuthAttemptWaitResult.Approved - activeAttemptId != attemptId -> AuthAttemptWaitResult.Inactive - else -> null - } - }.first { it != null } - - if (result != AuthAttemptWaitResult.Approved) throw PubkyAuthAttemptInactive() - } - - private fun ensureAuthAttemptActive(attemptId: String?) { - if (attemptId == null) return - if (_activeAuthAttemptId.value == attemptId) return - - throw PubkyAuthAttemptInactive() - } - - private fun endAuthAttempt() { - _activeAuthAttemptId.update { null } - _approvedAuthAttemptId.update { null } - _authCancelEvents.tryEmit(Unit) - restoreAuthStateAfterAuthFlow() - } - - private fun restoreAuthStateAfterAuthFlow() { - _authState.update { if (_publicKey.value == null) PubkyAuthState.Idle else PubkyAuthState.Authenticated } - } - - // endregion - // region Payment endpoints suspend fun removeBitkitPaymentEndpoints(): Result = withContext(ioDispatcher) { @@ -759,7 +542,6 @@ class PubkyRepo @Inject constructor( ) enableLocalIdentityExport(publicKey) _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } _profile.update { createdProfile } cacheMetadata(createdProfile) settingsStore.setPubkyProfileSetupPending(false) @@ -1144,14 +926,12 @@ class PubkyRepo @Inject constructor( val identityRef = canonicalIdentity.toExternalRef() pubkyStore.update { it.copy(externalIdentityRef = identityRef) } shouldRollBackAdoption = true - _authState.update { PubkyAuthState.Authenticating } val publicKey = signInWithExternalCredential(credential) settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } notifyBackupStateChanged() _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } shouldRollBackAdoption = false Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG) loadProfile() @@ -1159,11 +939,9 @@ class PubkyRepo @Inject constructor( } }.onFailure { rollBackAdoptedRingIdentityIfNeeded(shouldRollBackAdoption) - restoreAuthStateAfterAuthFlow() } } catch (e: CancellationException) { rollBackAdoptedRingIdentityIfNeeded(shouldRollBackAdoption) - restoreAuthStateAfterAuthFlow() throw e } } @@ -1269,7 +1047,6 @@ class PubkyRepo @Inject constructor( } _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } var pendingSaved = false try { settingsStore.setPubkyProfileSetupPending(true) @@ -1393,7 +1170,6 @@ class PubkyRepo @Inject constructor( val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() enableLocalIdentityExport(publicKey) _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } } PubkySessionBackupKind.ExternalSession -> { @@ -1403,7 +1179,6 @@ class PubkyRepo @Inject constructor( val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix() disableLocalIdentityExport() _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } } } @@ -1440,7 +1215,6 @@ class PubkyRepo @Inject constructor( } notifyBackupStateChanged() _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } return@withContext true } @@ -1459,7 +1233,6 @@ class PubkyRepo @Inject constructor( notifyBackupStateChanged() _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } true } @@ -1799,7 +1572,6 @@ class PubkyRepo @Inject constructor( _contactsLoadVersion.update { 0L } clearPendingImport() _sessionRestorationFailed.update { false } - _authState.update { PubkyAuthState.Idle } } private fun markContactsLoaded() { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 4dbc1b63f0..fb0d2e8ae2 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -43,7 +43,6 @@ import com.synonym.paykit.PrivateReceivingDetailReservationResponse import com.synonym.paykit.PrivateReceivingDetailReservationResponseKind import com.synonym.paykit.PrivateStreamCounterpartyIntakeReport import com.synonym.paykit.PubkyAuthCompanionClaim -import com.synonym.paykit.PubkyAuthRequest import com.synonym.paykit.PubkyClientConfig import com.synonym.paykit.PubkyLocalSecretKey import com.synonym.paykit.PubkyProfile @@ -191,7 +190,6 @@ class PaykitSdkService @Inject constructor( private var isSetup = CompletableDeferred() private var setupFailed = false private var sdk: PaykitSdk? = null - private var activeAuthRequest: PubkyAuthRequest? = null private val _backupStateVersion = MutableStateFlow(0L) val backupStateVersion: StateFlow = _backupStateVersion.asStateFlow() private var sdkFactory: () -> PaykitSdk = { @@ -416,49 +414,6 @@ class PaykitSdkService @Inject constructor( return result } - suspend fun startAuth(): String { - isSetup.await() - return operationMutex.withLock { - val request = bootstrap().startSignInAuth(requiredCapabilities()) - activeAuthRequest = request - request.authorizationUrl() - } - } - - suspend fun completeAuth(): PubkySessionBootstrapResult { - isSetup.await() - return operationMutex.withLock { - val request = requireNotNull(activeAuthRequest) { "No active Pubky auth request" } - val previousPublicKey = currentSdkStatePublicKeyLocked() - var completed = false - try { - request.complete( - localSecretKey = null, - receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(), - requiredCapabilities = requiredCapabilities(), - ).also { - activateBootstrapResult( - result = it, - previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = false, - ) - notifyBackupStateChanged() - completed = true - } - } finally { - activeAuthRequest = null - if (!completed) resetRuntime() - } - } - } - - suspend fun cancelAuth() { - isSetup.await() - operationMutex.withLock { - activeAuthRequest = null - } - } - suspend fun approveAuth( authUrl: String, expectedCapabilities: String, @@ -964,7 +919,6 @@ class PaykitSdkService @Inject constructor( suspend fun forgetSessionAccess() { isSetup.await() operationMutex.withLock { - activeAuthRequest = null try { withStateRevisionTracking { handle -> handle.forgetSessionAccess() @@ -991,7 +945,6 @@ class PaykitSdkService @Inject constructor( check(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) == managedSecretKeyHex) { "Managed local Pubky secret changed during external session cleanup" } - activeAuthRequest = null resetRuntime() notifyBackupStateChanged() } @@ -1005,7 +958,6 @@ class PaykitSdkService @Inject constructor( private suspend fun clearStateLocked() { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) - activeAuthRequest = null resetRuntime() notifyBackupStateChanged() } diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index 8f33f58247..70da40131a 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -119,23 +119,6 @@ class PubkyService @Inject constructor( // endregion - // region Auth flow (Ring) - - suspend fun startAuth(): String = ServiceQueue.CORE.background { - paykitSdkService.startAuth() - } - - suspend fun completeAuth(): Unit = ServiceQueue.CORE.background { - paykitSdkService.completeAuth() - Unit - } - - suspend fun cancelAuth() = ServiceQueue.CORE.background { - paykitSdkService.cancelAuth() - } - - // endregion - // region Auth approval suspend fun parseAuthUrl(url: String) = ServiceQueue.CORE.background { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c87b10fad0..b281db1ad1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -121,7 +121,6 @@ import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyPublicKeyFormat import to.bitkit.models.PubkyRingAuthCallback -import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.SamRockSetupRequest import to.bitkit.models.SendFailureDetails import to.bitkit.models.Suggestion @@ -5476,11 +5475,7 @@ class AppViewModel @Inject constructor( return@launch } - PubkyRingAuthCallback.parse(uri)?.let { - if (!isPaykitEnabled.value) return@launch - handlePubkyRingAuthCallback(it) - return@launch - } + if (PubkyRingAuthCallback.parse(uri) != null) return@launch if (PubkyAuthRequest.isProtocolUrl(value)) { launchScan( @@ -5550,21 +5545,6 @@ class AppViewModel @Inject constructor( return true } - private suspend fun handlePubkyRingAuthCallback(callback: PubkyRingAuthCallback) { - when (val result = pubkyRepo.handleAuthCallback(callback)) { - is PubkyRingAuthCallbackHandlingResult.TrustedError -> { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = result.message ?: context.getString(R.string.other__qr_error_text), - ) - } - PubkyRingAuthCallbackHandlingResult.Handled, - PubkyRingAuthCallbackHandlingResult.Ignored, - -> Unit - } - } - // TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70 private fun String.removeLightningSchemes(): String = LIGHTNING_SCHEME_PATTERNS.fold(this) { acc, regex -> acc.replace(regex, "") diff --git a/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt index 8e753f7b4c..3866aaada0 100644 --- a/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt +++ b/app/src/test/java/to/bitkit/models/PubkyRingAuthCallbackTest.kt @@ -11,32 +11,6 @@ import kotlin.test.assertNull @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class PubkyRingAuthCallbackTest { - @Test - fun `addCallbacks adds Ring x-callback params`() { - val url = checkNotNull( - PubkyRingAuthUrlBuilder.addCallbacks( - authUrl = "pubkyauth://auth?relay=https%3A%2F%2Frelay.example", - nonce = "12345678-1234-1234-1234-123456789ABC", - ), - ) { "Auth URL should be valid" } - val uri = url.toUri() - - assertEquals("https://relay.example", uri.getQueryParameter("relay")) - assertEquals( - "bitkit://pubky-auth/success?nonce=12345678-1234-1234-1234-123456789ABC", - uri.getQueryParameter("x-success"), - ) - assertEquals( - "bitkit://pubky-auth/cancel?nonce=12345678-1234-1234-1234-123456789ABC", - uri.getQueryParameter("x-cancel"), - ) - assertEquals( - "bitkit://pubky-auth/error?nonce=12345678-1234-1234-1234-123456789ABC", - uri.getQueryParameter("x-error"), - ) - assertEquals(PubkyRingAuthUrlBuilder.SOURCE, uri.getQueryParameter("x-source")) - } - @Test fun `parse returns success cancel and error callbacks`() { assertEquals( @@ -73,5 +47,10 @@ class PubkyRingAuthCallbackTest { fun `parse rejects other deeplinks`() { assertNull(PubkyRingAuthCallback.parse("bitkit://wallet/success".toUri())) assertNull(PubkyRingAuthCallback.parse("https://pubky-auth/success".toUri())) + assertNull(PubkyRingAuthCallback.parse("bitkit://pubky-auth/setup".toUri())) + assertNull(PubkyRingAuthCallback.parse("bitkit://pubky-auth/unknown".toUri())) + assertNull(PubkyRingAuthCallback.parse("bitkit://pubky-auth/success/".toUri())) + assertNull(PubkyRingAuthCallback.parse("BITKIT://pubky-auth/success".toUri())) + assertNull(PubkyRingAuthCallback.parse("bitkit://PUBKY-AUTH/success".toUri())) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index c92d996375..7bdbf85a83 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -59,8 +59,6 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyAuthRequest import to.bitkit.models.PubkyProfile -import to.bitkit.models.PubkyRingAuthCallback -import to.bitkit.models.PubkyRingAuthCallbackHandlingResult import to.bitkit.models.PubkySessionBackupKind import to.bitkit.models.PubkySessionBackupV1 import to.bitkit.services.PubkyRingAuthTimeoutError @@ -525,107 +523,6 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { forgetSessionAccess() } } - @Test - fun `startAuthentication should return auth uri on success`() = test { - val authUri = "pubky://auth?capabilities=..." - whenever(pubkyService.startAuth()).thenReturn(authUri) - - val result = sut.startAuthentication() - - assertTrue(result.isSuccess) - assertEquals(authUri, result.getOrNull()?.authUrl) - assertNotNull(result.getOrNull()?.callbackNonce) - } - - @Test - fun `startAuthentication should reset state on failure`() = test { - whenever(pubkyService.startAuth()).thenAnswer { throw TestAppError("Auth failed") } - - val result = sut.startAuthentication() - - assertTrue(result.isFailure) - sut.isAuthenticated.test(timeout = 500.milliseconds) { - assertFalse(awaitItem()) - } - } - - @Test - fun `completeAuthentication should save session and update state`() = test { - val testSecret = "session_secret" - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - - val pubkyProfile = createPubkyProfile(name = "User") - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) - - val authRequest = startAuthForTesting() - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertTrue(result.isSuccess) - assertEquals(VALID_SELF_KEY, sut.publicKey.value) - assertTrue(sut.isAuthenticated.value) - } - - @Test - fun `completeAuthentication should load contacts automatically`() = test { - val testSecret = "session_secret" - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - val pubkyProfile = createPubkyProfile(name = "User") - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = pubkyProfile)) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(testSecret) - - val authRequest = startAuthForTesting() - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertTrue(result.isSuccess) - verify(pubkyService).contactRecords() - } - - @Test - fun `completeAuthentication should reset state on failure`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenAnswer { throw TestAppError("Failed") } - - val authRequest = startAuthForTesting() - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertTrue(result.isFailure) - assertFalse(sut.isAuthenticated.value) - assertNull(sut.publicKey.value) - verifyBlocking(pubkyService) { signOut() } - } - - @Test - fun `completeAuthentication should fail when auth attempt inactive`() = test { - val result = sut.completeAuthentication() - - assertTrue(result.isFailure) - verifyBlocking(pubkyService, never()) { completeAuth() } - } - - @Test - fun `completeAuthentication should fail when auth is canceled before approval`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - sut.startAuthentication() - - val result = async { sut.completeAuthentication() } - sut.cancelAuthentication() - - assertTrue(result.await().isFailure) - verifyBlocking(pubkyService, never()) { completeAuth() } - } - @Test fun `approveAuth should forward requested capabilities`() = test { val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw" @@ -670,270 +567,6 @@ class PubkyRepoTest : BaseUnitTest() { } } - @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) - val result = sut.completeAuthentication() - - assertTrue(result.isFailure) - verifyBlocking(pubkyService) { signOut() } - 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) - assertTrue(settingsFlow.value.publicPaykitCleanupPending) - } - - @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 `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") - sut.startAuthentication() - - sut.cancelAuthentication() - - assertFalse(sut.isAuthenticated.value) - } - - @Test - fun `cancelAuthentication should keep restored profile authenticated`() = test { - authenticateForTesting() - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - sut.startAuthentication() - - sut.cancelAuthentication() - - assertTrue(sut.isAuthenticated.value) - assertNotNull(sut.publicKey.value) - } - - @Test - fun `handleAuthCallback should reject invalid success nonce`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - sut.startAuthentication() - - val result = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = "invalid")) - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should trust missing success nonce for active auth`() = test { - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) - sut.startAuthentication() - - val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = null)) - val result = sut.completeAuthentication() - - assertEquals(PubkyRingAuthCallbackHandlingResult.Handled, callbackResult) - assertTrue(result.isSuccess) - assertTrue(sut.isAuthenticated.value) - } - - @Test - fun `handleAuthCallback should ignore invalid cancel nonce`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - sut.startAuthentication() - - val result = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid")) - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result) - assertFalse(sut.isAuthenticated.value) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should ignore invalid error nonce`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - sut.startAuthentication() - - val result = sut.handleAuthCallback( - PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"), - ) - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should keep active auth after missing cancel nonce`() = test { - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) - val authRequest = startAuthForTesting() - - val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = null)) - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) - assertTrue(result.isSuccess) - assertTrue(sut.isAuthenticated.value) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should keep active auth after missing error nonce`() = test { - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) - val authRequest = startAuthForTesting() - - val callbackResult = sut.handleAuthCallback( - PubkyRingAuthCallback.Error(message = "Forged error", nonce = null), - ) - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) - assertTrue(result.isSuccess) - assertTrue(sut.isAuthenticated.value) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should keep active auth after invalid cancel nonce`() = test { - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) - val authRequest = startAuthForTesting() - - val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid")) - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) - assertTrue(result.isSuccess) - assertTrue(sut.isAuthenticated.value) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should keep active auth after invalid error nonce`() = test { - val testPk = VALID_SELF_KEY.removePrefix("pubky") - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(testPk) - whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)) - .thenReturn(createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile())) - val authRequest = startAuthForTesting() - - val callbackResult = sut.handleAuthCallback( - PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"), - ) - approveAuthForTesting(authRequest) - val result = sut.completeAuthentication() - - assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult) - assertTrue(result.isSuccess) - assertTrue(sut.isAuthenticated.value) - verifyBlocking(pubkyService, never()) { cancelAuth() } - } - - @Test - fun `handleAuthCallback should trust matching error nonce`() = test { - whenever(pubkyService.startAuth()).thenReturn("auth_uri") - val authRequest = checkNotNull(sut.startAuthentication().getOrNull()) { - "Auth request should be returned" - } - - val result = sut.handleAuthCallback( - PubkyRingAuthCallback.Error(message = "Ring failed", nonce = authRequest.callbackNonce), - ) - - assertEquals(PubkyRingAuthCallbackHandlingResult.TrustedError("Ring failed"), result) - verifyBlocking(pubkyService) { cancelAuth() } - } - @Test fun `createIdentity should forget session when incomplete session revocation fails`() = test { val httpClient = identityHttpClient() @@ -961,6 +594,27 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService) { forgetSessionAccess() } } + @Test + fun `createIdentity clears credentials when abandoned session cleanup fails`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubSignupKeys() + whenever(pubkyService.signUp("secret", "test-homeserver", "test-code")).thenReturn(Unit) + whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } + whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") } + whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Cleanup error") } + + val result = sut.createIdentity("Test", "", emptyList(), emptyList(), null) + httpClient.close() + + assertEquals("Publish failed", result.exceptionOrNull()?.message) + assertNull(sut.publicKey.value) + 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 fun `createIdentity clears stale pending signup without a session`() = test { profileSetupPending.value = true @@ -1933,15 +1587,13 @@ class PubkyRepoTest : BaseUnitTest() { secret = oldSecret, profileName = "Initial Old", ) - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(newPublicKey) + whenever(pubkyService.importExternalSession(newSecret)).thenReturn(newPublicKey) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(newSecret) whenever(pubkyService.contactRecords()).thenReturn(emptyList()) val staleProfile = createPubkyProfile(name = "Stale Old") whenever(pubkyService.resolveContactProfile(oldPublicKey.ensurePubkyPrefixForTest(), true)).thenAnswer { runBlocking { - approveAuthForTesting(startAuthForTesting()) - sut.completeAuthentication() + sut.initialize() } createResolution(oldPublicKey.ensurePubkyPrefixForTest(), pubkyProfile = staleProfile) } @@ -1976,16 +1628,14 @@ class PubkyRepoTest : BaseUnitTest() { ) sut.addContact(existingContact.publicKey, existingProfile = existingContact) - whenever(pubkyService.completeAuth()).thenReturn(Unit) - whenever(pubkyService.currentPublicKey()).thenReturn(newPublicKey) + whenever(pubkyService.importExternalSession(newSecret)).thenReturn(newPublicKey) val newProfile = createPubkyProfile(name = "New User") whenever(pubkyService.resolveContactProfile(newPublicKey.ensurePubkyPrefixForTest(), true)) .thenReturn(createResolution(newPublicKey.ensurePubkyPrefixForTest(), pubkyProfile = newProfile)) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(oldSecret) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(newSecret) whenever(pubkyService.contactRecords()).thenAnswer { runBlocking { - approveAuthForTesting(startAuthForTesting()) - sut.completeAuthentication() + sut.initialize() } listOf(createContactRecord(staleContactKey, profile = createPaykitProfile("Stale Contact"))) } @@ -2269,7 +1919,7 @@ class PubkyRepoTest : BaseUnitTest() { profileName: String = "Test", ) { val prefixedPublicKey = publicKey.ensurePubkyPrefixForTest() - whenever { pubkyService.completeAuth() }.thenReturn(Unit) + whenever { pubkyService.importExternalSession(secret) }.thenReturn(publicKey) whenever { pubkyService.currentPublicKey() }.thenReturn(publicKey) val pubkyProfile = createPubkyProfile(name = profileName) whenever { pubkyService.resolveContactProfile(prefixedPublicKey, true) } @@ -2277,19 +1927,7 @@ class PubkyRepoTest : BaseUnitTest() { whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(secret) whenever { pubkyService.contactRecords() }.thenReturn(emptyList()) - approveAuthForTesting(startAuthForTesting()) - sut.completeAuthentication() - } - - private suspend fun startAuthForTesting(authUri: String = "auth_uri"): PubkyRingAuthRequest { - whenever { pubkyService.startAuth() }.thenReturn(authUri) - return checkNotNull(sut.startAuthentication().getOrNull()) { - "Auth request should be returned" - } - } - - private suspend fun approveAuthForTesting(authRequest: PubkyRingAuthRequest) { - sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = authRequest.callbackNonce)) + sut.initialize() } private fun identityHttpClient() = HttpClient( diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f316194c7a..a668af11f8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -2745,13 +2745,48 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `pubky ring callback deeplink is ignored when Paykit UI is disabled`() = test { - val intent = Intent(Intent.ACTION_VIEW, "bitkit://pubky-auth/success".toUri()) + fun `legacy Ring callbacks are consumed without changing payment state or showing errors`() = test { + advanceUntilIdle() + sut.setIsAuthenticated(true) + val paymentState = SendUiState(address = "existing-payment", amount = 1_000u) + setSendState(paymentState) + clearInvocations(coreService, pubkyRepo, toastManager) - sut.handleDeeplinkIntent(intent) + for (enabled in listOf(false, true)) { + isPaykitEnabled.value = enabled + advanceUntilIdle() + for (path in listOf("success", "cancel", "error")) { + for (query in listOf("", "?nonce=old-attempt&errorMessage=Denied", "?nonce")) { + val callback = "bitkit://pubky-auth/$path$query" + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, callback.toUri())) + advanceUntilIdle() + + assertEquals(paymentState, sut.sendUiState.value, callback) + assertNull(sut.currentSheet.value, callback) + } + } + } + + verify(coreService, never()).decode(any()) + verify(pubkyRepo, never()).hasSecretKey() + verify(pubkyRepo, never()).hasIdentity() + verify(toastManager, never()).enqueue(any()) + } + + @Test + fun `unrecognized Ring callback paths still reach the scanner`() = test { advanceUntilIdle() + sut.setIsAuthenticated(true) + + for (path in listOf("setup", "unknown", "success/")) { + val deeplink = "bitkit://pubky-auth/$path" + whenever(coreService.decode(deeplink)).thenThrow(IllegalStateException("Unsupported URI")) - verify(pubkyRepo, never()).handleAuthCallback(any()) + sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, deeplink.toUri())) + advanceUntilIdle() + + verify(coreService).decode(deeplink) + } } @Test From 913e1029c42b5a4a8a25bc24dce5ae9b2102c972 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 18 Sep 2026 08:30:50 +0200 Subject: [PATCH 14/23] refactor: unify shared pubky identity records --- .../main/java/to/bitkit/data/PubkyStore.kt | 4 +- .../data/sharing/SharedPubkyContract.kt | 24 ++---- .../java/to/bitkit/repositories/PubkyRepo.kt | 36 ++------ .../ui/screens/profile/PubkyChoiceScreen.kt | 9 +- .../screens/profile/PubkyChoiceViewModel.kt | 16 +--- .../serializers/PubkyStoreSerializerTest.kt | 65 ++++++++++++++ .../data/sharing/SharedPubkyContractTest.kt | 27 +++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 86 ++++++++++++++++--- .../profile/PubkyChoiceViewModelTest.kt | 20 +++++ 9 files changed, 210 insertions(+), 77 deletions(-) create mode 100644 app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 7eb5c9053b..3898d85093 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -7,7 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import to.bitkit.data.serializers.PubkyStoreSerializer -import to.bitkit.data.sharing.ExternalPubkyIdentityRef +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.models.PubkyProfileData import javax.inject.Inject import javax.inject.Singleton @@ -39,5 +39,5 @@ data class PubkyStoreData( val cachedName: String? = null, val cachedImageUri: String? = null, val contactProfileOverrides: Map = emptyMap(), - val externalIdentityRef: ExternalPubkyIdentityRef? = null, + val externalIdentityRef: SharedPubkyIdentity? = null, ) diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt index a5f49a13b2..5cf2d58d9d 100644 --- a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt @@ -67,26 +67,13 @@ object SharedPubkyContract { } } -data class SharedPubkyIdentity( - val protocolVersion: Int, - val sourcePackage: String, - val pubky: String, -) - -class SharedPubkyCredential( - val identity: SharedPubkyIdentity, - secretKeyHex: String, -) { - val secretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) -} - @Serializable -data class ExternalPubkyIdentityRef( +data class SharedPubkyIdentity( val protocolVersion: Int, val sourcePackage: String, val pubky: String, ) { - fun validated(): ExternalPubkyIdentityRef { + fun validated(): SharedPubkyIdentity { if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) { throw SharedPubkyError.UnsupportedVersion(protocolVersion) } @@ -97,6 +84,13 @@ data class ExternalPubkyIdentityRef( } } +class SharedPubkyCredential( + val identity: SharedPubkyIdentity, + secretKeyHex: String, +) { + val secretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) +} + sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) { data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable") class UntrustedSource(source: String) : SharedPubkyError("Untrusted Pubky identity source '$source'") diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 890b2a2c90..cfdfa02688 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -36,7 +36,6 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain import to.bitkit.data.paykitDisabled -import to.bitkit.data.sharing.ExternalPubkyIdentityRef import to.bitkit.data.sharing.SharedPubkyContract import to.bitkit.data.sharing.SharedPubkyCredential import to.bitkit.data.sharing.SharedPubkyDiscovery @@ -266,7 +265,7 @@ class PubkyRepo @Inject constructor( private suspend fun resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, - externalIdentityRef: ExternalPubkyIdentityRef?, + externalIdentityRef: SharedPubkyIdentity?, ): InitResult = withContext(ioDispatcher) { if (externalIdentityRef != null) { return@withContext resolveExternalSession( @@ -294,7 +293,7 @@ class PubkyRepo @Inject constructor( private suspend fun resolveExternalSession( savedSessionSecret: String?, - identityRef: ExternalPubkyIdentityRef, + identityRef: SharedPubkyIdentity, ): InitResult = withContext(ioDispatcher) { val sourceIdentity = sharedPubkyDiscovery.discoverRingIdentities().getOrElse { return@withContext InitResult.ExternalSourceUnavailable @@ -904,7 +903,7 @@ class PubkyRepo @Inject constructor( runSuspendCatching { withContext(ioDispatcher) { ensureServiceInitialized() - val canonicalIdentity = identity.validatedRingIdentity() + val canonicalIdentity = identity.validated() val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated() val currentPublicKey = _publicKey.value val isAlreadyActive = currentIdentityRef?.pubky == canonicalIdentity.pubky && @@ -923,8 +922,7 @@ class PubkyRepo @Inject constructor( if (!credential.identity.matches(canonicalIdentity)) throw SharedPubkyError.InvalidResponse disableLocalIdentityExport() - val identityRef = canonicalIdentity.toExternalRef() - pubkyStore.update { it.copy(externalIdentityRef = identityRef) } + pubkyStore.update { it.copy(externalIdentityRef = canonicalIdentity) } shouldRollBackAdoption = true val publicKey = signInWithExternalCredential(credential) @@ -1408,7 +1406,7 @@ class PubkyRepo @Inject constructor( private suspend fun signInWithExternalCredential(credential: SharedPubkyCredential): String = withContext(ioDispatcher) { - val identity = credential.identity.validatedRingIdentity() + val identity = credential.identity.validated() val derivedWirePubky = wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex)) if (derivedWirePubky != identity.pubky) throw SharedPubkyError.InvalidResponse @@ -1638,35 +1636,13 @@ class PubkyRepo @Inject constructor( private fun wirePubky(value: String): String = SharedPubkyContract.canonicalPubky(value) - private fun SharedPubkyIdentity.validatedRingIdentity(): SharedPubkyIdentity { - if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) { - throw SharedPubkyError.UnsupportedVersion(protocolVersion) - } - if (sourcePackage != SharedPubkyContract.RING_SOURCE) { - throw SharedPubkyError.UntrustedSource(sourcePackage) - } - return copy(pubky = SharedPubkyContract.requireWirePubky(pubky)) - } - - private fun SharedPubkyIdentity.toExternalRef() = ExternalPubkyIdentityRef( - protocolVersion = protocolVersion, - sourcePackage = sourcePackage, - pubky = SharedPubkyContract.requireWirePubky(pubky), - ) - - private fun SharedPubkyIdentity.matches(identityRef: ExternalPubkyIdentityRef): Boolean = - protocolVersion == identityRef.protocolVersion && - sourcePackage == identityRef.sourcePackage && - SharedPubkyContract.requireWirePubky(pubky) == - SharedPubkyContract.requireWirePubky(identityRef.pubky) - private fun SharedPubkyIdentity.matches(other: SharedPubkyIdentity): Boolean = protocolVersion == other.protocolVersion && sourcePackage == other.sourcePackage && SharedPubkyContract.requireWirePubky(pubky) == SharedPubkyContract.requireWirePubky(other.pubky) - private fun SharedPubkyCredential.matches(identityRef: ExternalPubkyIdentityRef): Boolean = + private fun SharedPubkyCredential.matches(identityRef: SharedPubkyIdentity): Boolean = identity.matches(identityRef) private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index c1d9224116..fa6917851a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB @@ -312,9 +313,11 @@ private fun Preview() { uiState = PubkyChoiceUiState( identities = persistentListOf( SharedPubkyChoice( - protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, - sourcePackage = SharedPubkyContract.RING_SOURCE, - pubky = pubky, + identity = SharedPubkyIdentity( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = pubky, + ), profile = PubkyProfile.forDisplay( publicKey = SharedPubkyContract.toBitkitPubky(pubky), name = "Satoshi Nakamoto", diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 00624aaaee..8d7de68db3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -60,9 +60,7 @@ class PubkyChoiceViewModel @Inject constructor( .getOrNull() ?: PubkyProfile.placeholder(bitkitPubky) SharedPubkyChoice( - protocolVersion = identity.protocolVersion, - sourcePackage = identity.sourcePackage, - pubky = identity.pubky, + identity = identity, profile = profile, ) } @@ -84,7 +82,7 @@ class PubkyChoiceViewModel @Inject constructor( _uiState.update { it.copy(selectedPubky = choice.pubky) } viewModelScope.launch { - pubkyRepo.adoptRingIdentity(choice.toIdentity()) + pubkyRepo.adoptRingIdentity(choice.identity) .onSuccess { pubkyRepo.prepareImport() .onSuccess { @@ -126,16 +124,10 @@ data class PubkyChoiceUiState( @Stable data class SharedPubkyChoice( - val protocolVersion: Int, - val sourcePackage: String, - val pubky: String, + val identity: SharedPubkyIdentity, val profile: PubkyProfile, ) { - fun toIdentity() = SharedPubkyIdentity( - protocolVersion = protocolVersion, - sourcePackage = sourcePackage, - pubky = pubky, - ) + val pubky: String get() = identity.pubky } sealed interface PubkyChoiceEffect { diff --git a/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt new file mode 100644 index 0000000000..6e54c7d7f8 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt @@ -0,0 +1,65 @@ +package to.bitkit.data.serializers + +import org.junit.Test +import to.bitkit.data.sharing.SharedPubkyIdentity +import to.bitkit.di.json +import to.bitkit.test.BaseUnitTest +import java.io.ByteArrayOutputStream +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class PubkyStoreSerializerTest : BaseUnitTest() { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + + @Test + fun `persisted reference retains its existing JSON fields and metadata`() = test { + val storedJson = """ + { + "cachedName": "Ring user", + "cachedImageUri": "pubky://avatar", + "contactProfileOverrides": {}, + "externalIdentityRef": { + "protocolVersion": 1, + "sourcePackage": "app.pubkyring", + "pubky": "$WIRE_PUBKY" + } + } + """.trimIndent() + + val restored = PubkyStoreSerializer.readFrom(storedJson.byteInputStream()) + val output = ByteArrayOutputStream() + PubkyStoreSerializer.writeTo(restored, output) + + assertEquals(SharedPubkyIdentity(1, "app.pubkyring", WIRE_PUBKY), restored.externalIdentityRef) + assertEquals(json.parseToJsonElement(storedJson), json.parseToJsonElement(output.toByteArray().decodeToString())) + } + + @Test + fun `invalid persisted references survive decoding for explicit recovery`() = test { + val invalidReferences = listOf( + SharedPubkyIdentity(2, "app.pubkyring", WIRE_PUBKY), + SharedPubkyIdentity(1, "other.app", WIRE_PUBKY), + SharedPubkyIdentity(1, "app.pubkyring", "invalid"), + ) + for (identity in invalidReferences) { + val storedJson = """ + { + "cachedName": "Preserved metadata", + "externalIdentityRef": { + "protocolVersion": ${identity.protocolVersion}, + "sourcePackage": "${identity.sourcePackage}", + "pubky": "${identity.pubky}" + } + } + """.trimIndent() + + val restored = PubkyStoreSerializer.readFrom(storedJson.byteInputStream()) + + assertNotNull(restored.externalIdentityRef) + assertEquals(identity, restored.externalIdentityRef) + assertEquals("Preserved metadata", restored.cachedName) + } + } +} diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt index d4d09f03b4..50911705d0 100644 --- a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt @@ -65,18 +65,41 @@ class SharedPubkyContractTest { @Test fun `external reference rejects unsupported sources and versions`() { assertFailsWith { - ExternalPubkyIdentityRef( + SharedPubkyIdentity( protocolVersion = 2, sourcePackage = SharedPubkyContract.RING_SOURCE, pubky = WIRE_PUBKY, ).validated() } assertFailsWith { - ExternalPubkyIdentityRef( + SharedPubkyIdentity( protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, sourcePackage = "other.app", pubky = WIRE_PUBKY, ).validated() } } + + @Test + fun `identity validates version then source then strict wire key`() { + val invalidIdentity = SharedPubkyIdentity(2, "other.app", "invalid") + + assertFailsWith { invalidIdentity.validated() } + assertFailsWith { + invalidIdentity.copy(protocolVersion = SharedPubkyContract.PROTOCOL_VERSION).validated() + } + assertFailsWith { + invalidIdentity.copy( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + ).validated() + } + assertFailsWith { + SharedPubkyIdentity( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = "pubky$WIRE_PUBKY", + ).validated() + } + } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7bdbf85a83..b15df4f4a1 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -50,10 +50,11 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain -import to.bitkit.data.sharing.ExternalPubkyIdentityRef +import to.bitkit.data.serializers.PubkyStoreSerializer import to.bitkit.data.sharing.SharedPubkyContract import to.bitkit.data.sharing.SharedPubkyCredential import to.bitkit.data.sharing.SharedPubkyDiscovery +import to.bitkit.data.sharing.SharedPubkyError import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.ext.runSuspendCatching import to.bitkit.models.PubkyAuthClaim @@ -297,7 +298,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isSuccess) assertEquals(VALID_SELF_KEY, sut.publicKey.value) - assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) verifyBlocking(pubkyService) { signInExternal(SHARED_SECRET_KEY) } verifyBlocking(keychain, never()) { upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY) @@ -317,6 +318,29 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { signInExternal(SHARED_SECRET_KEY) } } + @Test + fun `adoption preserves strict credential matching and validation order`() = test { + val identity = stubRingIdentity() + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)).thenReturn( + Result.success(SharedPubkyCredential(identity.copy(pubky = "invalid"), SHARED_SECRET_KEY)), + ) + + val malformedKeyResult = sut.adoptRingIdentity(identity) + + assertTrue(malformedKeyResult.exceptionOrNull() is IllegalArgumentException) + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)).thenReturn( + Result.success( + SharedPubkyCredential(identity.copy(protocolVersion = 2, pubky = "invalid"), SHARED_SECRET_KEY), + ), + ) + + val wrongVersionResult = sut.adoptRingIdentity(identity) + + assertEquals(SharedPubkyError.InvalidResponse, wrongVersionResult.exceptionOrNull()) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { signInExternal(any()) } + } + @Test fun `adopt Ring identity rolls back borrowed reference when cancelled before activation`() = test { val identity = stubRingIdentity() @@ -352,7 +376,7 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(adoption.isCancelled) assertEquals(VALID_SELF_KEY, sut.publicKey.value) - assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } } @@ -453,7 +477,7 @@ class PubkyRepoTest : BaseUnitTest() { val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() assertEquals(cleanupError.message, thrown?.message) - assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) verify(pubkyStore, never()).reset() } @@ -469,7 +493,7 @@ class PubkyRepoTest : BaseUnitTest() { val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() assertEquals(resetError.message, thrown?.message) - assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) doAnswer { pubkyDataFlow.value = PubkyStoreData() @@ -1340,10 +1364,52 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } + @Test + fun `invalid persisted reference quarantines conflicting local secret during initialization`() = test { + val storedJson = """ + { + "cachedName": "Ring user", + "externalIdentityRef": { + "protocolVersion": 1, + "sourcePackage": "app.pubkyring", + "pubky": "invalid" + } + } + """.trimIndent() + pubkyDataFlow.value = PubkyStoreSerializer.readFrom(storedJson.byteInputStream()) + assertNotNull(pubkyDataFlow.value.externalIdentityRef) + var quarantineMarker: String? = null + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed_secret") + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)) + .thenAnswer { quarantineMarker } + whenever(keychain.upsertString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, "1")) + .thenAnswer { + quarantineMarker = "1" + Unit + } + clearInvocations(pubkyService, pubkyStore, keychain) + + sut.initialize() + + assertEquals("1", quarantineMarker) + assertNull(pubkyDataFlow.value.externalIdentityRef) + assertNull(sut.publicKey.value) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + verifyBlocking(pubkyService, never()) { importSession(any()) } + verifyBlocking(pubkyService, never()) { importExternalSession(any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signInExternal(any()) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + @Test fun `initialize retries quarantined external cleanup before removing its marker`() = test { val identity = stubRingIdentity() - pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity.toExternalRefForTest()) + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session") whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed_secret") whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)).thenReturn("1") @@ -1364,7 +1430,7 @@ class PubkyRepoTest : BaseUnitTest() { @Test fun `initialize preserves external marker when source exists but sign in cannot recover`() = test { val identity = stubRingIdentity() - val identityRef = identity.toExternalRefForTest() + val identityRef = identity pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identityRef) whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("stale_session") whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) @@ -2026,9 +2092,3 @@ private class TestAppError(message: String) : AppError(message) private fun String.ensurePubkyPrefixForTest(): String = if (startsWith("pubky")) this else "pubky$this" - -private fun SharedPubkyIdentity.toExternalRefForTest() = ExternalPubkyIdentityRef( - protocolVersion = protocolVersion, - sourcePackage = sourcePackage, - pubky = pubky, -) diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index 765db672d9..c7f24ad3d3 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -58,10 +58,30 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { assertFalse(sut.uiState.value.isDiscovering) assertEquals(1, sut.uiState.value.identities.size) + assertEquals(identity, sut.uiState.value.identities.single().identity) assertEquals(WIRE_PUBKY, sut.uiState.value.identities.single().pubky) assertEquals(profile, sut.uiState.value.identities.single().profile) } + @Test + fun `discovery sorts by profile name then shared identity key`() = test { + val first = ringIdentity().copy(pubky = "1${WIRE_PUBKY.drop(1)}") + val second = ringIdentity() + val third = ringIdentity().copy(pubky = "5${WIRE_PUBKY.drop(1)}") + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(third, first, second))) + for ((identity, name) in listOf(first to "Zeta", second to "Alpha", third to "alpha")) { + val publicKey = SharedPubkyContract.toBitkitPubky(identity.pubky) + whenever(pubkyRepo.fetchRemoteProfile(publicKey)) + .thenReturn(Result.success(PubkyProfile.forDisplay(publicKey, name, null))) + } + + val sut = createSut() + advanceUntilIdle() + + assertEquals(listOf(second, third, first), sut.uiState.value.identities.map { it.identity }) + assertEquals(listOf(second.pubky, third.pubky, first.pubky), sut.uiState.value.identities.map { it.pubky }) + } + @Test fun `selection adopts exact Ring identity then opens contact overview`() = test { val identity = ringIdentity() From 8eddccc6f983d1b7cc15dd7a643976e81a2449be Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 18 Sep 2026 08:49:15 +0200 Subject: [PATCH 15/23] fix: preserve cursor position when reading shared credentials --- .../data/sharing/SharedPubkyDiscovery.kt | 2 +- .../data/sharing/SharedPubkyDiscoveryTest.kt | 159 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt index 9dfd10bb10..b5c8b13f8d 100644 --- a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt @@ -84,7 +84,7 @@ class SharedPubkyDiscovery @Inject constructor( if (secretKeyColumn < 0 || !cursor.moveToFirst()) throw SharedPubkyError.IdentityUnavailable val identity = cursor.readIdentity(publicColumns) - if (identity.pubky != expectedPubky || cursor.moveToNext()) { + if (identity.pubky != expectedPubky || cursor.count != 1) { throw SharedPubkyError.InvalidResponse } return runCatching { diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt new file mode 100644 index 0000000000..948b926f6c --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt @@ -0,0 +1,159 @@ +package to.bitkit.data.sharing + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.ProviderInfo +import android.database.MatrixCursor +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class, sdk = [34], qualifiers = "en-rUS") +class SharedPubkyDiscoveryTest : BaseUnitTest() { + private companion object { + const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + private val context = mock() + private val packageManager = mock() + private val contentResolver = mock() + private val discovery = SharedPubkyDiscovery(context, testDispatcher) + + @Before + fun setUp() { + whenever(context.packageName).thenReturn(SharedPubkyContract.BITKIT_SOURCE) + whenever(context.packageManager).thenReturn(packageManager) + whenever(context.contentResolver).thenReturn(contentResolver) + whenever( + packageManager.resolveContentProvider(SharedPubkyContract.RING_AUTHORITY, PackageManager.MATCH_ALL), + ).thenReturn( + ProviderInfo().apply { + packageName = SharedPubkyContract.RING_SOURCE + authority = SharedPubkyContract.RING_AUTHORITY + readPermission = SharedPubkyContract.RING_READ_PERMISSION + }, + ) + whenever( + packageManager.checkSignatures(SharedPubkyContract.BITKIT_SOURCE, SharedPubkyContract.RING_SOURCE), + ).thenReturn(PackageManager.SIGNATURE_MATCH) + } + + @Test + fun `reads a valid credential from a single real cursor row`() = test { + val credential = readCredential(cursor(row())).getOrThrow() + + assertEquals( + SharedPubkyIdentity(SharedPubkyContract.PROTOCOL_VERSION, SharedPubkyContract.RING_SOURCE, WIRE_PUBKY), + credential.identity, + ) + assertEquals(SECRET_KEY, credential.secretKeyHex) + } + + @Test + fun `empty or absent credential responses are unavailable`() = test { + assertSame(SharedPubkyError.IdentityUnavailable, readCredential(cursor()).exceptionOrNull()) + assertSame(SharedPubkyError.IdentityUnavailable, readCredential(null).exceptionOrNull()) + } + + @Test + fun `missing secret column is unavailable before identity validation`() = test { + val cursor = MatrixCursor(SharedPubkyContract.publicColumns).apply { + addRow(arrayOf(2, "untrusted", "invalid")) + } + + assertSame(SharedPubkyError.IdentityUnavailable, readCredential(cursor).exceptionOrNull()) + } + + @Test + fun `missing public columns are invalid before checking rows or secret column`() = test { + SharedPubkyContract.publicColumns.forEach { missingColumn -> + val columns = SharedPubkyContract.publicColumns.filterNot { it == missingColumn }.toTypedArray() + + assertSame(SharedPubkyError.InvalidResponse, readCredential(MatrixCursor(columns)).exceptionOrNull()) + } + } + + @Test + fun `unsupported version precedes source key secret and row count validation`() = test { + val cursor = cursor(row(version = 2, source = "untrusted", pubky = "invalid", secret = null), row()) + + assertIs(readCredential(cursor).exceptionOrNull()) + } + + @Test + fun `untrusted source precedes key secret and row count validation`() = test { + val cursor = cursor(row(source = "untrusted", pubky = "invalid", secret = null), row()) + + assertIs(readCredential(cursor).exceptionOrNull()) + } + + @Test + fun `malformed or mismatched public keys are invalid`() = test { + listOf("invalid", "pubky$WIRE_PUBKY", "y".repeat(52)).forEach { pubky -> + assertSame(SharedPubkyError.InvalidResponse, readCredential(cursor(row(pubky = pubky))).exceptionOrNull()) + } + } + + @Test + fun `multiple rows are invalid even when the first credential is valid`() = test { + assertSame(SharedPubkyError.InvalidResponse, readCredential(cursor(row(), row())).exceptionOrNull()) + } + + @Test + fun `null empty and malformed secret values are invalid`() = test { + listOf(null, "", SECRET_KEY.dropLast(1), "g".repeat(64), SECRET_KEY.uppercase()).forEach { secret -> + assertSame(SharedPubkyError.InvalidResponse, readCredential(cursor(row(secret = secret))).exceptionOrNull()) + } + } + + @Test + fun `untrusted provider is rejected before querying credentials`() = test { + whenever( + packageManager.checkSignatures(SharedPubkyContract.BITKIT_SOURCE, SharedPubkyContract.RING_SOURCE), + ).thenReturn(PackageManager.SIGNATURE_NO_MATCH) + + assertIs(discovery.readRingCredential(WIRE_PUBKY).exceptionOrNull()) + verifyNoInteractions(contentResolver) + } + + private suspend fun readCredential(cursor: MatrixCursor?): Result { + whenever( + contentResolver.query( + SharedPubkyContract.ringCredentialUri(WIRE_PUBKY), + SharedPubkyContract.credentialColumns, + null, + null, + null, + ), + ).thenReturn(cursor) + + return discovery.readRingCredential(WIRE_PUBKY).also { + if (cursor != null) assertTrue(cursor.isClosed) + } + } + + private fun cursor(vararg rows: Array) = MatrixCursor(SharedPubkyContract.credentialColumns).apply { + rows.forEach { addRow(it) } + } + + private fun row( + version: Int = SharedPubkyContract.PROTOCOL_VERSION, + source: String = SharedPubkyContract.RING_SOURCE, + pubky: String = WIRE_PUBKY, + secret: String? = SECRET_KEY, + ): Array = arrayOf(version, source, pubky, secret) +} From 9a84e2b3e8d54aa9d89ae9359ac487e29a641d9c Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Fri, 18 Sep 2026 12:37:27 +0200 Subject: [PATCH 16/23] style: wrap pubky serializer assertion --- .../to/bitkit/data/serializers/PubkyStoreSerializerTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt index 6e54c7d7f8..192a2767f7 100644 --- a/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt +++ b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt @@ -33,7 +33,10 @@ class PubkyStoreSerializerTest : BaseUnitTest() { PubkyStoreSerializer.writeTo(restored, output) assertEquals(SharedPubkyIdentity(1, "app.pubkyring", WIRE_PUBKY), restored.externalIdentityRef) - assertEquals(json.parseToJsonElement(storedJson), json.parseToJsonElement(output.toByteArray().decodeToString())) + assertEquals( + json.parseToJsonElement(storedJson), + json.parseToJsonElement(output.toByteArray().decodeToString()), + ) } @Test From ab5362507f820c1e8ff676eda006fffab71fff6e Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 12:39:36 +0200 Subject: [PATCH 17/23] fix: preserve shared pubky on transient errors --- .../data/sharing/SharedPubkyContract.kt | 1 + .../data/sharing/SharedPubkyDiscovery.kt | 4 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 36 ++++- .../screens/profile/PubkyChoiceViewModel.kt | 9 +- .../data/sharing/SharedPubkyDiscoveryTest.kt | 43 +++++- .../to/bitkit/repositories/PubkyRepoTest.kt | 145 ++++++++++++++++++ .../profile/PubkyChoiceViewModelTest.kt | 31 ++++ 7 files changed, 254 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt index 5cf2d58d9d..6931ab4ec7 100644 --- a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt @@ -93,6 +93,7 @@ class SharedPubkyCredential( sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) { data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable") + data object ProviderQueryFailed : SharedPubkyError("Pubky Ring identity sharing query failed") class UntrustedSource(source: String) : SharedPubkyError("Untrusted Pubky identity source '$source'") class UnsupportedVersion(version: Int) : SharedPubkyError("Unsupported Pubky sharing version '$version'") data object InvalidResponse : SharedPubkyError("Pubky Ring returned an invalid shared identity") diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt index b5c8b13f8d..cc73e17cc6 100644 --- a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt @@ -25,7 +25,7 @@ class SharedPubkyDiscovery @Inject constructor( null, null, null, - )?.use(::readPublicIdentities) ?: throw SharedPubkyError.SourceUnavailable + )?.use(::readPublicIdentities) ?: throw SharedPubkyError.ProviderQueryFailed } } @@ -39,7 +39,7 @@ class SharedPubkyDiscovery @Inject constructor( null, null, null, - )?.use { readCredential(it, expectedPubky) } ?: throw SharedPubkyError.IdentityUnavailable + )?.use { readCredential(it, expectedPubky) } ?: throw SharedPubkyError.ProviderQueryFailed } } diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 983661428f..234db85262 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -302,7 +302,7 @@ class PubkyRepo @Inject constructor( identityRef: SharedPubkyIdentity, ): InitResult = withContext(ioDispatcher) { val sourceIdentity = sharedPubkyDiscovery.discoverRingIdentities().getOrElse { - return@withContext InitResult.ExternalSourceUnavailable + return@withContext externalSourceFailure(it) }.firstOrNull { it.matches(identityRef) } ?: return@withContext InitResult.ExternalSourceUnavailable @@ -319,7 +319,7 @@ class PubkyRepo @Inject constructor( } val credential = sharedPubkyDiscovery.readRingCredential(sourceIdentity.pubky).getOrElse { - return@withContext InitResult.ExternalSourceUnavailable + return@withContext externalSourceFailure(it) } if (!credential.matches(identityRef)) return@withContext InitResult.ExternalSourceUnavailable @@ -333,6 +333,17 @@ class PubkyRepo @Inject constructor( } } + private fun externalSourceFailure(error: Throwable): InitResult { + if (error.isDefinitiveExternalSourceFailure()) { + return InitResult.ExternalSourceUnavailable + } + Logger.warn("Failed to restore Pubky Ring identity source", error, context = TAG) + return InitResult.RestorationFailed + } + + private fun Throwable.isDefinitiveExternalSourceFailure() = + this is SharedPubkyError && this !is SharedPubkyError.ProviderQueryFailed + private suspend fun resolveSignedInSession( savedSessionSecret: String?, storedSecretKeyHex: String?, @@ -973,9 +984,20 @@ class PubkyRepo @Inject constructor( } ?: return@withContext true val available = sharedPubkyDiscovery.discoverRingIdentities() - .getOrNull() - ?.any { it.matches(identityRef) } - ?: false + .getOrElse { + if (it.isDefinitiveExternalSourceFailure()) { + clearUnavailableExternalIdentityLocked() + Logger.warn( + "Disconnected unavailable Pubky Ring identity '${redacted(identityRef.pubky)}'", + it, + context = TAG, + ) + return@withContext false + } + Logger.warn("Failed to validate Pubky Ring identity source", it, context = TAG) + return@withContext false + } + .any { it.matches(identityRef) } if (available) return@withContext true clearUnavailableExternalIdentityLocked() @@ -1209,7 +1231,7 @@ class PubkyRepo @Inject constructor( if (identityRef != null) { if (!validateExternalIdentitySourceLocked()) return@withContext false val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse { - clearUnavailableExternalIdentityLocked() + if (it.isDefinitiveExternalSourceFailure()) clearUnavailableExternalIdentityLocked() return@withContext false } val publicKey = signInWithExternalCredential(credential) @@ -1461,7 +1483,7 @@ class PubkyRepo @Inject constructor( } val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse { - clearUnavailableExternalIdentityLocked() + if (it.isDefinitiveExternalSourceFailure()) clearUnavailableExternalIdentityLocked() return@withContext null } val isValid = runSuspendCatching { diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 8d7de68db3..6f27093e87 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -94,16 +94,18 @@ class PubkyChoiceViewModel @Inject constructor( } } .onFailure { - handleSelectionFailure("Preparing shared profile failed", it) + reportSelectionError("Preparing shared profile failed", it) + _effects.emit(PubkyChoiceEffect.NavigateToPayContacts) } } .onFailure { - handleSelectionFailure("Connecting shared profile failed", it) + reportSelectionError("Connecting shared profile failed", it) + refreshRingIdentities() } } } - private suspend fun handleSelectionFailure(message: String, error: Throwable) { + private suspend fun reportSelectionError(message: String, error: Throwable) { Logger.error(message, error, context = TAG) _uiState.update { it.copy(selectedPubky = null) } ToastEventBus.send( @@ -111,7 +113,6 @@ class PubkyChoiceViewModel @Inject constructor( title = context.getString(R.string.profile__choice_error), description = error.message, ) - refreshRingIdentities() } } diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt index 948b926f6c..8d92f4d643 100644 --- a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt @@ -64,9 +64,32 @@ class SharedPubkyDiscoveryTest : BaseUnitTest() { } @Test - fun `empty or absent credential responses are unavailable`() = test { + fun `empty credential response proves the identity is unavailable`() = test { assertSame(SharedPubkyError.IdentityUnavailable, readCredential(cursor()).exceptionOrNull()) - assertSame(SharedPubkyError.IdentityUnavailable, readCredential(null).exceptionOrNull()) + } + + @Test + fun `null provider query results are retryable`() = test { + assertSame(SharedPubkyError.ProviderQueryFailed, discoverIdentities(null).exceptionOrNull()) + assertSame(SharedPubkyError.ProviderQueryFailed, readCredential(null).exceptionOrNull()) + } + + @Test + fun `empty identity response proves no shared identities exist`() = test { + val result = discoverIdentities(MatrixCursor(SharedPubkyContract.publicColumns)) + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow().isEmpty()) + } + + @Test + fun `missing Ring provider is definitively unavailable`() = test { + whenever( + packageManager.resolveContentProvider(SharedPubkyContract.RING_AUTHORITY, PackageManager.MATCH_ALL), + ).thenReturn(null) + + assertSame(SharedPubkyError.SourceUnavailable, discovery.discoverRingIdentities().exceptionOrNull()) + verifyNoInteractions(contentResolver) } @Test @@ -146,6 +169,22 @@ class SharedPubkyDiscoveryTest : BaseUnitTest() { } } + private suspend fun discoverIdentities(cursor: MatrixCursor?): Result> { + whenever( + contentResolver.query( + SharedPubkyContract.ringIdentitiesUri, + SharedPubkyContract.publicColumns, + null, + null, + null, + ), + ).thenReturn(cursor) + + return discovery.discoverRingIdentities().also { + if (cursor != null) assertTrue(cursor.isClosed) + } + } + private fun cursor(vararg rows: Array) = MatrixCursor(SharedPubkyContract.credentialColumns).apply { rows.forEach { addRow(it) } } diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index e4f31840d9..d23be61e8c 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -397,6 +397,24 @@ class PubkyRepoTest : BaseUnitTest() { } } + @Test + fun `transient Ring credential query failure rejects auth without disconnecting identity`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)) + .thenReturn(Result.failure(SharedPubkyError.ProviderQueryFailed)) + clearInvocations(pubkyService, pubkyStore) + + val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw", "paykit.test") + + assertTrue(result.isFailure) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verifyBlocking(pubkyService, never()) { approveAuth(any(), any(), any(), any()) } + verify(pubkyStore, never()).reset() + } + @Test fun `missing Ring source clears borrowed reference and local session`() = test { val identity = stubRingIdentity() @@ -417,6 +435,45 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { forgetSessionAccess() } } + @Test + fun `transient Ring discovery failures keep borrowed reference and local session for retry`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + clearInvocations(pubkyService, pubkyStore) + + listOf( + TestAppError("Provider unavailable"), + SharedPubkyError.ProviderQueryFailed, + ).forEach { + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.failure(it)) + + assertFalse(sut.validateExternalIdentitySource()) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) + } + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verify(pubkyStore, never()).reset() + } + + @Test + fun `unavailable Ring source clears borrowed reference and local session`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()) + .thenReturn(Result.failure(SharedPubkyError.SourceUnavailable)) + clearInvocations(pubkyService, pubkyStore) + + val available = sut.validateExternalIdentitySource() + + assertFalse(available) + assertNull(sut.publicKey.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + } + @Test fun `missing Ring source removes published payment endpoints before dropping the session`() = test { val identity = stubRingIdentity() @@ -1522,6 +1579,94 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyStore, never()) { reset() } } + @Test + fun `initialize preserves borrowed identity when Ring discovery failures are retryable`() = test { + val identity = stubRingIdentity() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session") + listOf( + TestAppError("Provider unavailable"), + SharedPubkyError.ProviderQueryFailed, + ).forEach { + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.failure(it)) + clearInvocations(pubkyService, pubkyStore) + val repo = createSut() + + repo.awaitInitialization() + + assertTrue(repo.sessionRestorationFailed.value) + assertFalse(repo.isAuthenticated.value) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verifyBlocking(pubkyStore, never()) { reset() } + } + } + + @Test + fun `initialize clears borrowed identity when Ring source is unavailable`() = test { + val identity = stubRingIdentity() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session") + whenever(sharedPubkyDiscovery.discoverRingIdentities()) + .thenReturn(Result.failure(SharedPubkyError.SourceUnavailable)) + clearInvocations(pubkyService, pubkyStore) + val repo = createSut() + + repo.awaitInitialization() + + assertFalse(repo.sessionRestorationFailed.value) + assertFalse(repo.isAuthenticated.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + } + + @Test + fun `initialize preserves borrowed identity when Ring credential read fails transiently`() = test { + val identity = stubRingIdentity() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("stale_session") + whenever(pubkyService.importExternalSession("stale_session")) + .thenAnswer { throw TestAppError("Expired") } + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)) + .thenReturn(Result.failure(SharedPubkyError.ProviderQueryFailed)) + clearInvocations(pubkyService, pubkyStore) + val repo = createSut() + + repo.awaitInitialization() + + assertTrue(repo.sessionRestorationFailed.value) + assertFalse(repo.isAuthenticated.value) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verifyBlocking(pubkyStore, never()) { reset() } + } + + @Test + fun `initialize clears borrowed identity when Ring credential is unavailable`() = test { + val identity = stubRingIdentity() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("stale_session") + whenever(pubkyService.importExternalSession("stale_session")) + .thenAnswer { throw TestAppError("Expired") } + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)) + .thenReturn(Result.failure(SharedPubkyError.IdentityUnavailable)) + clearInvocations(pubkyService, pubkyStore) + val repo = createSut() + + repo.awaitInitialization() + + assertFalse(repo.sessionRestorationFailed.value) + assertFalse(repo.isAuthenticated.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + } + @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index c7f24ad3d3..e9f184fe0a 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R @@ -132,6 +133,36 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { job.cancel() } + @Test + fun `failed import preparation reports error and opens Pay Contacts with adopted identity`() = test { + val identity = ringIdentity() + val error = IllegalStateException("Contacts unavailable") + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.prepareImport()).thenReturn(Result.failure(error)) + val sut = createSut() + advanceUntilIdle() + + val effects = mutableListOf() + val toasts = mutableListOf() + val effectsJob = launch { sut.effects.collect(effects::add) } + val toastsJob = launch { ToastEventBus.events.collect(toasts::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) + advanceUntilIdle() + + assertEquals(listOf(PubkyChoiceEffect.NavigateToPayContacts), effects) + assertNull(sut.uiState.value.selectedPubky) + assertEquals("Couldn't use this pubky", toasts.last().title) + assertEquals(error.message, toasts.last().description) + verify(pubkyRepo).adoptRingIdentity(identity) + verify(pubkyRepo).prepareImport() + verify(pubkyRepo, times(1)).discoverRingIdentities() + effectsJob.cancel() + toastsJob.cancel() + } + @Test fun `failed selection clears loading and reports error`() = test { val identity = ringIdentity() From 25cbd40189d7703a623ac3329dfa05365edad6d0 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 13:15:06 +0200 Subject: [PATCH 18/23] fix: preserve restored pubky sessions --- .../java/to/bitkit/repositories/PubkyRepo.kt | 19 ++- .../to/bitkit/repositories/PubkyRepoTest.kt | 130 +++++++++++++++++- 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 234db85262..4616493c9e 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -480,6 +480,13 @@ class PubkyRepo @Inject constructor( return@withLock runSuspendCatching { withContext(ioDispatcher) { val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" } + val storedSecretKeyHex = loadUnquarantinedLocalSecretKey() + if ( + storedSecretKeyHex.isNullOrBlank() || + pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() != publicKey + ) { + throw PubkyAlreadySignedInError + } val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes) finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl) } @@ -491,15 +498,12 @@ class PubkyRepo @Inject constructor( val result = runSuspendCatching { withContext(ioDispatcher) { settingsStore.setPubkyProfileSetupPending(false) - val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - .takeUnless { - keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == - MANAGED_SECRET_QUARANTINED - } + val storedSecretKeyHex = loadUnquarantinedLocalSecretKey() val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) { pubkyService.signIn(storedSecretKeyHex) pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() } else { + if (_publicKey.value != null) throw PubkyAlreadySignedInError val (publicKey, secretKeyHex) = deriveKeys().getOrThrow() val signupDetails: Pair = Env.e2eHomeserverPubky?.let { it to null } ?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode } @@ -527,6 +531,11 @@ class PubkyRepo @Inject constructor( } } + private fun loadUnquarantinedLocalSecretKey(): String? = + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).takeUnless { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == MANAGED_SECRET_QUARANTINED + } + private suspend fun publishIdentityProfile( name: String, bio: String, diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index d23be61e8c..6d822b9d22 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -834,6 +834,124 @@ class PubkyRepoTest : BaseUnitTest() { verify(keychain, never()).delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + @Test + fun `createIdentity preserves a restored legacy external session`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubSignupKeys() + authenticateForTesting(publicKey = VALID_CONTACT_KEY_A, secret = "legacy-session") + val existingProfile = sut.profile.value + var storedSecretKeyHex: String? = null + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer { storedSecretKeyHex } + whenever(pubkyService.signUp("secret", "test-homeserver", "test-code")).thenAnswer { + storedSecretKeyHex = "secret" + Unit + } + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + clearInvocations(pubkyService, keychain, pubkyStore) + + val result = sut.createIdentity("Replacement", "", emptyList(), emptyList(), null) + + assertEquals(PubkyAlreadySignedInError, result.exceptionOrNull()) + assertEquals(VALID_CONTACT_KEY_A, sut.publicKey.value) + assertEquals(existingProfile, sut.profile.value) + assertTrue(sut.isAuthenticated.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + assertEquals("legacy-session", keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) + assertNull(storedSecretKeyHex) + assertTrue((httpClient.engine as MockEngine).requestHistory.isEmpty()) + verify(keychain, never()).loadString(Keychain.Key.BIP39_MNEMONIC.name) + verify(keychain, never()).upsertString(any(), any()) + verify(keychain, never()).delete(any()) + verify(pubkyStore, never()).reset() + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + httpClient.close() + } + + @Test + fun `createIdentity does not resume a legacy session with stale pending setup`() = test { + authenticateForTesting(publicKey = VALID_CONTACT_KEY_A, secret = "legacy-session") + profileSetupPending.value = true + val existingProfile = sut.profile.value + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + clearInvocations(pubkyService, keychain, pubkyStore) + + val result = sut.createIdentity("Replacement", "", emptyList(), emptyList(), null) + + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + assertEquals(PubkyAlreadySignedInError, result.exceptionOrNull()) + assertEquals(VALID_CONTACT_KEY_A, sut.publicKey.value) + assertEquals(existingProfile, sut.profile.value) + assertTrue(sut.isAuthenticated.value) + assertTrue(profileSetupPending.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + assertEquals("legacy-session", keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) + verify(keychain, never()).upsertString(any(), any()) + verify(keychain, never()).delete(any()) + verify(pubkyStore, never()).reset() + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + } + + @Test + fun `pending setup rejects mismatched or quarantined managed keys without deleting them`() = test { + authenticateForTesting(publicKey = VALID_CONTACT_KEY_A, secret = "legacy-session") + profileSetupPending.value = true + val existingProfile = sut.profile.value + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed-secret") + + for ((derivedPublicKey, quarantined) in listOf(VALID_SELF_KEY to false, VALID_CONTACT_KEY_A to true)) { + whenever(pubkyService.publicKeyFromSecret("managed-secret")).thenReturn(derivedPublicKey) + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)) + .thenReturn(if (quarantined) "1" else null) + clearInvocations(pubkyService, keychain, pubkyStore) + + val result = sut.createIdentity("Replacement", "", emptyList(), emptyList(), null) + + assertEquals(PubkyAlreadySignedInError, result.exceptionOrNull()) + assertEquals(VALID_CONTACT_KEY_A, sut.publicKey.value) + assertEquals(existingProfile, sut.profile.value) + assertTrue(profileSetupPending.value) + verify(keychain, never()).upsertString(any(), any()) + verify(keychain, never()).delete(any()) + verify(pubkyStore, never()).reset() + verifyBlocking(pubkyService, never()) { publishPaykitProfile(any()) } + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signOut() } + verifyBlocking(pubkyService, never()) { forgetSessionAccess() } + } + } + + @Test + fun `createIdentity creates a fresh identity without an existing session`() = test { + val httpClient = identityHttpClient() + sut = createSut(httpClient) + stubSignupKeys() + var storedSecretKeyHex: String? = null + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer { storedSecretKeyHex } + whenever(pubkyService.signUp("secret", "test-homeserver", "test-code")).thenAnswer { + storedSecretKeyHex = "secret" + Unit + } + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + + val result = sut.createIdentity("Fresh", "", emptyList(), emptyList(), null) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals("Fresh", sut.profile.value?.name) + verifyBlocking(pubkyService) { signUp("secret", "test-homeserver", "test-code") } + verifyBlocking(pubkyService, never()) { signIn(any()) } + httpClient.close() + } + @Test fun `createIdentity signs up when a Ring session has no local key`() = test { val httpClient = identityHttpClient() @@ -858,11 +976,12 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `createIdentity should preserve signup session when pending profile publication fails`() = test { + fun `createIdentity preserves owned pending signup after publication failure and retries`() = test { val registeredSession = mock() stubSignupKeys() whenever(pubkyService.registerIdentity("secret", "homeserver", "invite")).thenReturn(registeredSession) assertTrue(sut.approveSignupAuth(ringSignupRequest()).isSuccess) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("secret") clearInvocations(pubkyService) whenever(pubkyService.publishPaykitProfile(any())).thenAnswer { throw TestAppError("Publish failed") } @@ -880,6 +999,15 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyService, never()) { signIn(any()) } verifyBlocking(pubkyService, never()) { signOut() } assertTrue(profileSetupPending.value) + + whenever(pubkyService.publishPaykitProfile(any())).thenReturn(mock()) + assertTrue(sut.createIdentity("Retried", "", emptyList(), emptyList(), null).isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals("Retried", sut.profile.value?.name) + assertFalse(profileSetupPending.value) + verifyBlocking(pubkyService, never()) { signUp(any(), any(), any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(pubkyService, never()) { signOut() } } @Test From 3adc559ae5cb648a4359b129c3616fd5d64f0cfa Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 13:33:39 +0200 Subject: [PATCH 19/23] fix: quarantine stale private paykit state --- .../main/java/to/bitkit/data/PubkyStore.kt | 1 + .../java/to/bitkit/repositories/PubkyRepo.kt | 45 ++++- .../serializers/PubkyStoreSerializerTest.kt | 24 +++ .../to/bitkit/repositories/PubkyRepoTest.kt | 170 +++++++++++++++++- 4 files changed, 235 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 3898d85093..7b8422fe92 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -40,4 +40,5 @@ data class PubkyStoreData( val cachedImageUri: String? = null, val contactProfileOverrides: Map = emptyMap(), val externalIdentityRef: SharedPubkyIdentity? = null, + val privatePaykitStateCleanupPending: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 4616493c9e..598685f020 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import to.bitkit.async.appScope import to.bitkit.data.PubkyStore +import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain @@ -60,6 +61,7 @@ import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.ByteArrayOutputStream import javax.inject.Inject +import javax.inject.Provider import javax.inject.Singleton import kotlin.math.min @@ -82,6 +84,7 @@ class PubkyRepo @Inject constructor( private val settingsStore: SettingsStore, private val httpClient: HttpClient, private val sharedPubkyDiscovery: SharedPubkyDiscovery, + private val privatePaykitRepo: Provider, ) { companion object { private const val TAG = "PubkyRepo" @@ -177,6 +180,7 @@ class PubkyRepo @Inject constructor( identityLifecycleMutex.withLock { _sessionRestorationFailed.update { false } val result = runSuspendCatching { + retryPendingPrivatePaykitStateCleanupLocked() resolveStoredSessionInitialization() }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) @@ -471,6 +475,7 @@ class PubkyRepo @Inject constructor( avatarBytes: ByteArray?, ): Result = identityLifecycleMutex.withLock { runSuspendCatching { + retryPendingPrivatePaykitStateCleanupLocked() if (pubkyStore.data.first().externalIdentityRef != null) { throw SharedPubkyError.IdentityConflict } @@ -929,6 +934,7 @@ class PubkyRepo @Inject constructor( runSuspendCatching { withContext(ioDispatcher) { ensureServiceInitialized() + retryPendingPrivatePaykitStateCleanupLocked() val canonicalIdentity = identity.validated() val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated() val currentPublicKey = _publicKey.value @@ -1054,6 +1060,7 @@ class PubkyRepo @Inject constructor( runSuspendCatching { withContext(ioDispatcher) { require(request.isSignup) { "Not a Pubky signup request" } + retryPendingPrivatePaykitStateCleanupLocked() if (pubkyStore.data.first().externalIdentityRef != null) { throw SharedPubkyError.IdentityConflict } @@ -1182,6 +1189,7 @@ class PubkyRepo @Inject constructor( ensureServiceInitialized() identityLifecycleMutex.withLock { + retryPendingPrivatePaykitStateCleanupLocked() disableLocalIdentityExport() runSuspendCatching { pubkyService.forgetSessionAccess() } .onFailure { @@ -1236,6 +1244,7 @@ class PubkyRepo @Inject constructor( suspend fun refreshSessionIfPossible(): Result = identityLifecycleMutex.withLock { runSuspendCatching { withContext(ioDispatcher) { + retryPendingPrivatePaykitStateCleanupLocked() val identityRef = pubkyStore.data.first().externalIdentityRef?.validated() if (identityRef != null) { if (!validateExternalIdentitySourceLocked()) return@withContext false @@ -1535,6 +1544,14 @@ class PubkyRepo @Inject constructor( // Published endpoints outlive the borrowed identity, so drop them while the session still // works and keep the cleanup pending when that fails. + val privatePaykit = privatePaykitRepo.get() + pubkyStore.update { it.copy(privatePaykitStateCleanupPending = true) } + val privateEndpointCleanupResult = runSuspendCatching { + privatePaykit.removePublishedEndpointsForCleanup(TAG) + }.getOrElse { + Logger.warn("Failed to remove private Paykit endpoints", it, context = TAG) + Result.failure(it) + } val hadPaykitState = settingsStore.data.first().hasPaykitState() val endpointCleanupResult = if (hadPaykitState) { removeBitkitPaymentEndpoints() @@ -1544,14 +1561,30 @@ class PubkyRepo @Inject constructor( } pubkyService.clearExternalSessionAccess() + val privateStateCleanupResult = if (privateEndpointCleanupResult.isSuccess) { + runSuspendCatching { privatePaykit.closeAndClear() } + .getOrElse { Result.failure(it) } + .onFailure { Logger.warn("Failed to clear private Paykit state", it, context = TAG) } + } else { + null + } clearPublicPaykitSharingState( publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState, ) clearAuthenticatedRuntimeState() - pubkyStore.reset() + if (privateStateCleanupResult?.isSuccess == true) { + pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } + } + resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() notifyBackupStateChanged() } + private suspend fun retryPendingPrivatePaykitStateCleanupLocked() { + if (!pubkyStore.data.first().privatePaykitStateCleanupPending) return + privatePaykitRepo.get().closeAndClear().getOrThrow() + pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } + } + private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) { if ( keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == @@ -1595,10 +1628,18 @@ class PubkyRepo @Inject constructor( } private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { - runSuspendCatching { pubkyStore.reset() } + runSuspendCatching { resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() } clearAuthenticatedRuntimeState() } + private suspend fun resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() { + if (pubkyStore.data.first().privatePaykitStateCleanupPending) { + pubkyStore.update { PubkyStoreData(privatePaykitStateCleanupPending = true) } + } else { + pubkyStore.reset() + } + } + private suspend fun clearAuthenticatedRuntimeState() = withContext(ioDispatcher) { evictPubkyImages() _publicKey.update { null } diff --git a/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt index 192a2767f7..b41f604aea 100644 --- a/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt +++ b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt @@ -20,6 +20,7 @@ class PubkyStoreSerializerTest : BaseUnitTest() { "cachedName": "Ring user", "cachedImageUri": "pubky://avatar", "contactProfileOverrides": {}, + "privatePaykitStateCleanupPending": false, "externalIdentityRef": { "protocolVersion": 1, "sourcePackage": "app.pubkyring", @@ -39,6 +40,29 @@ class PubkyStoreSerializerTest : BaseUnitTest() { ) } + @Test + fun `private Paykit cleanup marker survives persistence`() = test { + val storedJson = """ + { + "cachedName": null, + "cachedImageUri": null, + "contactProfileOverrides": {}, + "externalIdentityRef": null, + "privatePaykitStateCleanupPending": true + } + """.trimIndent() + + val restored = PubkyStoreSerializer.readFrom(storedJson.byteInputStream()) + val output = ByteArrayOutputStream() + PubkyStoreSerializer.writeTo(restored, output) + + assertEquals(true, restored.privatePaykitStateCleanupPending) + assertEquals( + json.parseToJsonElement(storedJson), + json.parseToJsonElement(output.toByteArray().decodeToString()), + ) + } + @Test fun `invalid persisted references survive decoding for explicit recovery`() = test { val invalidReferences = listOf( diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 6d822b9d22..62c87b6ced 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -67,6 +67,7 @@ import to.bitkit.services.PubkyRingAuthTimeoutError import to.bitkit.services.PubkyService import to.bitkit.test.BaseUnitTest import to.bitkit.utils.AppError +import javax.inject.Provider import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -95,6 +96,8 @@ class PubkyRepoTest : BaseUnitTest() { private val pubkyStore = mock() private val settingsStore = mock() private val sharedPubkyDiscovery = mock() + private val privatePaykitRepo = mock() + private val privatePaykitRepoProvider = mock>() private val settingsFlow = MutableStateFlow(SettingsData()) private val profileSetupPending = MutableStateFlow(false) private val pubkyDataFlow = MutableStateFlow(PubkyStoreData()) @@ -108,6 +111,9 @@ class PubkyRepoTest : BaseUnitTest() { whenever(pubkyStore.data).thenReturn(pubkyDataFlow) whenever(settingsStore.data).thenReturn(settingsFlow) whenever(settingsStore.isPubkyProfileSetupPending).thenReturn(profileSetupPending) + whenever(privatePaykitRepoProvider.get()).thenReturn(privatePaykitRepo) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) whenever { settingsStore.setPubkyProfileSetupPending(any()) }.thenAnswer { profileSetupPending.value = it.getArgument(0) Unit @@ -152,6 +158,7 @@ class PubkyRepoTest : BaseUnitTest() { settingsStore = settingsStore, httpClient = httpClient, sharedPubkyDiscovery = sharedPubkyDiscovery, + privatePaykitRepo = privatePaykitRepoProvider, ) @Test @@ -475,7 +482,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `missing Ring source removes published payment endpoints before dropping the session`() = test { + fun `missing Ring source removes private and public payment endpoints before dropping the session`() = test { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) settingsFlow.value = SettingsData( @@ -483,13 +490,15 @@ class PubkyRepoTest : BaseUnitTest() { sharesPublicPaykitEndpoints = true, ) whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) - clearInvocations(pubkyService) + clearInvocations(privatePaykitRepo, pubkyService) assertFalse(sut.validateExternalIdentitySource()) - inOrder(pubkyService) { + inOrder(privatePaykitRepo, pubkyService) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") verify(pubkyService).removeBitkitPaymentEndpoints() verify(pubkyService).clearExternalSessionAccess() + verify(privatePaykitRepo).closeAndClear() } assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.publicPaykitCleanupPending) @@ -510,6 +519,161 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(pubkyDataFlow.value.externalIdentityRef) } + @Test + fun `missing Ring source preserves private cleanup state when private endpoint removal fails`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenReturn(Result.failure(TestAppError("Private cleanup failed"))) + clearInvocations(privatePaykitRepo, pubkyService, pubkyStore) + + assertFalse(sut.validateExternalIdentitySource()) + + inOrder(privatePaykitRepo, pubkyService, pubkyStore) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") + verify(pubkyService).removeBitkitPaymentEndpoints() + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).update(any()) + } + verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } + verify(pubkyStore, never()).reset() + assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + assertNull(pubkyDataFlow.value.externalIdentityRef) + } + + @Test + fun `private state cleanup failure quarantines identity changes until cleanup succeeds`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { privatePaykitRepo.closeAndClear() } + .thenReturn(Result.failure(TestAppError("Private state cleanup failed"))) + clearInvocations(privatePaykitRepo, pubkyStore) + + assertFalse(sut.validateExternalIdentitySource()) + + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyStore, never()) { reset() } + + whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) + clearInvocations(privatePaykitRepo, sharedPubkyDiscovery) + + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + + inOrder(privatePaykitRepo, sharedPubkyDiscovery) { + verify(privatePaykitRepo).closeAndClear() + verify(sharedPubkyDiscovery).readRingCredential(identity.pubky) + } + assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) + } + + @Test + fun `pending private state cleanup gates every identity entry point`() = test { + val cleanupError = TestAppError("Private state cleanup failed") + whenever { privatePaykitRepo.closeAndClear() }.thenAnswer { throw cleanupError } + val operations = listOf Result<*>>>( + "create" to { sut.createIdentity("Test", "", emptyList(), emptyList(), null) }, + "signup approval" to { sut.approveSignupAuth(ringSignupRequest()) }, + "backup restore" to { sut.restoreSessionBackupState(null) }, + "session refresh" to { sut.refreshSessionIfPossible() }, + ) + + operations.forEach { (name, operation) -> + pubkyDataFlow.value = PubkyStoreData(privatePaykitStateCleanupPending = true) + clearInvocations(privatePaykitRepo, pubkyService) + + val result = operation() + + assertTrue(result.isFailure, "$name must fail while private state cleanup is pending") + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + verifyBlocking(privatePaykitRepo) { closeAndClear() } + verify(pubkyService, never()).registerIdentity(any(), any(), any()) + verifyBlocking(pubkyService, never()) { importExternalSession(any()) } + verifyBlocking(pubkyService, never()) { signIn(any()) } + } + } + + @Test + fun `generic local wipe preserves pending private state cleanup marker`() = test { + pubkyDataFlow.value = PubkyStoreData( + cachedName = "Old identity", + privatePaykitStateCleanupPending = true, + ) + clearInvocations(pubkyStore) + + sut.wipeLocalState() + + assertEquals(PubkyStoreData(privatePaykitStateCleanupPending = true), pubkyDataFlow.value) + verify(pubkyStore, never()).reset() + } + + @Test + fun `initialize retries durable private state cleanup before restoring another session`() = test { + pubkyDataFlow.value = PubkyStoreData(privatePaykitStateCleanupPending = true) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") + whenever(pubkyService.importExternalSession("saved_session")).thenReturn(VALID_SELF_KEY) + whenever { privatePaykitRepo.closeAndClear() } + .thenReturn(Result.failure(TestAppError("Private state cleanup failed"))) + clearInvocations(privatePaykitRepo, pubkyService) + val repo = createSut() + + repo.awaitInitialization() + + assertNull(repo.publicKey.value) + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + verifyBlocking(pubkyService, never()) { importExternalSession(any()) } + + whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) + clearInvocations(privatePaykitRepo, pubkyService) + + repo.initialize() + + inOrder(privatePaykitRepo, pubkyService) { + verify(privatePaykitRepo).closeAndClear() + verify(pubkyService).importExternalSession("saved_session") + } + assertEquals(VALID_SELF_KEY, repo.publicKey.value) + assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending) + } + + @Test + fun `thrown private endpoint cleanup failure still disconnects without clearing private state`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenAnswer { throw IllegalStateException("Private cleanup crashed") } + clearInvocations(privatePaykitRepo, pubkyService, pubkyStore) + + assertFalse(sut.validateExternalIdentitySource()) + + verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } + verifyBlocking(pubkyService) { clearExternalSessionAccess() } + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + assertNull(pubkyDataFlow.value.externalIdentityRef) + } + + @Test + fun `thrown private state cleanup failure still disconnects and preserves retry marker`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { privatePaykitRepo.closeAndClear() } + .thenAnswer { throw IllegalStateException("Private state cleanup crashed") } + clearInvocations(privatePaykitRepo, pubkyService) + + assertFalse(sut.validateExternalIdentitySource()) + + verifyBlocking(pubkyService) { clearExternalSessionAccess() } + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + assertNull(pubkyDataFlow.value.externalIdentityRef) + } + @Test fun `missing Ring source skips endpoint removal without Paykit state`() = test { val identity = stubRingIdentity() From d1e3ed25372adc301d0e82003410d7973f033736 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 14:29:31 +0200 Subject: [PATCH 20/23] fix: clear stale pubky quarantine on restore --- .../java/to/bitkit/repositories/PubkyRepo.kt | 7 ++- .../to/bitkit/repositories/PubkyRepoTest.kt | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 598685f020..63bc75f24b 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1201,7 +1201,12 @@ class PubkyRepo @Inject constructor( } clearAuthenticatedState() runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + val localSecretResult = runSuspendCatching { + keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + } + if (localSecretResult.isSuccess) { + keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + } when (backup?.kind) { null -> Unit diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 62c87b6ced..1daec84505 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2069,6 +2069,53 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } } + @Test + fun `external restore releases stale managed secret quarantine before restart`() = test { + sut.awaitInitialization() + var sessionSecret: String? = "old_session" + var managedSecret: String? = "old_secret" + var quarantineMarker: String? = "1" + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { sessionSecret } + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenAnswer { managedSecret } + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)) + .thenAnswer { quarantineMarker } + whenever { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }.thenAnswer { + sessionSecret = null + Unit + } + whenever { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }.thenAnswer { + managedSecret = null + Unit + } + whenever { keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) }.thenAnswer { + quarantineMarker = null + Unit + } + whenever { pubkyService.forgetSessionAccess() }.thenAnswer { throw TestAppError("Forget failed") } + whenever { pubkyService.importExternalSession("external_session") }.thenAnswer { + sessionSecret = "external_session" + VALID_SELF_KEY + } + clearInvocations(pubkyService, keychain) + + val result = sut.restoreSessionBackupState( + PubkySessionBackupV1( + kind = PubkySessionBackupKind.ExternalSession, + sessionSecret = "external_session", + ), + ) + + assertTrue(result.isSuccess) + assertNull(managedSecret) + assertNull(quarantineMarker) + + val restoredRepo = createSut() + restoredRepo.awaitInitialization() + + assertEquals(VALID_SELF_KEY, restoredRepo.publicKey.value) + verifyBlocking(pubkyService, times(2)) { importExternalSession("external_session") } + } + @Test fun `restore without backup clears credentials when forgetting current session fails`() = test { authenticateForTesting(publicKey = VALID_SELF_KEY) From 26e67c319d12f2ccc5acce7dd7eb7453ddf387e3 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 14:30:02 +0200 Subject: [PATCH 21/23] fix: complete external identity teardown --- .../java/to/bitkit/repositories/PubkyRepo.kt | 34 ++++++++++--------- .../to/bitkit/repositories/PubkyRepoTest.kt | 32 +++++++++++++++++ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 63bc75f24b..026f36f651 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1565,23 +1565,25 @@ class PubkyRepo @Inject constructor( Result.success(Unit) } - pubkyService.clearExternalSessionAccess() - val privateStateCleanupResult = if (privateEndpointCleanupResult.isSuccess) { - runSuspendCatching { privatePaykit.closeAndClear() } - .getOrElse { Result.failure(it) } - .onFailure { Logger.warn("Failed to clear private Paykit state", it, context = TAG) } - } else { - null - } - clearPublicPaykitSharingState( - publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState, - ) - clearAuthenticatedRuntimeState() - if (privateStateCleanupResult?.isSuccess == true) { - pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } + withContext(NonCancellable) { + pubkyService.clearExternalSessionAccess() + val privateStateCleanupResult = if (privateEndpointCleanupResult.isSuccess) { + runSuspendCatching { privatePaykit.closeAndClear() } + .getOrElse { Result.failure(it) } + .onFailure { Logger.warn("Failed to clear private Paykit state", it, context = TAG) } + } else { + null + } + clearPublicPaykitSharingState( + publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState, + ) + clearAuthenticatedRuntimeState() + if (privateStateCleanupResult?.isSuccess == true) { + pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } + } + resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() + notifyBackupStateChanged() } - resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() - notifyBackupStateChanged() } private suspend fun retryPendingPrivatePaykitStateCleanupLocked() { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 1daec84505..30c6657ff1 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -674,6 +674,38 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(pubkyDataFlow.value.externalIdentityRef) } + @Test + fun `cancellation after external session deletion completes local teardown`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + val sessionCleared = CompletableDeferred() + val resumePrivateCleanup = CompletableDeferred() + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { pubkyService.clearExternalSessionAccess() }.thenAnswer { + sessionCleared.complete(Unit) + } + whenever { privatePaykitRepo.closeAndClear() }.doSuspendableAnswer { + resumePrivateCleanup.await() + Result.success(Unit) + } + + val validation = async { sut.validateExternalIdentitySource() } + runCurrent() + assertTrue(sessionCleared.isCompleted) + + validation.cancel() + runCurrent() + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) + + resumePrivateCleanup.complete(Unit) + advanceUntilIdle() + + assertTrue(validation.isCancelled) + assertNull(pubkyDataFlow.value.externalIdentityRef) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + } + @Test fun `missing Ring source skips endpoint removal without Paykit state`() = test { val identity = stubRingIdentity() From f19a2c785cdff2610a9b9c9aec7fcac4d81cddf4 Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 15:49:53 +0200 Subject: [PATCH 22/23] fix: fail closed on unreadable pubky quarantine --- .../data/sharing/SharedPubkyProvider.kt | 13 +++++----- .../java/to/bitkit/repositories/PubkyRepo.kt | 5 +++- .../data/sharing/SharedPubkyProviderTest.kt | 24 +++++++++++++++---- .../to/bitkit/repositories/PubkyRepoTest.kt | 22 +++++++++++++++++ 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt index c7290a92e0..80321cdf32 100644 --- a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt @@ -113,11 +113,11 @@ class SharedPubkyProvider : ContentProvider() { }.getOrNull() == EXPORT_ENABLED if (!isExportEnabled) return null - val isManagedSecretQuarantined = runCatching { - keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + val managedSecretQuarantine = runCatching { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == QUARANTINED }.onFailure { Logger.warn("Failed to read managed Pubky secret quarantine", it, context = TAG) - }.getOrNull() == QUARANTINED + } val secretKeyHex = runCatching { keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) @@ -128,7 +128,7 @@ class SharedPubkyProvider : ContentProvider() { return runCatching { localSharedPubkyIdentity( exportEnabled = true, - managedSecretQuarantined = isManagedSecretQuarantined, + managedSecretQuarantine = managedSecretQuarantine, secretKeyHex = secretKeyHex, publicKeyFromSecret = PaykitSdkService::publicKeyFromSecret, ) @@ -163,11 +163,12 @@ class SharedPubkyProvider : ContentProvider() { internal fun localSharedPubkyIdentity( exportEnabled: Boolean, - managedSecretQuarantined: Boolean, + managedSecretQuarantine: Result, secretKeyHex: String?, publicKeyFromSecret: (String) -> String, ): LocalIdentity? { - if (!exportEnabled || managedSecretQuarantined || secretKeyHex.isNullOrBlank()) return null + val isManagedSecretQuarantined = managedSecretQuarantine.getOrElse { return null } + if (!exportEnabled || isManagedSecretQuarantined || secretKeyHex.isNullOrBlank()) return null val canonicalSecretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) val pubky = SharedPubkyContract.canonicalPubky(publicKeyFromSecret(canonicalSecretKeyHex)) return LocalIdentity(pubky = pubky, secretKeyHex = canonicalSecretKeyHex) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 026f36f651..536bad473a 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -202,7 +202,10 @@ class PubkyRepo @Inject constructor( }.getOrNull() val isManagedSecretQuarantined = runCatching { keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) - }.getOrNull() == MANAGED_SECRET_QUARANTINED + }.getOrElse { + Logger.warn("Failed to read managed Pubky secret quarantine", it, context = TAG) + return InitResult.RestorationFailed + } == MANAGED_SECRET_QUARANTINED val storedSecretKeyHex = runCatching { keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) }.getOrNull().takeUnless { isManagedSecretQuarantined } diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt index c83e0af6f4..b9ea1cefbb 100644 --- a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt @@ -1,6 +1,8 @@ package to.bitkit.data.sharing import org.junit.Test +import to.bitkit.data.keychain.Keychain +import to.bitkit.data.keychain.KeychainError import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNull @@ -15,7 +17,7 @@ class SharedPubkyProviderTest { fun `borrowed active identity without a local secret is never exported`() { val identity = localSharedPubkyIdentity( exportEnabled = true, - managedSecretQuarantined = false, + managedSecretQuarantine = Result.success(false), secretKeyHex = null, publicKeyFromSecret = { error("Must not derive a borrowed identity") }, ) @@ -27,7 +29,7 @@ class SharedPubkyProviderTest { fun `disabled local identity is never exported`() { val identity = localSharedPubkyIdentity( exportEnabled = false, - managedSecretQuarantined = false, + managedSecretQuarantine = Result.success(false), secretKeyHex = SECRET_KEY, publicKeyFromSecret = { WIRE_PUBKY }, ) @@ -39,7 +41,7 @@ class SharedPubkyProviderTest { fun `quarantined managed identity is never exported`() { val identity = localSharedPubkyIdentity( exportEnabled = true, - managedSecretQuarantined = true, + managedSecretQuarantine = Result.success(true), secretKeyHex = SECRET_KEY, publicKeyFromSecret = { error("Must not derive a quarantined identity") }, ) @@ -47,11 +49,25 @@ class SharedPubkyProviderTest { assertNull(identity) } + @Test + fun `unreadable managed secret quarantine never exports a readable identity`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantine = Result.failure( + KeychainError.FailedToLoad(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name), + ), + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { error("Must not derive identity with unreadable quarantine state") }, + ) + + assertNull(identity) + } + @Test fun `public discovery row excludes the local secret`() { val identity = localSharedPubkyIdentity( exportEnabled = true, - managedSecretQuarantined = false, + managedSecretQuarantine = Result.success(false), secretKeyHex = SECRET_KEY, publicKeyFromSecret = { "pubky$WIRE_PUBKY" }, ) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 30c6657ff1..eccd55e9eb 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1804,6 +1804,28 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(sut.isAuthenticated.value) } + @Test + fun `initialize fails closed when managed secret quarantine cannot be read`() = test { + sut.awaitInitialization() + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(null) + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)) + .thenAnswer { throw TestAppError("Quarantine decryption failed") } + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("readable_secret") + clearInvocations(keychain, pubkyService) + val repo = createSut() + + repo.awaitInitialization() + + assertNull(repo.publicKey.value) + assertFalse(repo.isAuthenticated.value) + verify(keychain, never()).loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + verifyBlocking(pubkyService, never()) { signIn(any()) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } + verifyBlocking(keychain, never()) { + upsertString(eq(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name), any()) + } + } + @Test fun `initialize should keep saved session when re-sign-in is unavailable`() = test { val session = "stale_session" From f37c8cd6d05275f0d6dbf2afd694e86f28011f5f Mon Sep 17 00:00:00 2001 From: Jason van den Berg Date: Mon, 21 Sep 2026 15:50:10 +0200 Subject: [PATCH 23/23] fix: retain pubky cleanup session until teardown --- .../java/to/bitkit/repositories/PubkyRepo.kt | 47 +++-- .../to/bitkit/repositories/PubkyRepoTest.kt | 192 +++++++++++++----- 2 files changed, 171 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 536bad473a..ae7798c9a4 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1297,6 +1297,14 @@ class PubkyRepo @Inject constructor( suspend fun signOut(): Result = identityLifecycleMutex.withLock { withContext(NonCancellable + ioDispatcher) { + if (pubkyStore.data.first().privatePaykitStateCleanupPending) { + return@withContext runSuspendCatching { + retryPendingPrivatePaykitStateCleanupLocked() + }.onFailure { + Logger.warn("Failed to finish pending private Paykit cleanup", it, context = TAG) + } + } + runSuspendCatching { disableLocalIdentityExport() } .onFailure { Logger.error("Failed to disable shared Pubky export", it, context = TAG) } .exceptionOrNull() @@ -1553,13 +1561,28 @@ class PubkyRepo @Inject constructor( // Published endpoints outlive the borrowed identity, so drop them while the session still // works and keep the cleanup pending when that fails. val privatePaykit = privatePaykitRepo.get() + val hadPaykitState = settingsStore.data.first().hasPaykitState() pubkyStore.update { it.copy(privatePaykitStateCleanupPending = true) } + withContext(NonCancellable) { + clearPublicPaykitSharingState(publicPaykitCleanupPending = hadPaykitState) + clearAuthenticatedRuntimeState() + resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() + notifyBackupStateChanged() + } val privateEndpointCleanupResult = runSuspendCatching { privatePaykit.removePublishedEndpointsForCleanup(TAG) }.getOrElse { Logger.warn("Failed to remove private Paykit endpoints", it, context = TAG) Result.failure(it) } + if (privateEndpointCleanupResult.isFailure) return@withContext + + finishUnavailableExternalIdentityTeardown(privatePaykit) + } + + private suspend fun finishUnavailableExternalIdentityTeardown( + privatePaykit: PrivatePaykitRepo, + ): Result { val hadPaykitState = settingsStore.data.first().hasPaykitState() val endpointCleanupResult = if (hadPaykitState) { removeBitkitPaymentEndpoints() @@ -1568,31 +1591,29 @@ class PubkyRepo @Inject constructor( Result.success(Unit) } - withContext(NonCancellable) { + return withContext(NonCancellable) { + val privateStateCleanupResult = runSuspendCatching { privatePaykit.closeAndClear() } + .getOrElse { Result.failure(it) } + .onFailure { Logger.warn("Failed to clear private Paykit state", it, context = TAG) } + if (privateStateCleanupResult.isFailure) return@withContext privateStateCleanupResult + pubkyService.clearExternalSessionAccess() - val privateStateCleanupResult = if (privateEndpointCleanupResult.isSuccess) { - runSuspendCatching { privatePaykit.closeAndClear() } - .getOrElse { Result.failure(it) } - .onFailure { Logger.warn("Failed to clear private Paykit state", it, context = TAG) } - } else { - null - } clearPublicPaykitSharingState( publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState, ) clearAuthenticatedRuntimeState() - if (privateStateCleanupResult?.isSuccess == true) { - pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } - } + pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } resetPubkyMetadataPreservingPrivatePaykitCleanupMarker() notifyBackupStateChanged() + Result.success(Unit) } } private suspend fun retryPendingPrivatePaykitStateCleanupLocked() { if (!pubkyStore.data.first().privatePaykitStateCleanupPending) return - privatePaykitRepo.get().closeAndClear().getOrThrow() - pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) } + val privatePaykit = privatePaykitRepo.get() + privatePaykit.removePublishedEndpointsForCleanup(TAG).getOrThrow() + finishUnavailableExternalIdentityTeardown(privatePaykit).getOrThrow() } private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) { diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index eccd55e9eb..9b055cb073 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -35,9 +35,7 @@ import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doSuspendableAnswer -import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -497,8 +495,8 @@ class PubkyRepoTest : BaseUnitTest() { inOrder(privatePaykitRepo, pubkyService) { verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") verify(pubkyService).removeBitkitPaymentEndpoints() - verify(pubkyService).clearExternalSessionAccess() verify(privatePaykitRepo).closeAndClear() + verify(pubkyService).clearExternalSessionAccess() } assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints) assertFalse(settingsFlow.value.publicPaykitCleanupPending) @@ -520,7 +518,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `missing Ring source preserves private cleanup state when private endpoint removal fails`() = test { + fun `missing Ring source preserves its session when private endpoint removal fails`() = test { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) @@ -531,17 +529,31 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.validateExternalIdentitySource()) - inOrder(privatePaykitRepo, pubkyService, pubkyStore) { - verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") - verify(pubkyService).removeBitkitPaymentEndpoints() - verify(pubkyService).clearExternalSessionAccess() - verify(pubkyStore).update(any()) - } + verifyBlocking(privatePaykitRepo) { removePublishedEndpointsForCleanup("PubkyRepo") } verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } + verifyBlocking(pubkyService, never()) { removeBitkitPaymentEndpoints() } + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } verify(pubkyStore, never()).reset() assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints) + assertTrue(settingsFlow.value.publicPaykitCleanupPending) assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) assertNull(pubkyDataFlow.value.externalIdentityRef) + assertNull(sut.publicKey.value) + + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) + clearInvocations(privatePaykitRepo, pubkyService, sharedPubkyDiscovery) + + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + + inOrder(privatePaykitRepo, pubkyService, sharedPubkyDiscovery) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") + verify(pubkyService).removeBitkitPaymentEndpoints() + verify(privatePaykitRepo).closeAndClear() + verify(pubkyService).clearExternalSessionAccess() + verify(sharedPubkyDiscovery).readRingCredential(identity.pubky) + } + assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending) + assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) } @Test @@ -557,15 +569,18 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } verifyBlocking(pubkyStore, never()) { reset() } whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit)) - clearInvocations(privatePaykitRepo, sharedPubkyDiscovery) + clearInvocations(privatePaykitRepo, pubkyService, sharedPubkyDiscovery) assertTrue(sut.adoptRingIdentity(identity).isSuccess) - inOrder(privatePaykitRepo, sharedPubkyDiscovery) { + inOrder(privatePaykitRepo, pubkyService, sharedPubkyDiscovery) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") verify(privatePaykitRepo).closeAndClear() + verify(pubkyService).clearExternalSessionAccess() verify(sharedPubkyDiscovery).readRingCredential(identity.pubky) } assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending) @@ -591,13 +606,33 @@ class PubkyRepoTest : BaseUnitTest() { assertTrue(result.isFailure, "$name must fail while private state cleanup is pending") assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) - verifyBlocking(privatePaykitRepo) { closeAndClear() } + inOrder(privatePaykitRepo) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") + verify(privatePaykitRepo).closeAndClear() + } verify(pubkyService, never()).registerIdentity(any(), any(), any()) verifyBlocking(pubkyService, never()) { importExternalSession(any()) } verifyBlocking(pubkyService, never()) { signIn(any()) } } } + @Test + fun `pending private endpoint cleanup failure preserves state and blocks identity replacement`() = test { + val identity = stubRingIdentity() + pubkyDataFlow.value = PubkyStoreData(privatePaykitStateCleanupPending = true) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenReturn(Result.failure(TestAppError("Private endpoint cleanup failed"))) + clearInvocations(privatePaykitRepo, sharedPubkyDiscovery) + + val result = sut.adoptRingIdentity(identity) + + assertTrue(result.isFailure) + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + verifyBlocking(privatePaykitRepo) { removePublishedEndpointsForCleanup("PubkyRepo") } + verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } + verifyBlocking(sharedPubkyDiscovery, never()) { readRingCredential(any()) } + } + @Test fun `generic local wipe preserves pending private state cleanup marker`() = test { pubkyDataFlow.value = PubkyStoreData( @@ -613,10 +648,14 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `initialize retries durable private state cleanup before restoring another session`() = test { + fun `initialize removes a retained cleanup session before resolving another session`() = test { + var savedSession: String? = "saved_session" pubkyDataFlow.value = PubkyStoreData(privatePaykitStateCleanupPending = true) - whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session") - whenever(pubkyService.importExternalSession("saved_session")).thenReturn(VALID_SELF_KEY) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenAnswer { savedSession } + whenever { pubkyService.clearExternalSessionAccess() }.thenAnswer { + savedSession = null + Unit + } whenever { privatePaykitRepo.closeAndClear() } .thenReturn(Result.failure(TestAppError("Private state cleanup failed"))) clearInvocations(privatePaykitRepo, pubkyService) @@ -634,15 +673,17 @@ class PubkyRepoTest : BaseUnitTest() { repo.initialize() inOrder(privatePaykitRepo, pubkyService) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") verify(privatePaykitRepo).closeAndClear() - verify(pubkyService).importExternalSession("saved_session") + verify(pubkyService).clearExternalSessionAccess() } - assertEquals(VALID_SELF_KEY, repo.publicKey.value) + verifyBlocking(pubkyService, never()) { importExternalSession(any()) } + assertNull(repo.publicKey.value) assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending) } @Test - fun `thrown private endpoint cleanup failure still disconnects without clearing private state`() = test { + fun `thrown private endpoint cleanup failure quarantines identity without clearing its session`() = test { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) @@ -653,13 +694,14 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.validateExternalIdentitySource()) verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } - verifyBlocking(pubkyService) { clearExternalSessionAccess() } + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) assertNull(pubkyDataFlow.value.externalIdentityRef) + assertNull(sut.publicKey.value) } @Test - fun `thrown private state cleanup failure still disconnects and preserves retry marker`() = test { + fun `thrown private state cleanup failure preserves session and retry marker`() = test { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) @@ -669,38 +711,63 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.validateExternalIdentitySource()) - verifyBlocking(pubkyService) { clearExternalSessionAccess() } + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) assertNull(pubkyDataFlow.value.externalIdentityRef) } @Test - fun `cancellation after external session deletion completes local teardown`() = test { + fun `cancellation after remote cleanup completes local teardown`() = test { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) - val sessionCleared = CompletableDeferred() + val privateCleanupStarted = CompletableDeferred() val resumePrivateCleanup = CompletableDeferred() whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) - whenever { pubkyService.clearExternalSessionAccess() }.thenAnswer { - sessionCleared.complete(Unit) - } whenever { privatePaykitRepo.closeAndClear() }.doSuspendableAnswer { + privateCleanupStarted.complete(Unit) resumePrivateCleanup.await() Result.success(Unit) } val validation = async { sut.validateExternalIdentitySource() } runCurrent() - assertTrue(sessionCleared.isCompleted) + assertTrue(privateCleanupStarted.isCompleted) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } validation.cancel() runCurrent() - assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) resumePrivateCleanup.complete(Unit) advanceUntilIdle() assertTrue(validation.isCancelled) + verifyBlocking(pubkyService) { clearExternalSessionAccess() } + assertNull(pubkyDataFlow.value.externalIdentityRef) + assertNull(sut.publicKey.value) + assertFalse(sut.isAuthenticated.value) + } + + @Test + fun `cancellation during remote cleanup quarantines identity and preserves its session`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + val cleanupStarted = CompletableDeferred() + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.doSuspendableAnswer { + cleanupStarted.complete(Unit) + awaitCancellation() + } + + val validation = async { sut.validateExternalIdentitySource() } + runCurrent() + assertTrue(cleanupStarted.isCompleted) + + validation.cancelAndJoin() + + verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) assertNull(pubkyDataFlow.value.externalIdentityRef) assertNull(sut.publicKey.value) assertFalse(sut.isAuthenticated.value) @@ -720,7 +787,7 @@ class PubkyRepoTest : BaseUnitTest() { } @Test - fun `source cleanup preserves marker when external session cleanup fails`() = test { + fun `source cleanup remains quarantined when external session cleanup fails`() = test { val identity = stubRingIdentity() assertTrue(sut.adoptRingIdentity(identity).isSuccess) val cleanupError = RuntimeException("cleanup failed") @@ -731,32 +798,9 @@ class PubkyRepoTest : BaseUnitTest() { val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() assertEquals(cleanupError.message, thrown?.message) - assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) - verify(pubkyStore, never()).reset() - } - - @Test - fun `source cleanup keeps marker after reset failure and succeeds on retry`() = test { - val identity = stubRingIdentity() - assertTrue(sut.adoptRingIdentity(identity).isSuccess) - val resetError = RuntimeException("reset failed") - whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) - doThrow(resetError).whenever(pubkyStore).reset() - clearInvocations(pubkyService, pubkyStore) - - val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() - - assertEquals(resetError.message, thrown?.message) - assertEquals(identity, pubkyDataFlow.value.externalIdentityRef) - - doAnswer { - pubkyDataFlow.value = PubkyStoreData() - Unit - }.whenever(pubkyStore).reset() - assertFalse(sut.validateExternalIdentitySource()) assertNull(pubkyDataFlow.value.externalIdentityRef) - verifyBlocking(pubkyService, times(2)) { clearExternalSessionAccess() } - verifyBlocking(pubkyStore, times(2)) { reset() } + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + verify(pubkyStore, never()).reset() } @Test @@ -1436,6 +1480,44 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } + @Test + fun `signOut retries pending source cleanup without revoking the borrowed session`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + settingsFlow.value = SettingsData(sharesPrivatePaykitEndpoints = true) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) } + .thenReturn(Result.failure(TestAppError("Private cleanup failed"))) + + assertFalse(sut.validateExternalIdentitySource()) + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + clearInvocations(privatePaykitRepo, pubkyService) + + val failedSignOut = sut.signOut() + + assertTrue(failedSignOut.isFailure) + verifyBlocking(privatePaykitRepo) { removePublishedEndpointsForCleanup("PubkyRepo") } + verifyBlocking(privatePaykitRepo, never()) { closeAndClear() } + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verifyBlocking(pubkyService, never()) { signOut() } + assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending) + + whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }.thenReturn(Result.success(Unit)) + clearInvocations(privatePaykitRepo, pubkyService) + + val completedSignOut = sut.signOut() + + assertTrue(completedSignOut.isSuccess) + inOrder(privatePaykitRepo, pubkyService) { + verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo") + verify(pubkyService).removeBitkitPaymentEndpoints() + verify(privatePaykitRepo).closeAndClear() + verify(pubkyService).clearExternalSessionAccess() + } + verifyBlocking(pubkyService, never()) { signOut() } + assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending) + } + @Test fun `signOut should evict pubky images from caches`() = test { authenticateForTesting()