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..7b8422fe92 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.SharedPubkyIdentity
import to.bitkit.models.PubkyProfileData
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,4 +39,6 @@ data class PubkyStoreData(
val cachedName: String? = null,
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/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..6931ab4ec7
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt
@@ -0,0 +1,102 @@
+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
+ }
+}
+
+@Serializable
+data class SharedPubkyIdentity(
+ val protocolVersion: Int,
+ val sourcePackage: String,
+ val pubky: String,
+) {
+ fun validated(): 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))
+ }
+}
+
+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")
+ 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")
+ 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..cc73e17cc6
--- /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.ProviderQueryFailed
+ }
+ }
+
+ 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.ProviderQueryFailed
+ }
+ }
+
+ @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.count != 1) {
+ 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..80321cdf32
--- /dev/null
+++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt
@@ -0,0 +1,193 @@
+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 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)
+ }
+
+ 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,
+ managedSecretQuarantine = managedSecretQuarantine,
+ 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,
+ managedSecretQuarantine: Result,
+ secretKeyHex: String?,
+ publicKeyFromSecret: (String) -> String,
+): LocalIdentity? {
+ 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)
+}
+
+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/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 17817cdc95..ae7798c9a4 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
@@ -34,10 +32,16 @@ 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
import to.bitkit.data.paykitDisabled
+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.isPaykitIdentityError
@@ -49,8 +53,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
@@ -58,29 +60,19 @@ 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.Provider
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(
@@ -91,6 +83,8 @@ class PubkyRepo @Inject constructor(
private val pubkyStore: PubkyStore,
private val settingsStore: SettingsStore,
private val httpClient: HttpClient,
+ private val sharedPubkyDiscovery: SharedPubkyDiscovery,
+ private val privatePaykitRepo: Provider,
) {
companion object {
private const val TAG = "PubkyRepo"
@@ -98,21 +92,17 @@ 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
- 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()
@@ -158,6 +148,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()
@@ -186,39 +177,16 @@ class PubkyRepo @Inject constructor(
if (it.isPaykitIdentityError() && hasSavedSession()) _sessionRestorationFailed.update { true }
}.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,
- )
+ retryPendingPrivatePaykitStateCleanupLocked()
+ 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) {
@@ -228,6 +196,72 @@ 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)
+ }.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 }
+ 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 }
+ Logger.info("Restored paykit session for '${redacted(publicKey)}'", context = TAG)
+ }
+
private fun hasSavedSession(): Boolean = runCatching {
keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)
}.getOrNull()?.isNotBlank() == true
@@ -244,10 +278,22 @@ class PubkyRepo @Inject constructor(
private suspend fun resolveSessionInitialization(
savedSessionSecret: String?,
storedSecretKeyHex: String?,
+ externalIdentityRef: SharedPubkyIdentity?,
): 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)
@@ -258,6 +304,53 @@ class PubkyRepo @Inject constructor(
}
}
+ private suspend fun resolveExternalSession(
+ savedSessionSecret: String?,
+ identityRef: SharedPubkyIdentity,
+ ): InitResult = withContext(ioDispatcher) {
+ val sourceIdentity = sharedPubkyDiscovery.discoverRingIdentities().getOrElse {
+ return@withContext externalSourceFailure(it)
+ }.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 externalSourceFailure(it)
+ }
+ 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 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?,
@@ -289,95 +382,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 {
- val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive())
- var shouldRevokeSessionOnFailure = false
- return 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) }
- 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) {
@@ -398,110 +402,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) {
@@ -576,11 +476,25 @@ class PubkyRepo @Inject constructor(
links: List,
tags: List,
avatarBytes: ByteArray?,
- ): Result {
+ ): Result = identityLifecycleMutex.withLock {
+ runSuspendCatching {
+ retryPendingPrivatePaykitStateCleanupLocked()
+ 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 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)
}
@@ -588,15 +502,16 @@ class PubkyRepo @Inject constructor(
}
var shouldRevokeSessionOnFailure = false
- return try {
+ try {
val result = runSuspendCatching {
withContext(ioDispatcher) {
settingsStore.setPubkyProfileSetupPending(false)
- val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
+ 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 }
@@ -624,6 +539,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,
@@ -631,7 +551,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
}
@@ -653,8 +573,8 @@ class PubkyRepo @Inject constructor(
tags = tags,
status = null,
)
+ enableLocalIdentityExport(publicKey)
_publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
_profile.update { createdProfile }
cacheMetadata(createdProfile)
settingsStore.setPubkyProfileSetupPending(false)
@@ -671,14 +591,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,
@@ -687,6 +612,7 @@ class PubkyRepo @Inject constructor(
imageUrl: String?,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
"No session available"
}
@@ -719,6 +645,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"
}
@@ -864,6 +792,7 @@ class PubkyRepo @Inject constructor(
existingProfile: PubkyProfile? = null,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = requireAddableContactPublicKey(
publicKey = publicKey,
allowExisting = existingProfile != null,
@@ -883,6 +812,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
@@ -901,6 +831,7 @@ class PubkyRepo @Inject constructor(
tags: List,
): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = publicKey.ensurePubkyPrefix()
val updatedProfile = PubkyProfile(
publicKey = prefixedKey,
@@ -924,6 +855,7 @@ class PubkyRepo @Inject constructor(
suspend fun removeContact(publicKey: String): Result = runSuspendCatching {
withContext(ioDispatcher) {
+ requireExternalIdentitySource()
val prefixedKey = publicKey.ensurePubkyPrefix()
pubkyService.removeContact(prefixedKey)
removeContactProfileOverride(prefixedKey)
@@ -935,6 +867,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()
@@ -992,11 +925,111 @@ class PubkyRepo @Inject constructor(
// endregion
+ // region Shared Pubky identities
+
+ suspend fun discoverRingIdentities(): Result> =
+ sharedPubkyDiscovery.discoverRingIdentities()
+
+ suspend fun adoptRingIdentity(identity: SharedPubkyIdentity): Result =
+ identityLifecycleMutex.withLock {
+ var shouldRollBackAdoption = false
+ try {
+ runSuspendCatching {
+ withContext(ioDispatcher) {
+ ensureServiceInitialized()
+ retryPendingPrivatePaykitStateCleanupLocked()
+ val canonicalIdentity = identity.validated()
+ 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()
+ pubkyStore.update { it.copy(externalIdentityRef = canonicalIdentity) }
+ shouldRollBackAdoption = true
+
+ val publicKey = signInWithExternalCredential(credential)
+
+ settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
+ notifyBackupStateChanged()
+ _publicKey.update { publicKey }
+ shouldRollBackAdoption = false
+ Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG)
+ loadProfile()
+ loadContacts()
+ }
+ }.onFailure {
+ rollBackAdoptedRingIdentityIfNeeded(shouldRollBackAdoption)
+ }
+ } catch (e: CancellationException) {
+ rollBackAdoptedRingIdentityIfNeeded(shouldRollBackAdoption)
+ 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()
+ }
+
+ 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()
+ .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()
+ 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) {
@@ -1026,10 +1059,14 @@ 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" }
+ retryPendingPrivatePaykitStateCleanupLocked()
+ if (pubkyStore.data.first().externalIdentityRef != null) {
+ throw SharedPubkyError.IdentityConflict
+ }
if (hasIdentity()) throw PubkyAlreadySignedInError
val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
@@ -1055,7 +1092,6 @@ class PubkyRepo @Inject constructor(
}
_publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
var pendingSaved = false
try {
settingsStore.setPubkyProfileSetupPending(true)
@@ -1082,8 +1118,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)
}
@@ -1095,8 +1132,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,
@@ -1118,6 +1156,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)
@@ -1145,7 +1191,9 @@ class PubkyRepo @Inject constructor(
withContext(ioDispatcher) {
ensureServiceInitialized()
- initializeMutex.withLock {
+ identityLifecycleMutex.withLock {
+ retryPendingPrivatePaykitStateCleanupLocked()
+ disableLocalIdentityExport()
runSuspendCatching { pubkyService.forgetSessionAccess() }
.onFailure {
Logger.warn(
@@ -1155,8 +1203,13 @@ 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) }
+ 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
@@ -1166,8 +1219,8 @@ 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 }
}
PubkySessionBackupKind.ExternalSession -> {
@@ -1175,8 +1228,8 @@ class PubkyRepo @Inject constructor(
"Missing session secret in backup"
}
val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix()
+ disableLocalIdentityExport()
_publicKey.update { publicKey }
- _authState.update { PubkyAuthState.Authenticated }
}
}
@@ -1196,19 +1249,45 @@ 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) {
+ retryPendingPrivatePaykitStateCleanupLocked()
+ val identityRef = pubkyStore.data.first().externalIdentityRef?.validated()
+ if (identityRef != null) {
+ if (!validateExternalIdentitySourceLocked()) return@withContext false
+ val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse {
+ if (it.isDefinitiveExternalSourceFailure()) clearUnavailableExternalIdentityLocked()
+ return@withContext false
+ }
+ val publicKey = signInWithExternalCredential(credential)
+ if (wirePubky(publicKey) != identityRef.pubky) {
+ clearUnavailableExternalIdentityLocked()
+ return@withContext false
+ }
+ notifyBackupStateChanged()
+ _publicKey.update { publicKey }
+ 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 }
+
+ true
+ }
}
}
@@ -1216,31 +1295,46 @@ 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) }
+ 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)
+ }
+ }
- val result = runSuspendCatching {
- pubkyService.signOut()
- }.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) }
+ runSuspendCatching { disableLocalIdentityExport() }
+ .onFailure { Logger.error("Failed to disable shared Pubky export", it, context = TAG) }
+ .exceptionOrNull()
+ ?.let { return@withContext Result.failure(it) }
- 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)
+ 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 {
@@ -1249,6 +1343,10 @@ class PubkyRepo @Inject constructor(
clearLocalState()
}
+ suspend fun disableSharedIdentityExport(): Result = identityLifecycleMutex.withLock {
+ runSuspendCatching { disableLocalIdentityExport() }
+ }
+
// endregion
// region Private helpers
@@ -1368,7 +1466,163 @@ class PubkyRepo @Inject constructor(
}
}
+ private suspend fun signInWithExternalCredential(credential: SharedPubkyCredential): String =
+ withContext(ioDispatcher) {
+ val identity = credential.identity.validated()
+ 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 {
+ if (it.isDefinitiveExternalSourceFailure()) 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,
+ )
+ }
+
+ // 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()
+ .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) }
+ } else {
+ Result.success(Unit)
+ }
+
+ 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()
+ clearPublicPaykitSharingState(
+ publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState,
+ )
+ clearAuthenticatedRuntimeState()
+ pubkyStore.update { it.copy(privatePaykitStateCleanupPending = false) }
+ resetPubkyMetadataPreservingPrivatePaykitCleanupMarker()
+ notifyBackupStateChanged()
+ Result.success(Unit)
+ }
+ }
+
+ private suspend fun retryPendingPrivatePaykitStateCleanupLocked() {
+ if (!pubkyStore.data.first().privatePaykitStateCleanupPending) return
+ val privatePaykit = privatePaykitRepo.get()
+ privatePaykit.removePublishedEndpointsForCleanup(TAG).getOrThrow()
+ finishUnavailableExternalIdentityTeardown(privatePaykit).getOrThrow()
+ }
+
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
@@ -1385,7 +1639,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
}
@@ -1402,15 +1659,26 @@ class PubkyRepo @Inject constructor(
}
private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) {
+ 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()
- runSuspendCatching { pubkyStore.reset() }
_publicKey.update { null }
_profile.update { null }
_contacts.update { emptyList() }
_contactsLoadVersion.update { 0L }
clearPendingImport()
_sessionRestorationFailed.update { false }
- _authState.update { PubkyAuthState.Idle }
}
private fun markContactsLoaded() {
@@ -1418,8 +1686,20 @@ 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) }
+ 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()
@@ -1459,6 +1739,21 @@ 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.matches(other: SharedPubkyIdentity): Boolean =
+ protocolVersion == other.protocolVersion &&
+ sourcePackage == other.sourcePackage &&
+ SharedPubkyContract.requireWirePubky(pubky) ==
+ SharedPubkyContract.requireWirePubky(other.pubky)
+
+ private fun SharedPubkyCredential.matches(identityRef: SharedPubkyIdentity): 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..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 = {
@@ -388,7 +386,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,56 +407,13 @@ class PaykitSdkService @Inject constructor(
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
- shouldStoreLocalSecret = true,
+ shouldStoreLocalSecret = shouldStoreLocalSecret,
)
}
notifyBackupStateChanged()
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,
@@ -955,7 +919,6 @@ class PaykitSdkService @Inject constructor(
suspend fun forgetSessionAccess() {
isSetup.await()
operationMutex.withLock {
- activeAuthRequest = null
try {
withStateRevisionTracking { handle ->
handle.forgetSessionAccess()
@@ -966,6 +929,27 @@ 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"
+ }
+ resetRuntime()
+ notifyBackupStateChanged()
+ }
+ }
+
suspend fun clearState() {
operationMutex.withLock {
clearStateLocked()
@@ -974,7 +958,6 @@ class PaykitSdkService @Inject constructor(
private suspend fun clearStateLocked() {
keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name)
- activeAuthRequest = null
resetRuntime()
notifyBackupStateChanged()
}
@@ -992,13 +975,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 +1008,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 +1225,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 +1284,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 +1308,39 @@ 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(
+ clientId = access.clientId(),
+ 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 +1378,20 @@ 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) }
+ // 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()
+ 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..70da40131a 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,21 +113,8 @@ class PubkyService @Inject constructor(
Unit
}
- // 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()
+ suspend fun signInExternal(secretKeyHex: String): String = ServiceQueue.CORE.background {
+ paykitSdkService.signInExternal(secretKeyHex)
}
// endregion
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index c7711c6071..17474dd914 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -1555,9 +1555,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 41d3bd79df..60675f618c 100644
--- a/app/src/main/java/to/bitkit/ui/MainActivity.kt
+++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt
@@ -240,6 +240,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/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt
index d32233b387..b6f2698629 100644
--- a/app/src/main/java/to/bitkit/ui/components/Button.kt
+++ b/app/src/main/java/to/bitkit/ui/components/Button.kt
@@ -36,7 +36,9 @@ import androidx.compose.ui.text.TextStyle
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
@@ -112,6 +114,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
@@ -165,7 +168,7 @@ fun PrimaryButton(
text?.let {
Text(
text = text,
- style = size.textStyle(),
+ style = size.textStyle().copy(letterSpacing = letterSpacing),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -187,6 +190,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)
@@ -252,7 +256,7 @@ fun SecondaryButton(
text?.let {
Text(
text = text,
- style = size.textStyle(),
+ style = size.textStyle().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 e3d4146ca6..0a51d72765 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,
@@ -225,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),
@@ -233,6 +238,7 @@ fun BodyMSB(
overflow = overflow,
modifier = modifier,
textAlign = textAlign,
+ letterSpacing = letterSpacing,
)
}
@@ -244,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/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt
index 854541911d..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
@@ -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,26 @@ 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.data.sharing.SharedPubkyIdentity
+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 +53,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 +68,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 +94,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 +107,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 +118,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 +130,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 +272,63 @@ 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(
+ identity = SharedPubkyIdentity(
+ 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..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
@@ -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,95 @@ 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(
+ identity = identity,
+ 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.identity)
.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,
- )
+ reportSelectionError("Preparing shared profile failed", it)
+ _effects.emit(PubkyChoiceEffect.NavigateToPayContacts)
}
}
.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,
- )
+ reportSelectionError("Connecting shared profile failed", it)
+ refreshRingIdentities()
}
}
}
- 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 reportSelectionError(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,
+ )
}
}
-@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 identity: SharedPubkyIdentity,
+ val profile: PubkyProfile,
+) {
+ val pubky: String get() = identity.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/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
index b1015e656f..6786f6ef4c 100644
--- a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
+++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt
@@ -58,6 +58,9 @@ class WipeWalletUseCase @Inject constructor(
lightningRepo.setWiping(true)
val result = try {
runSuspendCatching {
+ // 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/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 1503501d54..0dc7e7f760 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
@@ -5484,11 +5483,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(
@@ -5558,21 +5553,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, "")
@@ -5589,6 +5569,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 7ac0ae8f9f..7d91cdfa55 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
@@ -656,11 +656,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…
@@ -697,14 +696,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/serializers/PubkyStoreSerializerTest.kt b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt
new file mode 100644
index 0000000000..b41f604aea
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/serializers/PubkyStoreSerializerTest.kt
@@ -0,0 +1,92 @@
+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": {},
+ "privatePaykitStateCleanupPending": false,
+ "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 `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(
+ 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
new file mode 100644
index 0000000000..50911705d0
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt
@@ -0,0 +1,105 @@
+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 {
+ SharedPubkyIdentity(
+ protocolVersion = 2,
+ sourcePackage = SharedPubkyContract.RING_SOURCE,
+ pubky = WIRE_PUBKY,
+ ).validated()
+ }
+ assertFailsWith {
+ 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/data/sharing/SharedPubkyDiscoveryTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt
new file mode 100644
index 0000000000..8d92f4d643
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyDiscoveryTest.kt
@@ -0,0 +1,198 @@
+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 credential response proves the identity is unavailable`() = test {
+ assertSame(SharedPubkyError.IdentityUnavailable, readCredential(cursor()).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
+ 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 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) }
+ }
+
+ 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)
+}
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..b9ea1cefbb
--- /dev/null
+++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt
@@ -0,0 +1,94 @@
+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
+
+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,
+ managedSecretQuarantine = Result.success(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,
+ managedSecretQuarantine = Result.success(false),
+ secretKeyHex = SECRET_KEY,
+ publicKeyFromSecret = { WIRE_PUBKY },
+ )
+
+ assertNull(identity)
+ }
+
+ @Test
+ fun `quarantined managed identity is never exported`() {
+ val identity = localSharedPubkyIdentity(
+ exportEnabled = true,
+ managedSecretQuarantine = Result.success(true),
+ secretKeyHex = SECRET_KEY,
+ publicKeyFromSecret = { error("Must not derive a quarantined identity") },
+ )
+
+ 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,
+ managedSecretQuarantine = Result.success(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/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 5b8d001cea..9b055cb073 100644
--- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
@@ -21,18 +21,23 @@ 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.doSuspendableAnswer
+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
@@ -44,18 +49,23 @@ 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.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
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
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
@@ -65,12 +75,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
@@ -80,26 +93,58 @@ class PubkyRepoTest : BaseUnitTest() {
private val imageLoader = mock()
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())
+ 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(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
}
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(
@@ -110,6 +155,8 @@ class PubkyRepoTest : BaseUnitTest() {
pubkyStore = pubkyStore,
settingsStore = settingsStore,
httpClient = httpClient,
+ sharedPubkyDiscovery = sharedPubkyDiscovery,
+ privatePaykitRepo = privatePaykitRepoProvider,
)
@Test
@@ -250,408 +297,596 @@ class PubkyRepoTest : BaseUnitTest() {
}
@Test
- fun `startAuthentication should return auth uri on success`() = test {
- val authUri = "pubky://auth?capabilities=..."
- whenever(pubkyService.startAuth()).thenReturn(authUri)
+ fun `adopt Ring identity persists source reference and never stores shared secret`() = test {
+ val identity = stubRingIdentity()
- val result = sut.startAuthentication()
+ val result = sut.adoptRingIdentity(identity)
assertTrue(result.isSuccess)
- assertEquals(authUri, result.getOrNull()?.authUrl)
- assertNotNull(result.getOrNull()?.callbackNonce)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertEquals(identity, 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 `startAuthentication should reset state on failure`() = test {
- whenever(pubkyService.startAuth()).thenAnswer { throw TestAppError("Auth failed") }
+ fun `adopt Ring identity rejects credential whose secret derives another pubky`() = test {
+ val identity = stubRingIdentity(derivedPublicKey = VALID_CONTACT_KEY_B)
- val result = sut.startAuthentication()
+ val result = sut.adoptRingIdentity(identity)
assertTrue(result.isFailure)
- sut.isAuthenticated.test(timeout = 500.milliseconds) {
- assertFalse(awaitItem())
- }
+ assertNull(sut.publicKey.value)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { signInExternal(SHARED_SECRET_KEY) }
}
@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)
+ 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 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 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 authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ val wrongVersionResult = sut.adoptRingIdentity(identity)
- assertTrue(result.isSuccess)
- assertEquals(VALID_SELF_KEY, sut.publicKey.value)
- assertTrue(sut.isAuthenticated.value)
+ assertEquals(SharedPubkyError.InvalidResponse, wrongVersionResult.exceptionOrNull())
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { signInExternal(any()) }
}
@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)
+ 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 authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ val adoption = async { sut.adoptRingIdentity(identity) }
+ signInStarted.await()
+ adoption.cancelAndJoin()
- assertTrue(result.isSuccess)
- verify(pubkyService).contactRecords()
+ assertTrue(adoption.isCancelled)
+ assertNull(sut.publicKey.value)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ assertNull(sharedExportEnabled)
+ verifyBlocking(pubkyService) { clearExternalSessionAccess() }
}
@Test
- fun `completeAuthentication should reset state on failure`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- whenever(pubkyService.completeAuth()).thenAnswer { throw TestAppError("Failed") }
+ 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 authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ val adoption = async { sut.adoptRingIdentity(identity) }
+ profileLoadStarted.await()
+ adoption.cancelAndJoin()
- assertTrue(result.isFailure)
- assertFalse(sut.isAuthenticated.value)
- assertNull(sut.publicKey.value)
- verifyBlocking(pubkyService) { signOut() }
+ assertTrue(adoption.isCancelled)
+ assertEquals(VALID_SELF_KEY, sut.publicKey.value)
+ assertEquals(identity, pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
}
@Test
- fun `completeAuthentication should fail when auth attempt inactive`() = test {
- val result = sut.completeAuthentication()
+ fun `Ring managed identity reads credential just in time for auth approval`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
- assertTrue(result.isFailure)
- verifyBlocking(pubkyService, never()) { completeAuth() }
+ val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw", "paykit.test")
+
+ assertTrue(result.isSuccess)
+ verifyBlocking(pubkyService) {
+ approveAuth("pubkyauth://signin", "/pub/example/:rw", "paykit.test", SHARED_SECRET_KEY)
+ }
+ verifyBlocking(keychain, never()) {
+ upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY)
+ }
}
@Test
- fun `completeAuthentication should fail when auth is canceled before approval`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
+ 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 = async { sut.completeAuthentication() }
- sut.cancelAuthentication()
+ val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw", "paykit.test")
- assertTrue(result.await().isFailure)
- verifyBlocking(pubkyService, never()) { completeAuth() }
+ 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 `approveAuth should forward requested capabilities`() = test {
- val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw"
- val capabilities = "/pub/bitkit.to/:rw"
- val clientId = "paykit.test"
- val secretKey = "local_secret"
- whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
+ 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 result = sut.approveAuth(authUrl, capabilities, clientId)
+ val available = sut.validateExternalIdentitySource()
- assertTrue(result.isSuccess)
- verifyBlocking(pubkyService) { approveAuth(authUrl, capabilities, clientId, secretKey) }
+ assertFalse(available)
+ assertNull(sut.publicKey.value)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ inOrder(pubkyService, pubkyStore) {
+ verify(pubkyService).clearExternalSessionAccess()
+ verify(pubkyStore).reset()
+ }
+ verifyBlocking(pubkyService, never()) { signOut() }
+ verifyBlocking(pubkyService, never()) { forgetSessionAccess() }
}
@Test
- fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test {
- val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1"
- val clientId = "paykit.test"
- val secretKey = "local_secret"
- val payload = ByteArray(84) { it.toByte() }
- whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
+ 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)
- val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload)
+ listOf(
+ TestAppError("Provider unavailable"),
+ SharedPubkyError.ProviderQueryFailed,
+ ).forEach {
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.failure(it))
- assertTrue(result.isSuccess)
- verifyBlocking(pubkyService) {
- approveAuthWithCompanionClaim(
- authUrl = authUrl,
- expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES,
- approvedClientId = clientId,
- secretKeyHex = secretKey,
- claim = PubkyAuthCompanionClaim(
- queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
- claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
- unsignedPayload = payload,
- ),
- )
+ 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 `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") }
+ 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 authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ val available = sut.validateExternalIdentitySource()
- assertTrue(result.isFailure)
- verifyBlocking(pubkyService) { signOut() }
- verifyBlocking(pubkyService) { forgetSessionAccess() }
+ assertFalse(available)
+ assertNull(sut.publicKey.value)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ inOrder(pubkyService, pubkyStore) {
+ verify(pubkyService).clearExternalSessionAccess()
+ verify(pubkyStore).reset()
+ }
}
@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
+ 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(
+ hasConfirmedPublicPaykitEndpoints = true,
+ sharesPublicPaykitEndpoints = true,
+ )
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ clearInvocations(privatePaykitRepo, pubkyService)
+
+ assertFalse(sut.validateExternalIdentitySource())
+
+ inOrder(privatePaykitRepo, pubkyService) {
+ verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo")
+ verify(pubkyService).removeBitkitPaymentEndpoints()
+ verify(privatePaykitRepo).closeAndClear()
+ verify(pubkyService).clearExternalSessionAccess()
}
- whenever(pubkyService.signOut()).thenAnswer { throw TestAppError("Server error") }
- whenever(pubkyService.forgetSessionAccess()).thenAnswer { throw TestAppError("Cleanup error") }
+ assertFalse(settingsFlow.value.sharesPublicPaykitEndpoints)
+ assertFalse(settingsFlow.value.publicPaykitCleanupPending)
+ }
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ @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(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)
+ assertFalse(settingsFlow.value.sharesPrivatePaykitEndpoints)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
}
@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()
+ 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)
+ 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())
+
+ 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
+ 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)
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = async { sut.completeAuthentication() }
- completionStarted.await()
+ assertFalse(sut.validateExternalIdentitySource())
- result.cancel()
- verifyBlocking(pubkyService, never()) { signOut() }
- finishCompletion.complete(Unit)
- result.join()
+ assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
+ verifyBlocking(pubkyStore, never()) { reset() }
- verifyBlocking(pubkyService) { signOut() }
+ whenever { privatePaykitRepo.closeAndClear() }.thenReturn(Result.success(Unit))
+ clearInvocations(privatePaykitRepo, pubkyService, sharedPubkyDiscovery)
+
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+
+ inOrder(privatePaykitRepo, pubkyService, sharedPubkyDiscovery) {
+ verify(privatePaykitRepo).removePublishedEndpointsForCleanup("PubkyRepo")
+ verify(privatePaykitRepo).closeAndClear()
+ verify(pubkyService).clearExternalSessionAccess()
+ verify(sharedPubkyDiscovery).readRingCredential(identity.pubky)
+ }
+ assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ assertEquals(identity, pubkyDataFlow.value.externalIdentityRef)
}
@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())
+ 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)
+ 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()) }
}
+ }
- val authRequest = startAuthForTesting()
- approveAuthForTesting(authRequest)
- val result = async { sut.completeAuthentication() }
- profileLoadStarted.await()
+ @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)
- assertTrue(sut.isAuthenticated.value)
- result.cancel()
- finishProfileLoad.complete(Unit)
- result.join()
+ val result = sut.adoptRingIdentity(identity)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { signOut() }
+ assertTrue(result.isFailure)
+ assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ verifyBlocking(privatePaykitRepo) { removePublishedEndpointsForCleanup("PubkyRepo") }
+ verifyBlocking(privatePaykitRepo, never()) { closeAndClear() }
+ verifyBlocking(sharedPubkyDiscovery, never()) { readRingCredential(any()) }
}
@Test
- fun `cancelAuthentication should reset state to idle`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
+ fun `generic local wipe preserves pending private state cleanup marker`() = test {
+ pubkyDataFlow.value = PubkyStoreData(
+ cachedName = "Old identity",
+ privatePaykitStateCleanupPending = true,
+ )
+ clearInvocations(pubkyStore)
- sut.cancelAuthentication()
+ sut.wipeLocalState()
- assertFalse(sut.isAuthenticated.value)
+ assertEquals(PubkyStoreData(privatePaykitStateCleanupPending = true), pubkyDataFlow.value)
+ verify(pubkyStore, never()).reset()
}
@Test
- fun `cancelAuthentication should keep restored profile authenticated`() = test {
- authenticateForTesting()
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
+ 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)).thenAnswer { savedSession }
+ whenever { pubkyService.clearExternalSessionAccess() }.thenAnswer {
+ savedSession = null
+ Unit
+ }
+ whenever { privatePaykitRepo.closeAndClear() }
+ .thenReturn(Result.failure(TestAppError("Private state cleanup failed")))
+ clearInvocations(privatePaykitRepo, pubkyService)
+ val repo = createSut()
- sut.cancelAuthentication()
+ repo.awaitInitialization()
- assertTrue(sut.isAuthenticated.value)
- assertNotNull(sut.publicKey.value)
+ 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).removePublishedEndpointsForCleanup("PubkyRepo")
+ verify(privatePaykitRepo).closeAndClear()
+ verify(pubkyService).clearExternalSessionAccess()
+ }
+ verifyBlocking(pubkyService, never()) { importExternalSession(any()) }
+ assertNull(repo.publicKey.value)
+ assertFalse(pubkyDataFlow.value.privatePaykitStateCleanupPending)
}
@Test
- fun `handleAuthCallback should reject invalid success nonce`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
+ 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()))
+ whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any()) }
+ .thenAnswer { throw IllegalStateException("Private cleanup crashed") }
+ clearInvocations(privatePaykitRepo, pubkyService, pubkyStore)
- val result = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = "invalid"))
+ assertFalse(sut.validateExternalIdentitySource())
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
+ verifyBlocking(privatePaykitRepo, never()) { closeAndClear() }
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
+ assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ assertNull(sut.publicKey.value)
}
@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()
+ 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()))
+ whenever { privatePaykitRepo.closeAndClear() }
+ .thenAnswer { throw IllegalStateException("Private state cleanup crashed") }
+ clearInvocations(privatePaykitRepo, pubkyService)
- val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Success(nonce = null))
- val result = sut.completeAuthentication()
+ assertFalse(sut.validateExternalIdentitySource())
- assertEquals(PubkyRingAuthCallbackHandlingResult.Handled, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
+ assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
}
@Test
- fun `handleAuthCallback should ignore invalid cancel nonce`() = test {
- whenever(pubkyService.startAuth()).thenReturn("auth_uri")
- sut.startAuthentication()
+ fun `cancellation after remote cleanup completes local teardown`() = test {
+ val identity = stubRingIdentity()
+ assertTrue(sut.adoptRingIdentity(identity).isSuccess)
+ val privateCleanupStarted = CompletableDeferred()
+ val resumePrivateCleanup = CompletableDeferred()
+ whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ whenever { privatePaykitRepo.closeAndClear() }.doSuspendableAnswer {
+ privateCleanupStarted.complete(Unit)
+ resumePrivateCleanup.await()
+ Result.success(Unit)
+ }
+
+ val validation = async { sut.validateExternalIdentitySource() }
+ runCurrent()
+ assertTrue(privateCleanupStarted.isCompleted)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
- val result = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid"))
+ validation.cancel()
+ runCurrent()
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result)
+ resumePrivateCleanup.complete(Unit)
+ advanceUntilIdle()
+
+ assertTrue(validation.isCancelled)
+ verifyBlocking(pubkyService) { clearExternalSessionAccess() }
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ assertNull(sut.publicKey.value)
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()
+ 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 result = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"),
- )
+ val validation = async { sut.validateExternalIdentitySource() }
+ runCurrent()
+ assertTrue(cleanupStarted.isCompleted)
+
+ validation.cancelAndJoin()
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, result)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
+ verifyBlocking(privatePaykitRepo, never()) { closeAndClear() }
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
+ assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ assertNull(sut.publicKey.value)
+ assertFalse(sut.isAuthenticated.value)
}
@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()
+ 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)
- val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = null))
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ assertFalse(sut.validateExternalIdentitySource())
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
+ verifyBlocking(pubkyService, never()) { removeBitkitPaymentEndpoints() }
+ assertFalse(settingsFlow.value.publicPaykitCleanupPending)
}
@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()
+ fun `source cleanup remains quarantined 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 callbackResult = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Forged error", nonce = null),
- )
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull()
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
+ assertEquals(cleanupError.message, thrown?.message)
+ assertNull(pubkyDataFlow.value.externalIdentityRef)
+ assertTrue(pubkyDataFlow.value.privatePaykitStateCleanupPending)
+ verify(pubkyStore, never()).reset()
}
@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()
+ 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")
- val callbackResult = sut.handleAuthCallback(PubkyRingAuthCallback.Cancel(nonce = "invalid"))
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ assertFalse(sut.validateExternalIdentitySource())
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
- assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
+ verifyBlocking(keychain) {
+ upsertString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, "1")
+ }
+ verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
}
@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()
+ 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 callbackResult = sut.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Forged error", nonce = "invalid"),
- )
- approveAuthForTesting(authRequest)
- val result = sut.completeAuthentication()
+ val adoption = async { sut.adoptRingIdentity(identity) }
+ runCurrent()
+ val restore = async { sut.restoreSessionBackupState(null) }
+ runCurrent()
+
+ assertFalse(restore.isCompleted)
+ verifyBlocking(pubkyService, never()) { forgetSessionAccess() }
+
+ releaseExternalSignIn.complete(Unit)
+ advanceUntilIdle()
+
+ assertTrue(adoption.await().isSuccess)
+ assertTrue(restore.await().isSuccess)
+ verifyBlocking(pubkyService) { forgetSessionAccess() }
+ }
+
+ @Test
+ fun `approveAuth should forward requested capabilities`() = test {
+ val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw"
+ val capabilities = "/pub/bitkit.to/:rw"
+ val clientId = "paykit.test"
+ val secretKey = "local_secret"
+ 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)
- assertEquals(PubkyRingAuthCallbackHandlingResult.Ignored, callbackResult)
assertTrue(result.isSuccess)
- assertTrue(sut.isAuthenticated.value)
- verifyBlocking(pubkyService, never()) { cancelAuth() }
+ verifyBlocking(pubkyService) { approveAuth(authUrl, capabilities, clientId, secretKey) }
}
@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"
- }
+ fun `approveAuthWithCompanionClaim forwards exact claim identifiers and capability`() = test {
+ val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1"
+ val clientId = "paykit.test"
+ val secretKey = "local_secret"
+ val payload = ByteArray(84) { it.toByte() }
+ 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.handleAuthCallback(
- PubkyRingAuthCallback.Error(message = "Ring failed", nonce = authRequest.callbackNonce),
- )
+ val result = sut.approveAuthWithCompanionClaim(authUrl, clientId, payload)
- assertEquals(PubkyRingAuthCallbackHandlingResult.TrustedError("Ring failed"), result)
- verifyBlocking(pubkyService) { cancelAuth() }
+ assertTrue(result.isSuccess)
+ verifyBlocking(pubkyService) {
+ approveAuthWithCompanionClaim(
+ authUrl = authUrl,
+ expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES,
+ approvedClientId = clientId,
+ secretKeyHex = secretKey,
+ claim = PubkyAuthCompanionClaim(
+ queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
+ claimType = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_V1.wireValue,
+ unsignedPayload = payload,
+ ),
+ )
+ }
}
@Test
@@ -681,6 +916,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
@@ -818,13 +1074,136 @@ 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()
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)
@@ -837,11 +1216,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") }
@@ -859,6 +1239,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
@@ -870,7 +1259,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()))
@@ -1086,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()
@@ -1300,7 +1732,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() }
@@ -1312,14 +1744,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())
@@ -1365,6 +1797,30 @@ class PubkyRepoTest : BaseUnitTest() {
verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
}
+ @Test
+ fun `initialize preserves borrowed identity without exporting it when startup has an identity error`() = test {
+ val identity = stubRingIdentity()
+ pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity)
+ whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session")
+ whenever(pubkyService.initialize()).thenAnswer {
+ throw AppError(PaykitException.Identity("identity_error", "Missing capabilities"))
+ }
+ clearInvocations(keychain, pubkyService, pubkyStore, sharedPubkyDiscovery)
+ val repo = createSut()
+
+ repo.awaitInitialization()
+
+ assertTrue(repo.sessionRestorationFailed.value)
+ assertFalse(repo.isAuthenticated.value)
+ assertEquals(identity, pubkyDataFlow.value.externalIdentityRef)
+ assertNull(sharedExportEnabled)
+ verifyBlocking(keychain, never()) { delete(any()) }
+ verifyBlocking(keychain, never()) { upsertString(any(), any()) }
+ verifyBlocking(pubkyStore, never()) { reset() }
+ verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() }
+ verifyBlocking(sharedPubkyDiscovery, never()) { readRingCredential(any()) }
+ }
+
@Test
fun `initialize should not flag session restoration failure when service startup fails with non-identity error`() =
test {
@@ -1402,7 +1858,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))
@@ -1430,12 +1886,34 @@ 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"
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()
@@ -1444,6 +1922,179 @@ 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)
+ 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
+ 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 `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"
@@ -1472,6 +2123,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"))
@@ -1553,6 +2205,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)
@@ -1647,15 +2346,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)
}
@@ -1690,16 +2387,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")))
}
@@ -1895,6 +2590,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()
@@ -1932,13 +2646,39 @@ 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",
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) }
@@ -1946,19 +2686,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/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
index 005e78b71e..92ac914c42 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()
@@ -255,7 +256,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 +267,83 @@ 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 `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"
+
+ 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..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
@@ -1,19 +1,20 @@
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.times
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 +22,178 @@ 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)
+ whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(emptyList()))
+ Unit
}
- private fun createSut() {
- sut = PubkyChoiceViewModel(
- context = context,
- pubkyRepo = pubkyRepo,
+ @Test
+ 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()
+
+ 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 `session restoration redirects to profile when already authenticated`() = test {
- isAuthenticated.value = true
- createSut()
-
+ 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()
- assertTrue(sut.uiState.value.navigateToProfile)
+ 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 `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 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(it) } }
- val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } }
-
- sut.startRingAuth()
+ val effectsJob = launch { sut.effects.collect(effects::add) }
+ val toastsJob = 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()
-
+ 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()
- toastJob.cancel()
+ toastsJob.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) } }
-
- sut.onRingLaunchFailed()
+ 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()
- assertFalse(sut.uiState.value.isWaitingForRing)
- assertTrue(sut.uiState.value.showRingNotInstalledDialog)
- assertTrue(effects.isEmpty())
- assertTrue(toasts.isEmpty())
- verify(pubkyRepo).cancelAuthentication()
+ val toasts = mutableListOf()
+ val job = launch { ToastEventBus.events.collect(toasts::add) }
+ sut.selectRingIdentity(sut.uiState.value.identities.single())
+ advanceUntilIdle()
- 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()
}
+
+ private fun createSut() = PubkyChoiceViewModel(
+ context = context,
+ pubkyRepo = pubkyRepo,
+ )
+
+ private fun ringIdentity() = SharedPubkyIdentity(
+ protocolVersion = SharedPubkyContract.PROTOCOL_VERSION,
+ sourcePackage = SharedPubkyContract.RING_SOURCE,
+ pubky = WIRE_PUBKY,
+ )
}
diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt
index e7680b246c..2df2be66c3 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))
@@ -128,6 +130,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())
@@ -175,6 +178,28 @@ 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(migrationService, never()).setNeedsPostMigrationSync(any())
+ verify(migrationService, never()).cleanupAfterMigration()
+ 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)
diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index 5f47896f2c..2c4e30f173 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"
@@ -2756,13 +2766,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
diff --git a/changelog.d/next/1109.added.md b/changelog.d/next/1109.added.md
new file mode 100644
index 0000000000..632fbf2f1d
--- /dev/null
+++ b/changelog.d/next/1109.added.md
@@ -0,0 +1 @@
+Added secure reuse of Pubky Ring profiles in Bitkit and sharing of Bitkit-owned Pubky identities with Ring.