diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 466b64fdb9..4eae59f2cf 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 @@ + + + + @@ -188,6 +194,13 @@ android:exported="false" tools:node="remove" /> + + diff --git a/app/src/main/java/to/bitkit/data/PubkyStore.kt b/app/src/main/java/to/bitkit/data/PubkyStore.kt index 3dcc132236..7eb5c9053b 100644 --- a/app/src/main/java/to/bitkit/data/PubkyStore.kt +++ b/app/src/main/java/to/bitkit/data/PubkyStore.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable import to.bitkit.data.serializers.PubkyStoreSerializer +import to.bitkit.data.sharing.ExternalPubkyIdentityRef import to.bitkit.models.PubkyProfileData import javax.inject.Inject import javax.inject.Singleton @@ -38,4 +39,5 @@ data class PubkyStoreData( val cachedName: String? = null, val cachedImageUri: String? = null, val contactProfileOverrides: Map = emptyMap(), + val externalIdentityRef: ExternalPubkyIdentityRef? = null, ) diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 00d4f31f52..21730cc512 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -234,6 +234,8 @@ class Keychain @Inject constructor( PAYKIT_SESSION, PAYKIT_RECEIVER_NOISE_SECRET_KEY, PAYKIT_SDK_STATE, + PUBKY_MANAGED_SECRET_QUARANTINED, + PUBKY_SHARED_EXPORT_ENABLED, PUBKY_SECRET_KEY, } } diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt new file mode 100644 index 0000000000..a5f49a13b2 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt @@ -0,0 +1,107 @@ +package to.bitkit.data.sharing + +import android.net.Uri +import kotlinx.serialization.Serializable +import to.bitkit.utils.AppError +import java.util.Locale + +object SharedPubkyContract { + const val PROTOCOL_VERSION = 1 + const val BITKIT_SOURCE = "to.bitkit" + const val RING_SOURCE = "app.pubkyring" + const val RING_AUTHORITY = "app.pubkyring.sharedpubky" + const val RING_READ_PERMISSION = "app.pubkyring.permission.READ_SHARED_PUBKY" + const val IDENTITIES_PATH = "v1/identities" + const val RING_IDENTITIES_URI = "content://$RING_AUTHORITY/$IDENTITIES_PATH" + + const val COLUMN_PROTOCOL_VERSION = "protocol_version" + const val COLUMN_SOURCE_PACKAGE = "source_package" + const val COLUMN_PUBKY = "pubky" + const val COLUMN_SECRET_KEY = "secret_key" + + private const val BITKIT_PUBKY_PREFIX = "pubky" + private const val WIRE_PUBKY_LENGTH = 52 + private const val CREDENTIAL_SEGMENT = "credential" + private val wirePubkyPattern = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$") + private val secretKeyPattern = Regex("^[0-9a-f]{64}$") + + val publicColumns = arrayOf( + COLUMN_PROTOCOL_VERSION, + COLUMN_SOURCE_PACKAGE, + COLUMN_PUBKY, + ) + val credentialColumns = publicColumns + COLUMN_SECRET_KEY + + val ringIdentitiesUri: Uri + get() = Uri.parse(RING_IDENTITIES_URI) + + fun ringCredentialUri(pubky: String): Uri = Uri.parse(ringCredentialUriString(pubky)) + + internal fun ringCredentialUriString(pubky: String): String = + "$RING_IDENTITIES_URI/${canonicalPubky(pubky)}/$CREDENTIAL_SEGMENT" + + fun canonicalPubky(value: String): String { + val normalizedPubky = value.trim().lowercase(Locale.US) + val barePubky = if ( + normalizedPubky.length == WIRE_PUBKY_LENGTH + BITKIT_PUBKY_PREFIX.length && + normalizedPubky.startsWith(BITKIT_PUBKY_PREFIX) + ) { + normalizedPubky.removePrefix(BITKIT_PUBKY_PREFIX) + } else { + normalizedPubky + } + require(wirePubkyPattern.matches(barePubky)) { "Invalid shared Pubky public key" } + return barePubky + } + + fun requireWirePubky(value: String): String { + require(wirePubkyPattern.matches(value)) { "Invalid shared Pubky wire public key" } + return value + } + + fun toBitkitPubky(value: String): String = "$BITKIT_PUBKY_PREFIX${canonicalPubky(value)}" + + fun canonicalSecretKeyHex(value: String): String { + require(secretKeyPattern.matches(value)) { "Invalid shared Pubky secret key" } + return value + } +} + +data class SharedPubkyIdentity( + val protocolVersion: Int, + val sourcePackage: String, + val pubky: String, +) + +class SharedPubkyCredential( + val identity: SharedPubkyIdentity, + secretKeyHex: String, +) { + val secretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) +} + +@Serializable +data class ExternalPubkyIdentityRef( + val protocolVersion: Int, + val sourcePackage: String, + val pubky: String, +) { + fun validated(): ExternalPubkyIdentityRef { + if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) { + throw SharedPubkyError.UnsupportedVersion(protocolVersion) + } + if (sourcePackage != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(sourcePackage) + } + return copy(pubky = SharedPubkyContract.requireWirePubky(pubky)) + } +} + +sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) { + data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable") + class UntrustedSource(source: String) : SharedPubkyError("Untrusted Pubky identity source '$source'") + class UnsupportedVersion(version: Int) : SharedPubkyError("Unsupported Pubky sharing version '$version'") + data object InvalidResponse : SharedPubkyError("Pubky Ring returned an invalid shared identity") + data object IdentityUnavailable : SharedPubkyError("The selected Pubky Ring identity is unavailable") + data object IdentityConflict : SharedPubkyError("Another Pubky profile is already connected") +} diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt new file mode 100644 index 0000000000..9dfd10bb10 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt @@ -0,0 +1,137 @@ +package to.bitkit.data.sharing + +import android.content.Context +import android.content.pm.PackageManager +import android.database.Cursor +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import to.bitkit.di.IoDispatcher +import to.bitkit.ext.runSuspendCatching +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SharedPubkyDiscovery @Inject constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + suspend fun discoverRingIdentities(): Result> = runSuspendCatching { + withContext(ioDispatcher) { + verifyRingProvider() + context.contentResolver.query( + SharedPubkyContract.ringIdentitiesUri, + SharedPubkyContract.publicColumns, + null, + null, + null, + )?.use(::readPublicIdentities) ?: throw SharedPubkyError.SourceUnavailable + } + } + + suspend fun readRingCredential(pubky: String): Result = runSuspendCatching { + withContext(ioDispatcher) { + verifyRingProvider() + val expectedPubky = SharedPubkyContract.canonicalPubky(pubky) + context.contentResolver.query( + SharedPubkyContract.ringCredentialUri(expectedPubky), + SharedPubkyContract.credentialColumns, + null, + null, + null, + )?.use { readCredential(it, expectedPubky) } ?: throw SharedPubkyError.IdentityUnavailable + } + } + + @Suppress("ThrowsCount") + private fun verifyRingProvider() { + val packageManager = context.packageManager + val provider = packageManager.resolveContentProvider( + SharedPubkyContract.RING_AUTHORITY, + PackageManager.MATCH_ALL, + ) ?: throw SharedPubkyError.SourceUnavailable + if (provider.packageName != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(provider.packageName) + } + if ( + provider.authority != SharedPubkyContract.RING_AUTHORITY || + provider.readPermission != SharedPubkyContract.RING_READ_PERMISSION + ) { + throw SharedPubkyError.UntrustedSource(provider.packageName) + } + if ( + packageManager.checkSignatures(context.packageName, provider.packageName) != + PackageManager.SIGNATURE_MATCH + ) { + throw SharedPubkyError.UntrustedSource(provider.packageName) + } + } + + private fun readPublicIdentities(cursor: Cursor): List { + val columns = cursor.requiredPublicColumns() + val identities = buildList { + while (cursor.moveToNext()) { + add(cursor.readIdentity(columns)) + } + } + return identities.distinctBy { it.pubky } + } + + @Suppress("ThrowsCount") + private fun readCredential(cursor: Cursor, expectedPubky: String): SharedPubkyCredential { + val publicColumns = cursor.requiredPublicColumns() + val secretKeyColumn = cursor.getColumnIndex(SharedPubkyContract.COLUMN_SECRET_KEY) + if (secretKeyColumn < 0 || !cursor.moveToFirst()) throw SharedPubkyError.IdentityUnavailable + + val identity = cursor.readIdentity(publicColumns) + if (identity.pubky != expectedPubky || cursor.moveToNext()) { + throw SharedPubkyError.InvalidResponse + } + return runCatching { + SharedPubkyCredential( + identity = identity, + secretKeyHex = cursor.getString(secretKeyColumn).orEmpty(), + ) + }.getOrElse { + throw SharedPubkyError.InvalidResponse + } + } + + private fun Cursor.requiredPublicColumns() = PublicColumnIndexes( + protocolVersion = getColumnIndex(SharedPubkyContract.COLUMN_PROTOCOL_VERSION), + sourcePackage = getColumnIndex(SharedPubkyContract.COLUMN_SOURCE_PACKAGE), + pubky = getColumnIndex(SharedPubkyContract.COLUMN_PUBKY), + ).also { + if (it.protocolVersion < 0 || it.sourcePackage < 0 || it.pubky < 0) { + throw SharedPubkyError.InvalidResponse + } + } + + @Suppress("ThrowsCount") + private fun Cursor.readIdentity(columns: PublicColumnIndexes): SharedPubkyIdentity { + val version = getInt(columns.protocolVersion) + if (version != SharedPubkyContract.PROTOCOL_VERSION) { + throw SharedPubkyError.UnsupportedVersion(version) + } + val sourcePackage = getString(columns.sourcePackage).orEmpty() + if (sourcePackage != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(sourcePackage) + } + val pubky = runCatching { + SharedPubkyContract.requireWirePubky(getString(columns.pubky).orEmpty()) + }.getOrElse { + throw SharedPubkyError.InvalidResponse + } + return SharedPubkyIdentity( + protocolVersion = version, + sourcePackage = sourcePackage, + pubky = pubky, + ) + } +} + +private data class PublicColumnIndexes( + val protocolVersion: Int, + val sourcePackage: Int, + val pubky: Int, +) diff --git a/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt new file mode 100644 index 0000000000..c7290a92e0 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/sharing/SharedPubkyProvider.kt @@ -0,0 +1,192 @@ +package to.bitkit.data.sharing + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.PackageManager +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Binder +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import to.bitkit.data.keychain.Keychain +import to.bitkit.services.PaykitSdkService +import to.bitkit.utils.Logger + +class SharedPubkyProvider : ContentProvider() { + private companion object { + const val TAG = "SharedPubkyProvider" + const val EXPORT_ENABLED = "1" + const val QUARANTINED = "1" + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Dependencies { + fun keychain(): Keychain + } + + private val keychain: Keychain by lazy { + val applicationContext = requireNotNull(context?.applicationContext) { + "SharedPubkyProvider context is unavailable" + } + EntryPointAccessors.fromApplication(applicationContext, Dependencies::class.java).keychain() + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor { + enforceCaller() + require(selection == null && selectionArgs == null && sortOrder == null) { + "Selection and sorting are unsupported" + } + + val route = ProviderRoute.parse(uri, requireNotNull(context).packageName) + val expectedColumns = when (route) { + ProviderRoute.Identities -> SharedPubkyContract.publicColumns + is ProviderRoute.Credential -> SharedPubkyContract.credentialColumns + } + require(projection == null || projection.contentEquals(expectedColumns)) { + "Unsupported shared Pubky projection" + } + + val cursor = MatrixCursor(expectedColumns) + val localIdentity = readLocalIdentity() ?: return cursor + when (route) { + ProviderRoute.Identities -> cursor.addRow(localIdentity.publicRow()) + is ProviderRoute.Credential -> { + if (route.pubky == localIdentity.pubky) { + cursor.addRow(localIdentity.credentialRow()) + } + } + } + return cursor + } + + override fun getType(uri: Uri): String { + val packageName = requireNotNull(context).packageName + return when (ProviderRoute.parse(uri, packageName)) { + ProviderRoute.Identities -> "vnd.android.cursor.dir/vnd.$packageName.sharedpubky.identity" + is ProviderRoute.Credential -> "vnd.android.cursor.item/vnd.$packageName.sharedpubky.credential" + } + } + + override fun insert(uri: Uri, values: ContentValues?): Uri = + throw UnsupportedOperationException("Shared Pubky provider is read-only") + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = + throw UnsupportedOperationException("Shared Pubky provider is read-only") + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = throw UnsupportedOperationException("Shared Pubky provider is read-only") + + private fun enforceCaller() { + val providerContext = requireNotNull(context) + val caller = callingPackage + val isCallerPackage = caller == SharedPubkyContract.RING_SOURCE && + caller in providerContext.packageManager.getPackagesForUid(Binder.getCallingUid()).orEmpty() + val isCallerSignedByBitkit = caller != null && + providerContext.packageManager.checkSignatures(providerContext.packageName, caller) == + PackageManager.SIGNATURE_MATCH + if (!isCallerPackage || !isCallerSignedByBitkit) { + throw SecurityException("Caller is not trusted for shared Pubky access") + } + } + + private fun readLocalIdentity(): LocalIdentity? { + val isExportEnabled = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) + }.onFailure { + Logger.warn("Failed to read shared Pubky export state", it, context = TAG) + }.getOrNull() == EXPORT_ENABLED + if (!isExportEnabled) return null + + val isManagedSecretQuarantined = runCatching { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + }.onFailure { + Logger.warn("Failed to read managed Pubky secret quarantine", it, context = TAG) + }.getOrNull() == QUARANTINED + + val secretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.onFailure { + Logger.warn("Failed to read local shared Pubky identity", it, context = TAG) + }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null + + return runCatching { + localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = isManagedSecretQuarantined, + secretKeyHex = secretKeyHex, + publicKeyFromSecret = PaykitSdkService::publicKeyFromSecret, + ) + }.onFailure { + Logger.warn("Failed to validate local shared Pubky identity", it, context = TAG) + }.getOrNull() + } + + private sealed interface ProviderRoute { + data object Identities : ProviderRoute + data class Credential(val pubky: String) : ProviderRoute + + companion object { + fun parse(uri: Uri, packageName: String): ProviderRoute { + require(uri.scheme == "content" && uri.authority == "$packageName.sharedpubky") { + "Unsupported shared Pubky URI" + } + val segments = uri.pathSegments + if (segments == listOf("v1", "identities")) return Identities + if ( + segments.size == 4 && + segments.take(2) == listOf("v1", "identities") && + segments.last() == "credential" + ) { + return Credential(SharedPubkyContract.requireWirePubky(segments[2])) + } + throw IllegalArgumentException("Unsupported shared Pubky URI") + } + } + } +} + +internal fun localSharedPubkyIdentity( + exportEnabled: Boolean, + managedSecretQuarantined: Boolean, + secretKeyHex: String?, + publicKeyFromSecret: (String) -> String, +): LocalIdentity? { + if (!exportEnabled || managedSecretQuarantined || secretKeyHex.isNullOrBlank()) return null + val canonicalSecretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex) + val pubky = SharedPubkyContract.canonicalPubky(publicKeyFromSecret(canonicalSecretKeyHex)) + return LocalIdentity(pubky = pubky, secretKeyHex = canonicalSecretKeyHex) +} + +internal class LocalIdentity( + val pubky: String, + private val secretKeyHex: String, +) { + fun publicRow(): Array = arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + pubky, + ) + + fun credentialRow(): Array = arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + pubky, + secretKeyHex, + ) +} diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index be6db40cd4..e3017f1f41 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -36,6 +36,12 @@ import to.bitkit.data.PubkyStore import to.bitkit.data.SettingsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.keychain.Keychain +import to.bitkit.data.sharing.ExternalPubkyIdentityRef +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyCredential +import to.bitkit.data.sharing.SharedPubkyDiscovery +import to.bitkit.data.sharing.SharedPubkyError +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching @@ -87,6 +93,7 @@ class PubkyRepo @Inject constructor( private val pubkyStore: PubkyStore, private val settingsStore: SettingsStore, private val httpClient: HttpClient, + private val sharedPubkyDiscovery: SharedPubkyDiscovery, ) { companion object { private const val TAG = "PubkyRepo" @@ -94,11 +101,13 @@ class PubkyRepo @Inject constructor( private const val PUBKY_SCHEME = "pubky://" private const val AVATAR_MAX_SIZE = 400 private const val AVATAR_QUALITY = 80 + private const val MANAGED_SECRET_QUARANTINED = "1" + private const val SHARED_EXPORT_ENABLED = "1" } private val scope = appScope(ioDispatcher, TAG) private val serviceInitializeMutex = Mutex() - private val initializeMutex = Mutex() + private val identityLifecycleMutex = Mutex() private val loadProfileMutex = Mutex() private val loadContactsMutex = Mutex() private var isServiceInitialized = false @@ -154,6 +163,7 @@ class PubkyRepo @Inject constructor( data object NoSession : InitResult data class Restored(val publicKey: String) : InitResult data object RestorationFailed : InitResult + data object ExternalSourceUnavailable : InitResult } init { @@ -169,44 +179,84 @@ class PubkyRepo @Inject constructor( Logger.error("Failed to initialize paykit", it, context = TAG) }.getOrNull() ?: return@withContext - initializeMutex.withLock { + identityLifecycleMutex.withLock { _sessionRestorationFailed.update { false } val result = runSuspendCatching { - val savedSessionSecret = runCatching { - keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) - }.getOrNull() - val storedSecretKeyHex = runCatching { - keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - }.getOrNull() - - resolveSessionInitialization( - savedSessionSecret = savedSessionSecret, - storedSecretKeyHex = storedSecretKeyHex, - ) + resolveStoredSessionInitialization() }.onFailure { Logger.error("Failed to initialize paykit", it, context = TAG) }.getOrNull() ?: return@withLock - when (result) { - is InitResult.NoSession -> { - clearAuthenticatedState() - Logger.debug("Found no saved paykit session", context = TAG) - } - is InitResult.Restored -> { - _publicKey.update { result.publicKey } - _authState.update { PubkyAuthState.Authenticated } - Logger.info("Restored paykit session for '${redacted(result.publicKey)}'", context = TAG) - loadProfile() - loadContacts() - } - is InitResult.RestorationFailed -> { + applySessionInitialization(result) + } + } + + private suspend fun resolveStoredSessionInitialization(): InitResult { + val savedSessionSecret = runCatching { + keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) + }.getOrNull() + val isManagedSecretQuarantined = runCatching { + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + }.getOrNull() == MANAGED_SECRET_QUARANTINED + val storedSecretKeyHex = runCatching { + keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + }.getOrNull().takeUnless { isManagedSecretQuarantined } + val externalIdentityRef = pubkyStore.data.first().externalIdentityRef?.let { identityRef -> + runCatching { identityRef.validated() }.getOrElse { + return InitResult.ExternalSourceUnavailable + } + } + if (isManagedSecretQuarantined && externalIdentityRef != null) { + return InitResult.ExternalSourceUnavailable + } + + return resolveSessionInitialization( + savedSessionSecret = savedSessionSecret.takeUnless { + isManagedSecretQuarantined && externalIdentityRef == null + }, + storedSecretKeyHex = storedSecretKeyHex, + externalIdentityRef = externalIdentityRef, + ) + } + + private suspend fun applySessionInitialization(result: InitResult) { + when (result) { + is InitResult.NoSession -> { + disableLocalIdentityExport() + clearAuthenticatedState() + Logger.debug("Found no saved paykit session", context = TAG) + } + is InitResult.Restored -> restoreInitializedSession(result.publicKey) + is InitResult.RestorationFailed -> { + disableLocalIdentityExport() + if (pubkyStore.data.first().externalIdentityRef == null) { clearAuthenticatedState() - _sessionRestorationFailed.update { true } + } else { + clearAuthenticatedRuntimeState() } + _sessionRestorationFailed.update { true } + } + is InitResult.ExternalSourceUnavailable -> { + clearUnavailableExternalIdentityLocked() + Logger.warn("Disconnected unavailable Pubky Ring identity", context = TAG) } } } + private suspend fun restoreInitializedSession(publicKey: String) { + val hasLocalSecret = !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank() + if (pubkyStore.data.first().externalIdentityRef == null && hasLocalSecret) { + enableLocalIdentityExport(publicKey) + } else { + disableLocalIdentityExport() + } + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Restored paykit session for '${redacted(publicKey)}'", context = TAG) + loadProfile() + loadContacts() + } + private suspend fun ensureServiceInitialized() = withContext(ioDispatcher) { serviceInitializeMutex.withLock { if (!isServiceInitialized) { @@ -219,10 +269,22 @@ class PubkyRepo @Inject constructor( private suspend fun resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, + externalIdentityRef: ExternalPubkyIdentityRef?, ): InitResult = withContext(ioDispatcher) { + if (externalIdentityRef != null) { + return@withContext resolveExternalSession( + savedSessionSecret = savedSessionSecret, + identityRef = externalIdentityRef, + ) + } + if (!savedSessionSecret.isNullOrEmpty()) { runSuspendCatching { - val publicKey = pubkyService.importSession(savedSessionSecret).ensurePubkyPrefix() + val publicKey = if (storedSecretKeyHex.isNullOrBlank()) { + pubkyService.importExternalSession(savedSessionSecret) + } else { + pubkyService.importSession(savedSessionSecret) + }.ensurePubkyPrefix() InitResult.Restored(publicKey) }.getOrElse { Logger.warn("Failed to restore paykit session, attempting re-sign-in", it, context = TAG) @@ -233,6 +295,42 @@ class PubkyRepo @Inject constructor( } } + private suspend fun resolveExternalSession( + savedSessionSecret: String?, + identityRef: ExternalPubkyIdentityRef, + ): InitResult = withContext(ioDispatcher) { + val sourceIdentity = sharedPubkyDiscovery.discoverRingIdentities().getOrElse { + return@withContext InitResult.ExternalSourceUnavailable + }.firstOrNull { it.matches(identityRef) } + ?: return@withContext InitResult.ExternalSourceUnavailable + + if (!savedSessionSecret.isNullOrBlank()) { + runSuspendCatching { + val restored = canonicalBitkitPubky(pubkyService.importExternalSession(savedSessionSecret)) + if (wirePubky(restored) != identityRef.pubky) throw SharedPubkyError.InvalidResponse + InitResult.Restored(restored) + }.onSuccess { + return@withContext it + }.onFailure { + Logger.warn("Failed to restore external paykit session, attempting re-sign-in", it, context = TAG) + } + } + + val credential = sharedPubkyDiscovery.readRingCredential(sourceIdentity.pubky).getOrElse { + return@withContext InitResult.ExternalSourceUnavailable + } + if (!credential.matches(identityRef)) return@withContext InitResult.ExternalSourceUnavailable + + runSuspendCatching { + val publicKey = signInWithExternalCredential(credential) + Logger.info("Re-signed in with Pubky Ring identity '${redacted(publicKey)}'", context = TAG) + InitResult.Restored(publicKey) + }.getOrElse { + Logger.error("Failed external re-sign-in recovery", it, context = TAG) + InitResult.RestorationFailed + } + } + private suspend fun resolveSignedInSession( savedSessionSecret: String?, storedSecretKeyHex: String?, @@ -286,10 +384,11 @@ class PubkyRepo @Inject constructor( } } - suspend fun completeAuthentication(): Result { - val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive()) + suspend fun completeAuthentication(): Result = identityLifecycleMutex.withLock { + val attemptId = _activeAuthAttemptId.value + ?: return@withLock Result.failure(PubkyAuthAttemptInactive()) var didCompleteAuth = false - return try { + try { val result = runSuspendCatching { waitForAuthApproval(attemptId) withContext(ioDispatcher) { @@ -302,6 +401,7 @@ class PubkyRepo @Inject constructor( ensureAuthAttemptActive(attemptId) settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + disableLocalIdentityExport() notifyBackupStateChanged() pk @@ -534,52 +634,63 @@ class PubkyRepo @Inject constructor( links: List, tags: List, avatarBytes: ByteArray?, - ): Result = runSuspendCatching { - withContext(ioDispatcher) { - val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() - - val homegate = fetchHomegateSignupCode() + ): Result = identityLifecycleMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + if (pubkyStore.data.first().externalIdentityRef != null) { + throw SharedPubkyError.IdentityConflict + } + val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow() - runSuspendCatching { - pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) - }.getOrElse { - Logger.warn("Retrying sign in after sign up failed", it, context = TAG) - pubkyService.signIn(secretKeyHex) - } + val homegate = fetchHomegateSignupCode() - val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() } - writeProfile(name, bio, links, tags, imageUrl) + runSuspendCatching { + pubkyService.signUp(secretKeyHex, homegate.homeserverPubky, homegate.signupCode) + }.getOrElse { + Logger.warn("Retrying sign in after sign up failed", it, context = TAG) + pubkyService.signIn(secretKeyHex) + } - val createdProfile = PubkyProfile( - publicKey = publicKeyZ32, - name = name, - bio = bio, - imageUrl = imageUrl, - links = links, - tags = tags, - status = null, - ) - _publicKey.update { publicKeyZ32 } - _authState.update { PubkyAuthState.Authenticated } - _profile.update { createdProfile } - cacheMetadata(createdProfile) - notifyBackupStateChanged() - Logger.info("Created identity for '${redacted(publicKeyZ32)}'", context = TAG) - loadProfile() - loadContacts() + val imageUrl = avatarBytes?.let { uploadAvatarInternal(it) } + writeProfile(name, bio, links, tags, imageUrl) + + val createdProfile = PubkyProfile( + publicKey = publicKeyZ32, + name = name, + bio = bio, + imageUrl = imageUrl, + links = links, + tags = tags, + status = null, + ) + enableLocalIdentityExport(publicKeyZ32) + _publicKey.update { publicKeyZ32 } + _authState.update { PubkyAuthState.Authenticated } + _profile.update { createdProfile } + cacheMetadata(createdProfile) + notifyBackupStateChanged() + Logger.info("Created identity for '${redacted(publicKeyZ32)}'", context = TAG) + loadProfile() + loadContacts() + } } } 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, @@ -588,6 +699,7 @@ class PubkyRepo @Inject constructor( imageUrl: String?, ): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) { "No session available" } @@ -620,6 +732,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" } @@ -764,6 +878,7 @@ class PubkyRepo @Inject constructor( existingProfile: PubkyProfile? = null, ): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = requireAddableContactPublicKey( publicKey = publicKey, allowExisting = existingProfile != null, @@ -783,6 +898,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 @@ -801,6 +917,7 @@ class PubkyRepo @Inject constructor( tags: List, ): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = publicKey.ensurePubkyPrefix() val updatedProfile = PubkyProfile( publicKey = prefixedKey, @@ -824,6 +941,7 @@ class PubkyRepo @Inject constructor( suspend fun removeContact(publicKey: String): Result = runSuspendCatching { withContext(ioDispatcher) { + requireExternalIdentitySource() val prefixedKey = publicKey.ensurePubkyPrefix() pubkyService.removeContact(prefixedKey) removeContactProfileOverride(prefixedKey) @@ -835,6 +953,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() @@ -892,11 +1011,94 @@ class PubkyRepo @Inject constructor( // endregion + // region Shared Pubky identities + + suspend fun discoverRingIdentities(): Result> = + sharedPubkyDiscovery.discoverRingIdentities() + + suspend fun adoptRingIdentity(identity: SharedPubkyIdentity): Result = + identityLifecycleMutex.withLock { + var didPersistExternalIdentity = false + runSuspendCatching { + withContext(ioDispatcher) { + ensureServiceInitialized() + val canonicalIdentity = identity.validatedRingIdentity() + val currentIdentityRef = pubkyStore.data.first().externalIdentityRef?.validated() + val currentPublicKey = _publicKey.value + val isAlreadyActive = currentIdentityRef?.pubky == canonicalIdentity.pubky && + currentPublicKey?.let(::wirePubky) == canonicalIdentity.pubky + if (isAlreadyActive) { + return@withContext + } + if ( + currentPublicKey != null || + !keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank() + ) { + throw SharedPubkyError.IdentityConflict + } + + val credential = sharedPubkyDiscovery.readRingCredential(canonicalIdentity.pubky).getOrThrow() + if (!credential.identity.matches(canonicalIdentity)) throw SharedPubkyError.InvalidResponse + + disableLocalIdentityExport() + val identityRef = canonicalIdentity.toExternalRef() + pubkyStore.update { it.copy(externalIdentityRef = identityRef) } + didPersistExternalIdentity = true + _authState.update { PubkyAuthState.Authenticating } + + val publicKey = signInWithExternalCredential(credential) + + settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) } + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + Logger.info("Connected Pubky Ring identity '${redacted(publicKey)}'", context = TAG) + loadProfile() + loadContacts() + } + }.onFailure { + if (didPersistExternalIdentity) { + withContext(NonCancellable) { + runSuspendCatching { clearUnavailableExternalIdentityLocked() } + .onFailure { + Logger.error("Failed to roll back Pubky Ring identity connection", it, context = TAG) + } + } + } + restoreAuthStateAfterAuthFlow() + } + } + + suspend fun validateExternalIdentitySource(): Boolean = identityLifecycleMutex.withLock { + validateExternalIdentitySourceLocked() + } + + private suspend fun validateExternalIdentitySourceLocked(): Boolean = withContext(ioDispatcher) { + val identityRef = runSuspendCatching { + pubkyStore.data.first().externalIdentityRef?.validated() + }.getOrElse { + clearUnavailableExternalIdentityLocked() + return@withContext false + } ?: return@withContext true + + val available = sharedPubkyDiscovery.discoverRingIdentities() + .getOrNull() + ?.any { it.matches(identityRef) } + ?: false + if (available) return@withContext true + + clearUnavailableExternalIdentityLocked() + Logger.warn("Disconnected missing Pubky Ring identity '${redacted(identityRef.pubky)}'", context = TAG) + false + } + + // endregion + // region Auth approval suspend fun hasSecretKey(): Boolean = runSuspendCatching { val publicKey = _publicKey.value ?: return@runSuspendCatching false - managedSecretKeyFor(publicKey) != null + activeIdentitySecretKey(publicKey) != null }.getOrDefault(false) suspend fun parseAuthUrl(authUrl: String): Result = runSuspendCatching { @@ -912,8 +1114,9 @@ class PubkyRepo @Inject constructor( suspend fun approveAuth(authUrl: String, expectedCapabilities: 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, secretKeyHex) } @@ -924,8 +1127,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, @@ -946,6 +1150,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) @@ -973,11 +1185,12 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { ensureServiceInitialized() - initializeMutex.withLock { + identityLifecycleMutex.withLock { + disableLocalIdentityExport() pubkyService.clearSessionAccess() clearAuthenticatedState() - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } when (backup?.kind) { null -> Unit @@ -987,6 +1200,7 @@ class PubkyRepo @Inject constructor( keychain.upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, secretKeyHex) pubkyService.signIn(secretKeyHex) val publicKey = pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix() + enableLocalIdentityExport(publicKey) _publicKey.update { publicKey } _authState.update { PubkyAuthState.Authenticated } } @@ -996,6 +1210,7 @@ class PubkyRepo @Inject constructor( "Missing session secret in backup" } val publicKey = pubkyService.importExternalSession(sessionSecret).ensurePubkyPrefix() + disableLocalIdentityExport() _publicKey.update { publicKey } _authState.update { PubkyAuthState.Authenticated } } @@ -1017,19 +1232,46 @@ class PubkyRepo @Inject constructor( } } - suspend fun refreshSessionIfPossible(): Result = runSuspendCatching { - withContext(ioDispatcher) { - val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) - ?: return@withContext false + suspend fun refreshSessionIfPossible(): Result = identityLifecycleMutex.withLock { + runSuspendCatching { + withContext(ioDispatcher) { + val identityRef = pubkyStore.data.first().externalIdentityRef?.validated() + if (identityRef != null) { + if (!validateExternalIdentitySourceLocked()) return@withContext false + val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse { + clearUnavailableExternalIdentityLocked() + return@withContext false + } + val publicKey = signInWithExternalCredential(credential) + if (wirePubky(publicKey) != identityRef.pubky) { + clearUnavailableExternalIdentityLocked() + return@withContext false + } + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } + return@withContext true + } - pubkyService.signIn(storedSecretKeyHex) - val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() + val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + ?: return@withContext false + if ( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + return@withContext false + } - notifyBackupStateChanged() - _publicKey.update { publicKey } - _authState.update { PubkyAuthState.Authenticated } + pubkyService.signIn(storedSecretKeyHex) + val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix() + enableLocalIdentityExport(publicKey) + + notifyBackupStateChanged() + _publicKey.update { publicKey } + _authState.update { PubkyAuthState.Authenticated } - true + true + } } } @@ -1037,7 +1279,11 @@ class PubkyRepo @Inject constructor( // region Sign out - suspend fun signOut(): Result { + suspend fun signOut(): Result = identityLifecycleMutex.withLock { + runSuspendCatching { disableLocalIdentityExport() } + .onFailure { Logger.error("Failed to disable shared Pubky export", it, context = TAG) } + .exceptionOrNull() + ?.let { return@withLock Result.failure(it) } val hadPaykitState = settingsStore.data.first().hasPaykitState() val endpointCleanupResult = removeBitkitPaymentEndpoints() .onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) } @@ -1053,10 +1299,10 @@ class PubkyRepo @Inject constructor( ) clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState) - return result + result } - suspend fun wipeLocalState() { + suspend fun wipeLocalState() = identityLifecycleMutex.withLock { clearLocalState() } @@ -1179,7 +1425,112 @@ class PubkyRepo @Inject constructor( } } + private suspend fun signInWithExternalCredential(credential: SharedPubkyCredential): String = + withContext(ioDispatcher) { + val identity = credential.identity.validatedRingIdentity() + val derivedWirePubky = wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex)) + if (derivedWirePubky != identity.pubky) throw SharedPubkyError.InvalidResponse + + val signedInPubky = canonicalBitkitPubky(pubkyService.signInExternal(credential.secretKeyHex)) + if (wirePubky(signedInPubky) != identity.pubky) throw SharedPubkyError.InvalidResponse + if (!keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()) { + throw SharedPubkyError.InvalidResponse + } + signedInPubky + } + + private suspend fun enableLocalIdentityExport(publicKey: String) = withContext(ioDispatcher) { + val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) { + "Local Pubky secret is unavailable" + } + val derivedPublicKey = canonicalBitkitPubky(pubkyService.publicKeyFromSecret(secretKeyHex)) + if (!PubkyPublicKeyFormat.matches(derivedPublicKey, publicKey)) { + throw SharedPubkyError.InvalidResponse + } + keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) + check(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == null) { + "Failed to release managed local Pubky secret quarantine" + } + keychain.upsertString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name, SHARED_EXPORT_ENABLED) + check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == SHARED_EXPORT_ENABLED) { + "Failed to verify shared Pubky export state" + } + } + + private suspend fun disableLocalIdentityExport() = withContext(ioDispatcher) { + keychain.delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) + check(keychain.loadString(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) { + "Failed to disable shared Pubky export" + } + } + + private suspend fun activeIdentitySecretKey(publicKey: String): String? = identityLifecycleMutex.withLock { + activeIdentitySecretKeyLocked(publicKey) + } + + private suspend fun activeIdentitySecretKeyLocked(publicKey: String): String? = withContext(ioDispatcher) { + val identityRef = pubkyStore.data.first().externalIdentityRef?.validated() + ?: return@withContext managedSecretKeyFor(publicKey) + if (wirePubky(publicKey) != identityRef.pubky || !validateExternalIdentitySourceLocked()) { + return@withContext null + } + + val credential = sharedPubkyDiscovery.readRingCredential(identityRef.pubky).getOrElse { + clearUnavailableExternalIdentityLocked() + return@withContext null + } + val isValid = runSuspendCatching { + credential.matches(identityRef) && + wirePubky(pubkyService.publicKeyFromSecret(credential.secretKeyHex)) == identityRef.pubky + }.getOrDefault(false) + if (!isValid) { + clearUnavailableExternalIdentityLocked() + return@withContext null + } + credential.secretKeyHex + } + + private suspend fun requireExternalIdentitySource() { + if (!validateExternalIdentitySource()) throw SharedPubkyError.SourceUnavailable + } + + private suspend fun clearUnavailableExternalIdentityLocked() = withContext(ioDispatcher) { + val externalIdentityRef = pubkyStore.data.first().externalIdentityRef ?: return@withContext + disableLocalIdentityExport() + + val managedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + if (!managedSecretKeyHex.isNullOrBlank()) { + keychain.upsertString( + Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, + MANAGED_SECRET_QUARANTINED, + ) + check( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + "Failed to quarantine conflicting managed local Pubky secret" + } + Logger.error( + "Quarantined managed local secret while clearing external identity " + + "'${redacted(externalIdentityRef.pubky)}'", + context = TAG, + ) + } + + pubkyService.clearExternalSessionAccess() + clearPublicPaykitSharingState(publicPaykitCleanupPending = false) + clearAuthenticatedRuntimeState() + pubkyStore.reset() + notifyBackupStateChanged() + } + private suspend fun managedSecretKeyFor(publicKey: String): String? = withContext(ioDispatcher) { + if ( + keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) == + MANAGED_SECRET_QUARANTINED + ) { + return@withContext null + } val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: return@withContext null @@ -1196,7 +1547,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 } @@ -1213,8 +1567,12 @@ class PubkyRepo @Inject constructor( } private suspend fun clearAuthenticatedState() = withContext(ioDispatcher) { - evictPubkyImages() runSuspendCatching { pubkyStore.reset() } + clearAuthenticatedRuntimeState() + } + + private suspend fun clearAuthenticatedRuntimeState() = withContext(ioDispatcher) { + evictPubkyImages() _publicKey.update { null } _profile.update { null } _contacts.update { emptyList() } @@ -1229,8 +1587,10 @@ class PubkyRepo @Inject constructor( } private suspend fun clearLocalState(publicPaykitCleanupPending: Boolean = false) = withContext(ioDispatcher) { - runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } - runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + disableLocalIdentityExport() + runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + runSuspendCatching { keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } runSuspendCatching { clearPublicPaykitSharingState(publicPaykitCleanupPending) } .onFailure { Logger.warn("Failed to clear public Paykit sharing state", it, context = TAG) } notifyBackupStateChanged() @@ -1269,6 +1629,43 @@ class PubkyRepo @Inject constructor( private fun String.ensurePubkyPrefix(): String = if (startsWith(PUBKY_PREFIX)) this else "$PUBKY_PREFIX$this" + private fun canonicalBitkitPubky(value: String): String = + SharedPubkyContract.toBitkitPubky(value) + + private fun wirePubky(value: String): String = + SharedPubkyContract.canonicalPubky(value) + + private fun SharedPubkyIdentity.validatedRingIdentity(): SharedPubkyIdentity { + if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) { + throw SharedPubkyError.UnsupportedVersion(protocolVersion) + } + if (sourcePackage != SharedPubkyContract.RING_SOURCE) { + throw SharedPubkyError.UntrustedSource(sourcePackage) + } + return copy(pubky = SharedPubkyContract.requireWirePubky(pubky)) + } + + private fun SharedPubkyIdentity.toExternalRef() = ExternalPubkyIdentityRef( + protocolVersion = protocolVersion, + sourcePackage = sourcePackage, + pubky = SharedPubkyContract.requireWirePubky(pubky), + ) + + private fun SharedPubkyIdentity.matches(identityRef: ExternalPubkyIdentityRef): Boolean = + protocolVersion == identityRef.protocolVersion && + sourcePackage == identityRef.sourcePackage && + SharedPubkyContract.requireWirePubky(pubky) == + SharedPubkyContract.requireWirePubky(identityRef.pubky) + + private fun SharedPubkyIdentity.matches(other: SharedPubkyIdentity): Boolean = + protocolVersion == other.protocolVersion && + sourcePackage == other.sourcePackage && + SharedPubkyContract.requireWirePubky(pubky) == + SharedPubkyContract.requireWirePubky(other.pubky) + + private fun SharedPubkyCredential.matches(identityRef: ExternalPubkyIdentityRef): Boolean = + identity.matches(identityRef) + private fun redacted(publicKey: String): String = PubkyPublicKeyFormat.redacted(publicKey) private fun Throwable.isMissingPubkyData(): Boolean { diff --git a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt index 25ea826aa3..837c6daee9 100644 --- a/app/src/main/java/to/bitkit/services/PaykitSdkService.kt +++ b/app/src/main/java/to/bitkit/services/PaykitSdkService.kt @@ -105,7 +105,7 @@ internal object PaykitReceiverPaths { } @Singleton -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") class PaykitSdkService @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, @@ -214,7 +214,16 @@ class PaykitSdkService @Inject constructor( return result } - 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 receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey() @@ -227,7 +236,7 @@ class PaykitSdkService @Inject constructor( activateBootstrapResult( result = result, previousPublicKey = previousPublicKey, - shouldStoreLocalSecret = true, + shouldStoreLocalSecret = shouldStoreLocalSecret, ) } notifyBackupStateChanged() @@ -632,10 +641,34 @@ class PaykitSdkService @Inject constructor( } } + suspend fun clearExternalSessionAccess() { + operationMutex.withLock { + val managedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) + disableSharedPubkyExport() + sessionProvider.clearLiveSessionAccess() + keychain.delete(Keychain.Key.PAYKIT_SESSION.name) + keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) + check(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name) == null) { + "Failed to clear external Pubky session" + } + check(keychain.load(Keychain.Key.PAYKIT_SDK_STATE.name) == null) { + "Failed to clear external Pubky SDK state" + } + check(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) == managedSecretKeyHex) { + "Managed local Pubky secret changed during external session cleanup" + } + activeAuthRequest = null + resetRuntime() + notifyBackupStateChanged() + } + } + private suspend fun clearSessionAccessLocked() { + disableSharedPubkyExport() sessionProvider.clearLiveSessionAccess() keychain.delete(Keychain.Key.PAYKIT_SESSION.name) keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) + keychain.delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) activeAuthRequest = null resetRuntime() } @@ -666,13 +699,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" } } @@ -682,7 +732,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) } @@ -838,6 +893,10 @@ private class PaykitSdkStateBlobStore( private class PaykitSdkSessionProvider( private val keychain: Keychain, ) : SdkPubkySessionProvider { + private companion object { + const val QUARANTINED = "1" + } + private val lock = Any() private val receiverNoiseKeyStore = PaykitReceiverNoiseKeyStore(keychain) private var liveSessionAccess: PubkySessionAccess? = null @@ -873,12 +932,20 @@ private class PaykitSdkSessionProvider( override fun clearSessionAccess() { clearLiveSessionAccess() keychain.accessBlocking { + delete(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) + check(load(Keychain.Key.PUBKY_SHARED_EXPORT_ENABLED.name) == null) { + "Failed to disable shared Pubky export" + } delete(Keychain.Key.PAYKIT_SESSION.name) delete(Keychain.Key.PUBKY_SECRET_KEY.name) + delete(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name) } } 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 @@ -893,6 +960,38 @@ private class PaykitSdkSessionProvider( } } +internal fun managedSecretForSessionPersistence( + shouldStoreLocalSecret: Boolean, + exportedLocalSecretKeyHex: String?, + existingManagedSecretKeyHex: String?, +): String? { + if (shouldStoreLocalSecret) { + val exportedSecret = requireNotNull(exportedLocalSecretKeyHex) { + "Owned Pubky session did not export its local secret" + } + check(existingManagedSecretKeyHex.isNullOrBlank() || existingManagedSecretKeyHex == exportedSecret) { + "Refusing to replace a different managed local Pubky secret" + } + return exportedSecret + } + check(existingManagedSecretKeyHex.isNullOrBlank()) { + "Refusing to activate an external Pubky session over a managed local secret" + } + return null +} + +private fun liveSessionAccess( + access: PubkySessionAccess, + retainLocalSecret: Boolean, +): PubkySessionAccess { + if (retainLocalSecret) return access + return PubkySessionAccess( + sessionSecret = access.exportSessionSecret(), + localSecretKey = null, + receiverNoiseSecretKey = access.exportReceiverNoiseSecretKey(), + ) +} + internal object PaykitReceiverNoiseKeyDerivation { private const val DOMAIN = "bitkit/paykit/receiver-noise-key" private const val VERSION = "v1" diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt index cc6cd7360b..b7219bd5b4 100644 --- a/app/src/main/java/to/bitkit/services/PubkyService.kt +++ b/app/src/main/java/to/bitkit/services/PubkyService.kt @@ -41,6 +41,10 @@ class PubkyService @Inject constructor( paykitSdkService.forceSignOut() } + suspend fun clearExternalSessionAccess() = ServiceQueue.CORE.background { + paykitSdkService.clearExternalSessionAccess() + } + suspend fun clearSessionAccess() = ServiceQueue.CORE.background { paykitSdkService.clearSessionAccess() } @@ -85,6 +89,10 @@ class PubkyService @Inject constructor( Unit } + suspend fun signInExternal(secretKeyHex: String): String = ServiceQueue.CORE.background { + paykitSdkService.signInExternal(secretKeyHex) + } + // endregion // region Auth flow (Ring) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fafb9d44bc..96b7a80920 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1330,9 +1330,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 0161dd331e..4b1da48f93 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -228,6 +228,11 @@ class MainActivity : FragmentActivity() { handleLaunchIntent(intent) } + override fun onResume() { + super.onResume() + appViewModel.onAppResumed() + } + private fun handleLaunchIntent(intent: Intent) { if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) { handleUsbAttachIntent(intent) diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index cda8c1eaf4..99950de605 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -33,7 +33,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeTint @@ -102,6 +104,7 @@ fun PrimaryButton( color: Color? = null, enableGradient: Boolean = true, contentColor: Color = Colors.White, + letterSpacing: TextUnit = 0.4.sp, ) { val contentPadding = PaddingValues(horizontal = size.primaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val buttonShape = MaterialTheme.shapes.extraLarge @@ -154,6 +157,7 @@ fun PrimaryButton( text?.let { Text( text = text, + style = MaterialTheme.typography.labelLarge.copy(letterSpacing = letterSpacing), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -174,6 +178,7 @@ fun SecondaryButton( enabled: Boolean = true, fullWidth: Boolean = true, hazeState: HazeState? = null, + letterSpacing: TextUnit = 0.4.sp, ) { val contentPadding = PaddingValues(horizontal = size.secondaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val border = size.secondaryBorder(enabled) @@ -238,6 +243,7 @@ fun SecondaryButton( text?.let { Text( text = text, + style = MaterialTheme.typography.labelLarge.copy(letterSpacing = letterSpacing), maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index e8a089ecf1..b5dbbd4e93 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..c1d9224116 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -1,7 +1,5 @@ package to.bitkit.ui.screens.profile -import android.content.Intent -import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -14,8 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,23 +26,25 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf import to.bitkit.R +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.models.PubkyProfile import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.Display -import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.GradientCircularProgressIndicator import to.bitkit.ui.components.HorizontalSpacer -import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.PubkyContactAvatar +import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.shared.util.screen @@ -50,13 +52,13 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -private const val PUBKY_RING_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=to.pubky.ring" -private const val BG_IMAGE_WIDTH_FRACTION = 0.83f -private const val TAG_OFFSET_X = -0.179f -private const val TAG_OFFSET_Y = 0.13f -private const val KEYRING_OFFSET_X = 0.341f -private const val KEYRING_OFFSET_Y = 0.06f -private const val TAG_ALPHA = 0.6f +private const val TAG_IMAGE_WIDTH_FRACTION = 0.64f +private const val KEYRING_IMAGE_WIDTH_FRACTION = 0.83f +private const val TAG_OFFSET_X = -0.313f +private const val TAG_OFFSET_Y = 0.336f +private const val KEYRING_OFFSET_X = 0.251f +private const val KEYRING_OFFSET_Y = 0.27f +private const val TAG_ALPHA = 1f private const val KEYRING_ALPHA = 0.9f @Composable @@ -65,45 +67,24 @@ fun PubkyChoiceScreen( onNavigateToCreateProfile: () -> Unit, onNavigateToContactImportOverview: () -> Unit, onNavigateToPayContacts: () -> Unit, - onNavigateToProfile: () -> Unit, onBackClick: () -> Unit, ) { - val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.effects.collect { when (it) { - is PubkyChoiceEffect.OpenRingAuth -> runCatching { - context.startActivity(it.intent) - }.onFailure { - viewModel.onRingLaunchFailed() - } - PubkyChoiceEffect.NavigateToCreateProfile -> onNavigateToCreateProfile() PubkyChoiceEffect.NavigateToContactImportOverview -> onNavigateToContactImportOverview() PubkyChoiceEffect.NavigateToPayContacts -> onNavigateToPayContacts() } } } - LaunchedEffect(uiState.navigateToProfile) { - if (!uiState.navigateToProfile) return@LaunchedEffect - - viewModel.clearProfileNavigation() - onNavigateToProfile() - } - Content( uiState = uiState, onBackClick = onBackClick, onCreateProfile = onNavigateToCreateProfile, - onImportWithRing = { viewModel.startRingAuth() }, - onCancelAuth = { viewModel.cancelAuth() }, - onDownloadRing = { - viewModel.dismissRingNotInstalledDialog() - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PUBKY_RING_PLAY_STORE_URL))) - }, - onDismissDialog = { viewModel.dismissRingNotInstalledDialog() }, + onSelectRingIdentity = viewModel::selectRingIdentity, ) } @@ -112,10 +93,7 @@ private fun Content( uiState: PubkyChoiceUiState, onBackClick: () -> Unit, onCreateProfile: () -> Unit, - onImportWithRing: () -> Unit, - onCancelAuth: () -> Unit, - onDownloadRing: () -> Unit, - onDismissDialog: () -> Unit, + onSelectRingIdentity: (SharedPubkyChoice) -> Unit, ) { Box( modifier = Modifier @@ -128,7 +106,7 @@ private fun Content( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION) + .fillMaxWidth(TAG_IMAGE_WIDTH_FRACTION) .align(Alignment.Center) .offset(x = maxWidth * TAG_OFFSET_X, y = maxHeight * TAG_OFFSET_Y) .alpha(TAG_ALPHA) @@ -139,7 +117,7 @@ private fun Content( contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier - .fillMaxWidth(BG_IMAGE_WIDTH_FRACTION) + .fillMaxWidth(KEYRING_IMAGE_WIDTH_FRACTION) .align(Alignment.Center) .offset(x = maxWidth * KEYRING_OFFSET_X, y = maxHeight * KEYRING_OFFSET_Y) .alpha(KEYRING_ALPHA) @@ -151,66 +129,141 @@ private fun Content( titleText = stringResource(R.string.profile__nav_title), onBackClick = onBackClick, actions = { DrawerNavIcon() }, + modifier = Modifier.offset(y = (-10).dp), ) - Column(modifier = Modifier.padding(horizontal = 32.dp)) { - VerticalSpacer(24.dp) - - Display( - text = stringResource(R.string.profile__choice_title) - .withAccent(accentColor = Colors.PubkyGreen), - color = Colors.White, - ) - VerticalSpacer(8.dp) - - BodyM( - text = stringResource(R.string.profile__choice_description), - color = Colors.White64, - ) - VerticalSpacer(24.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .offset(y = (-6.5).dp) + ) { + Display( + text = stringResource(R.string.profile__choice_title) + .withAccent(accentColor = Colors.PubkyGreen), + color = Colors.White, + ) + VerticalSpacer(4.dp) + BodyM( + text = stringResource(R.string.profile__choice_description), + color = Colors.White64, + letterSpacing = 0.sp, + ) + VerticalSpacer(33.dp) - if (uiState.isLoadingAfterAuth) { - LoadingState(text = stringResource(R.string.profile__choice_loading_profile)) - } else if (uiState.isWaitingForRing) { - WaitingForRingState(onCancel = onCancelAuth) - } else { - OptionCard( - iconResId = R.drawable.ic_user_plus, - text = stringResource(R.string.profile__choice_create), + CreateProfileCard( + enabled = uiState.selectedPubky == null, onClick = onCreateProfile, - modifier = Modifier.testTag("PubkyChoiceCreate") - ) - VerticalSpacer(8.dp) - OptionCard( - iconResId = R.drawable.ic_lock_key, - text = stringResource(R.string.profile__choice_import), - onClick = onImportWithRing, - modifier = Modifier.testTag("PubkyChoiceImport") ) + + uiState.identities.forEach { + VerticalSpacer(8.dp) + RingIdentityCard( + choice = it, + isLoading = uiState.selectedPubky == it.pubky, + enabled = uiState.selectedPubky == null, + onClick = { onSelectRingIdentity(it) }, + ) + } + + if (uiState.isDiscovering) { + VerticalSpacer(20.dp) + GradientCircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterHorizontally) + .testTag("PubkyChoiceDiscovering") + ) + } + VerticalSpacer(32.dp) } } - - FillHeight() } } +} - if (uiState.showRingNotInstalledDialog) { - AppAlertDialog( - title = stringResource(R.string.profile__ring_not_installed_title), - text = stringResource(R.string.profile__ring_not_installed_description), - confirmText = stringResource(R.string.profile__ring_download), - onConfirm = onDownloadRing, - onDismiss = onDismissDialog, - ) - } +@Composable +private fun CreateProfileCard( + enabled: Boolean, + onClick: () -> Unit, +) { + ChoiceCard( + enabled = enabled, + onClick = onClick, + leading = { + ChoiceIcon(iconResId = R.drawable.ic_user_plus) + }, + content = { + Text13Up( + text = stringResource(R.string.profile__choice_new_pubky), + color = Colors.White64, + ) + BodyMSB( + text = stringResource(R.string.profile__choice_create), + color = Colors.White, + ) + }, + modifier = Modifier.testTag("PubkyChoiceCreate") + ) +} + +@Composable +private fun RingIdentityCard( + choice: SharedPubkyChoice, + isLoading: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + ChoiceCard( + enabled = enabled, + onClick = onClick, + leading = { + if (isLoading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(40.dp) + ) { + GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + } else { + ChoiceIcon(iconResId = R.drawable.ic_key) + } + }, + content = { + Text13Up( + text = choice.profile.truncatedPublicKey, + color = Colors.White64, + ) + BodyMSB( + text = choice.profile.name, + color = Colors.White, + ) + }, + trailing = { + PubkyContactAvatar( + profile = choice.profile, + size = 32.dp, + testTag = "PubkyChoiceRingAvatar", + ) + }, + modifier = Modifier.testTag("PubkyChoiceRing_${choice.pubky}") + ) } @Composable -private fun OptionCard( - iconResId: Int, - text: String, +private fun ChoiceCard( + leading: @Composable () -> Unit, + content: @Composable () -> Unit, + enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + trailing: (@Composable () -> Unit)? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -218,71 +271,61 @@ private fun OptionCard( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(Colors.Gray6) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(16.dp) ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(40.dp) - .background(Colors.Black, CircleShape) - ) { - Icon( - painter = painterResource(iconResId), - contentDescription = null, - tint = Colors.PubkyGreen, - modifier = Modifier.size(20.dp) - ) - } + leading() HorizontalSpacer(16.dp) - BodyMSB(text = text, color = Colors.White) + Column(modifier = Modifier.weight(1f)) { + content() + } + trailing?.let { + HorizontalSpacer(12.dp) + it() + } } } @Composable -private fun WaitingForRingState(onCancel: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() +private fun ChoiceIcon(iconResId: Int) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .background(Colors.Black, CircleShape) ) { - GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) - HorizontalSpacer(12.dp) - BodyM( - text = stringResource(R.string.profile__choice_waiting_ring), - color = Colors.White64, + Icon( + painter = painterResource(iconResId), + contentDescription = null, + tint = Colors.PubkyGreen, + modifier = Modifier.size(20.dp) ) } - VerticalSpacer(16.dp) - SecondaryButton( - text = stringResource(R.string.common__cancel), - onClick = onCancel, - ) -} - -@Composable -private fun LoadingState(text: String) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - GradientCircularProgressIndicator(modifier = Modifier.size(20.dp)) - HorizontalSpacer(12.dp) - BodyM(text = text, color = Colors.White64) - } } @Preview(showBackground = true) @Composable private fun Preview() { + val pubky = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" AppThemeSurface { Content( - uiState = PubkyChoiceUiState(), + uiState = PubkyChoiceUiState( + identities = persistentListOf( + SharedPubkyChoice( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = pubky, + profile = PubkyProfile.forDisplay( + publicKey = SharedPubkyContract.toBitkitPubky(pubky), + name = "Satoshi Nakamoto", + imageUrl = null, + ), + ), + ), + ), onBackClick = {}, onCreateProfile = {}, - onImportWithRing = {}, - onCancelAuth = {}, - onDownloadRing = {}, - onDismissDialog = {}, + onSelectRingIdentity = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 5414de9a61..00624aaaee 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -1,24 +1,27 @@ package to.bitkit.ui.screens.profile import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Job +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.models.PubkyRingAuthUrlBuilder +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyIdentity +import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.shared.toast.ToastEventBus @@ -32,7 +35,6 @@ class PubkyChoiceViewModel @Inject constructor( ) : ViewModel() { companion object { private const val TAG = "PubkyChoiceViewModel" - internal const val PUBKY_RING_PACKAGE = "to.pubky.ring" } private val _uiState = MutableStateFlow(PubkyChoiceUiState()) @@ -41,179 +43,102 @@ class PubkyChoiceViewModel @Inject constructor( private val _effects = MutableSharedFlow(extraBufferCapacity = 1) val effects = _effects.asSharedFlow() - private var approvalJob: Job? = null - init { - viewModelScope.launch { - pubkyRepo.authCancelEvents.collect { - approvalJob?.cancel() - approvalJob = null - _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } - } - } - viewModelScope.launch { - pubkyRepo.isAuthenticated.collectLatest { - if (it && approvalJob?.isActive != true && !_uiState.value.isLoadingAfterAuth) { - _uiState.update { state -> state.copy(navigateToProfile = true) } - } - } - } + refreshRingIdentities() } - override fun onCleared() { - super.onCleared() - if (_uiState.value.isWaitingForRing) { - pubkyRepo.cancelAuthenticationSync() - } - } - - fun startRingAuth() { + fun refreshRingIdentities() { viewModelScope.launch { - if (_uiState.value.isWaitingForRing) { - approvalJob?.cancel() - approvalJob = null - _uiState.update { it.copy(isWaitingForRing = false) } - pubkyRepo.cancelAuthentication() - } - - if (!isRingInstalled()) { - showRingNotInstalledDialog() - return@launch - } - - pubkyRepo.startAuthentication() - .onSuccess { authRequest -> - val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks( - authUrl = authRequest.authUrl, - nonce = authRequest.callbackNonce, - ) ?: authRequest.authUrl - val ringIntent = createRingAuthIntent(callbackAuthUrl) - if (!canOpenWithRing(ringIntent)) { - cancelAuthAndShowRingDialog() - return@launch + _uiState.update { it.copy(isDiscovering = true) } + val choices = pubkyRepo.discoverRingIdentities() + .map { identities -> + coroutineScope { + identities.map { identity -> + async { + val bitkitPubky = SharedPubkyContract.toBitkitPubky(identity.pubky) + val profile = pubkyRepo.fetchRemoteProfile(bitkitPubky) + .getOrNull() + ?: PubkyProfile.placeholder(bitkitPubky) + SharedPubkyChoice( + protocolVersion = identity.protocolVersion, + sourcePackage = identity.sourcePackage, + pubky = identity.pubky, + profile = profile, + ) + } + }.awaitAll() } - - _uiState.update { it.copy(isWaitingForRing = true) } - _effects.emit(PubkyChoiceEffect.OpenRingAuth(ringIntent)) - waitForApproval() } .onFailure { - Logger.error("Starting Ring auth failed", it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, - ) + Logger.info("Found no available Pubky Ring identities", context = TAG) } + .getOrDefault(emptyList()) + .sortedWith(compareBy({ it.profile.name.lowercase() }, { it.pubky })) + .toImmutableList() + _uiState.update { it.copy(isDiscovering = false, identities = choices) } } } - fun onRingLaunchFailed() { - viewModelScope.launch { - cancelAuthAndShowRingDialog() - } - } - - @VisibleForTesting - internal fun waitForApproval() { - if (approvalJob?.isActive == true) return + fun selectRingIdentity(choice: SharedPubkyChoice) { + if (_uiState.value.selectedPubky != null) return + _uiState.update { it.copy(selectedPubky = choice.pubky) } - approvalJob = viewModelScope.launch { - pubkyRepo.completeAuthentication() + viewModelScope.launch { + pubkyRepo.adoptRingIdentity(choice.toIdentity()) .onSuccess { - _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = true) } pubkyRepo.prepareImport() .onSuccess { - _uiState.update { state -> state.copy(isLoadingAfterAuth = false) } - val hasContacts = pubkyRepo.pendingImportContacts.value.isNotEmpty() - if (hasContacts) { - _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview) - } else { + _uiState.update { state -> state.copy(selectedPubky = null) } + if (pubkyRepo.pendingImportContacts.value.isEmpty()) { _effects.emit(PubkyChoiceEffect.NavigateToPayContacts) + } else { + _effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview) } } .onFailure { - Logger.error("Preparing contact import failed", it, context = TAG) - _uiState.update { state -> state.copy(isLoadingAfterAuth = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = it.message, - ) + handleSelectionFailure("Preparing shared profile failed", it) } } .onFailure { - Logger.error("Auth approval failed", it, context = TAG) - _uiState.update { it.copy(isWaitingForRing = false) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.profile__auth_error_title), - description = it.message, - ) + handleSelectionFailure("Connecting shared profile failed", it) } } } - fun cancelAuth() { - viewModelScope.launch { - approvalJob?.cancel() - approvalJob = null - pubkyRepo.cancelAuthentication() - _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } - } - } - - fun dismissRingNotInstalledDialog() { - _uiState.update { it.copy(showRingNotInstalledDialog = false) } - } - - fun clearProfileNavigation() { - _uiState.update { it.copy(navigateToProfile = false) } - } - - @VisibleForTesting - internal fun createRingAuthIntent(authUrl: String): Intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)).apply { - setPackage(PUBKY_RING_PACKAGE) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - @VisibleForTesting - internal fun isRingInstalled(): Boolean = - context.packageManager.getLaunchIntentForPackage(PUBKY_RING_PACKAGE) != null - - @VisibleForTesting - internal fun canOpenWithRing(intent: Intent): Boolean = - intent.resolveActivity(context.packageManager) != null - - private suspend fun cancelAuthAndShowRingDialog() { - approvalJob?.cancel() - approvalJob = null - pubkyRepo.cancelAuthentication() - showRingNotInstalledDialog() - } - - private fun showRingNotInstalledDialog() { - _uiState.update { - it.copy( - isWaitingForRing = false, - isLoadingAfterAuth = false, - showRingNotInstalledDialog = true, - ) - } + private suspend fun handleSelectionFailure(message: String, error: Throwable) { + Logger.error(message, error, context = TAG) + _uiState.update { it.copy(selectedPubky = null) } + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.profile__choice_error), + description = error.message, + ) + refreshRingIdentities() } } -@Immutable +@Stable data class PubkyChoiceUiState( - val isWaitingForRing: Boolean = false, - val isLoadingAfterAuth: Boolean = false, - val showRingNotInstalledDialog: Boolean = false, - val navigateToProfile: Boolean = false, + val isDiscovering: Boolean = false, + val identities: ImmutableList = persistentListOf(), + val selectedPubky: String? = null, ) +@Stable +data class SharedPubkyChoice( + val protocolVersion: Int, + val sourcePackage: String, + val pubky: String, + val profile: PubkyProfile, +) { + fun toIdentity() = SharedPubkyIdentity( + protocolVersion = protocolVersion, + sourcePackage = sourcePackage, + pubky = pubky, + ) +} + sealed interface PubkyChoiceEffect { - data class OpenRingAuth(val intent: Intent) : PubkyChoiceEffect - data object NavigateToCreateProfile : PubkyChoiceEffect data object NavigateToContactImportOverview : PubkyChoiceEffect data object NavigateToPayContacts : PubkyChoiceEffect } diff --git a/app/src/main/java/to/bitkit/ui/theme/Colors.kt b/app/src/main/java/to/bitkit/ui/theme/Colors.kt index 58a6d4917f..a588b28d40 100644 --- a/app/src/main/java/to/bitkit/ui/theme/Colors.kt +++ b/app/src/main/java/to/bitkit/ui/theme/Colors.kt @@ -10,7 +10,7 @@ object Colors { val Purple = Color(0xFFB95CE8) val Red = Color(0xFFE95164) val Yellow = Color(0xFFFFD200) - val PubkyGreen = Color(0xFFBEFF00) + val PubkyGreen = Color(0xFFC8FF00) val Bitcoin = Color(0xFFF7931A) // Base diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index ae40b68c2d..761c0f9df2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -93,6 +93,7 @@ import to.bitkit.ext.minSendableSat import to.bitkit.ext.minWithdrawableSat import to.bitkit.ext.rawId import to.bitkit.ext.removeSpaces +import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.setClipboardText import to.bitkit.ext.toHex import to.bitkit.ext.toUserMessage @@ -3260,6 +3261,15 @@ class AppViewModel @Inject constructor( hwWalletRepo.onAppForegrounded() } + 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 b8b74317fa..962c4aec61 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 @@ -589,11 +589,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… @@ -630,14 +629,6 @@ Scan to add {name} Restore Profile Try Again - Please authorize Bitkit with Pubky Ring, your mobile keychain for the next web. - Join the\n<accent>pubky web</accent> - Authorize - Download - Loading your profile… - Pubky Ring is required to authorize your profile. Would you like to download it? - Pubky Ring Not Installed - Waiting for authorization from Pubky Ring… Your profile session has expired. Please reconnect to restore your profile. Disconnect This will disconnect your Pubky profile from Bitkit. You can reconnect at any time. diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt new file mode 100644 index 0000000000..d4d09f03b4 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyContractTest.kt @@ -0,0 +1,82 @@ +package to.bitkit.data.sharing + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SharedPubkyContractTest { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + @Test + fun `wire format is always bare lowercase z-base32`() { + assertEquals(WIRE_PUBKY, SharedPubkyContract.canonicalPubky(" PUBKY${WIRE_PUBKY.uppercase()} ")) + assertEquals("pubky$WIRE_PUBKY", SharedPubkyContract.toBitkitPubky(WIRE_PUBKY)) + } + + @Test + fun `bare wire key may itself begin with pubky`() { + val wirePubky = "pubky" + "y".repeat(47) + + assertEquals(wirePubky, SharedPubkyContract.canonicalPubky(wirePubky)) + assertEquals("pubky$wirePubky", SharedPubkyContract.toBitkitPubky(wirePubky)) + } + + @Test + fun `wire format rejects wrong length and alphabet`() { + assertFailsWith { + SharedPubkyContract.canonicalPubky(WIRE_PUBKY.dropLast(1)) + } + assertFailsWith { + SharedPubkyContract.canonicalPubky("${WIRE_PUBKY.dropLast(1)}0") + } + assertFailsWith { + SharedPubkyContract.requireWirePubky("pubky$WIRE_PUBKY") + } + assertFailsWith { + SharedPubkyContract.requireWirePubky(WIRE_PUBKY.uppercase()) + } + } + + @Test + fun `credential Uri encodes the selected bare pubky in the v1 path`() { + assertEquals( + "content://app.pubkyring.sharedpubky/v1/identities/$WIRE_PUBKY/credential", + SharedPubkyContract.ringCredentialUriString("pubky$WIRE_PUBKY"), + ) + } + + @Test + fun `secret key wire format requires exactly 64 lowercase hex characters`() { + assertEquals(SECRET_KEY, SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY)) + assertFailsWith { + SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY.dropLast(1)) + } + assertFailsWith { + SharedPubkyContract.canonicalSecretKeyHex("${SECRET_KEY.dropLast(1)}z") + } + assertFailsWith { + SharedPubkyContract.canonicalSecretKeyHex(SECRET_KEY.uppercase()) + } + } + + @Test + fun `external reference rejects unsupported sources and versions`() { + assertFailsWith { + ExternalPubkyIdentityRef( + protocolVersion = 2, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = WIRE_PUBKY, + ).validated() + } + assertFailsWith { + ExternalPubkyIdentityRef( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = "other.app", + pubky = WIRE_PUBKY, + ).validated() + } + } +} diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt new file mode 100644 index 0000000000..641cfa8112 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyManifestTest.kt @@ -0,0 +1,41 @@ +package to.bitkit.data.sharing + +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.PermissionInfo +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.BuildConfig +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class, sdk = [34]) +class SharedPubkyManifestTest { + private val context = ApplicationProvider.getApplicationContext() + private val packageManager = context.packageManager + + @Test + fun `provider authority and permissions expand for the application variant`() { + val applicationId = BuildConfig.APPLICATION_ID + val permissionName = "$applicationId.permission.READ_SHARED_PUBKY" + val provider = requireNotNull( + packageManager.resolveContentProvider("$applicationId.sharedpubky", PackageManager.MATCH_ALL) + ) + + assertEquals(applicationId, provider.packageName) + assertEquals(permissionName, provider.readPermission) + assertEquals(permissionName, provider.writePermission) + assertTrue(provider.exported) + + val permission = packageManager.getPermissionInfo(permissionName, PackageManager.GET_META_DATA) + assertEquals( + PermissionInfo.PROTECTION_SIGNATURE, + permission.protectionLevel and PermissionInfo.PROTECTION_MASK_BASE, + ) + } +} diff --git a/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt new file mode 100644 index 0000000000..c83e0af6f4 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/sharing/SharedPubkyProviderTest.kt @@ -0,0 +1,78 @@ +package to.bitkit.data.sharing + +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SharedPubkyProviderTest { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + private const val SECRET_KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + } + + @Test + fun `borrowed active identity without a local secret is never exported`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = false, + secretKeyHex = null, + publicKeyFromSecret = { error("Must not derive a borrowed identity") }, + ) + + assertNull(identity) + } + + @Test + fun `disabled local identity is never exported`() { + val identity = localSharedPubkyIdentity( + exportEnabled = false, + managedSecretQuarantined = false, + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { WIRE_PUBKY }, + ) + + assertNull(identity) + } + + @Test + fun `quarantined managed identity is never exported`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = true, + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { error("Must not derive a quarantined identity") }, + ) + + assertNull(identity) + } + + @Test + fun `public discovery row excludes the local secret`() { + val identity = localSharedPubkyIdentity( + exportEnabled = true, + managedSecretQuarantined = false, + secretKeyHex = SECRET_KEY, + publicKeyFromSecret = { "pubky$WIRE_PUBKY" }, + ) + + assertEquals(WIRE_PUBKY, identity?.pubky) + assertContentEquals( + arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + WIRE_PUBKY, + ), + identity?.publicRow(), + ) + assertContentEquals( + arrayOf( + SharedPubkyContract.PROTOCOL_VERSION, + SharedPubkyContract.BITKIT_SOURCE, + WIRE_PUBKY, + SECRET_KEY, + ), + identity?.credentialRow(), + ) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 7972effba9..238a6638d6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -10,15 +10,24 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.PaykitProfile import com.synonym.paykit.PubkyAuthCompanionClaim import com.synonym.paykit.PublicationStatus +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.Mockito.clearInvocations import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -30,6 +39,11 @@ import to.bitkit.data.PubkyStoreData import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain +import to.bitkit.data.sharing.ExternalPubkyIdentityRef +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyCredential +import to.bitkit.data.sharing.SharedPubkyDiscovery +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.models.PubkyAuthClaim import to.bitkit.models.PubkyProfile import to.bitkit.models.PubkyRingAuthCallback @@ -48,12 +62,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 @@ -63,20 +80,47 @@ class PubkyRepoTest : BaseUnitTest() { private val imageLoader = mock() private val pubkyStore = mock() private val settingsStore = mock() + private val sharedPubkyDiscovery = mock() private val settingsFlow = MutableStateFlow(SettingsData()) + private val 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(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() = PubkyRepo( @@ -87,6 +131,7 @@ class PubkyRepoTest : BaseUnitTest() { pubkyStore = pubkyStore, settingsStore = settingsStore, httpClient = mock(), + sharedPubkyDiscovery = sharedPubkyDiscovery, ) @Test @@ -95,6 +140,151 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } + @Test + fun `adopt Ring identity persists source reference and never stores shared secret`() = test { + val identity = stubRingIdentity() + + val result = sut.adoptRingIdentity(identity) + + assertTrue(result.isSuccess) + assertEquals(VALID_SELF_KEY, sut.publicKey.value) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService) { signInExternal(SHARED_SECRET_KEY) } + verifyBlocking(keychain, never()) { + upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY) + } + assertNull(sut.snapshotSessionBackupState().getOrThrow()) + } + + @Test + fun `adopt Ring identity rejects credential whose secret derives another pubky`() = test { + val identity = stubRingIdentity(derivedPublicKey = VALID_CONTACT_KEY_B) + + val result = sut.adoptRingIdentity(identity) + + assertTrue(result.isFailure) + assertNull(sut.publicKey.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { signInExternal(SHARED_SECRET_KEY) } + } + + @Test + fun `Ring managed identity reads credential just in time for auth approval`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + + val result = sut.approveAuth("pubkyauth://signin", "/pub/example/:rw") + + assertTrue(result.isSuccess) + verifyBlocking(pubkyService) { + approveAuth("pubkyauth://signin", "/pub/example/:rw", SHARED_SECRET_KEY) + } + verifyBlocking(keychain, never()) { + upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, SHARED_SECRET_KEY) + } + } + + @Test + fun `missing Ring source clears borrowed reference and local session`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + clearInvocations(pubkyService, pubkyStore) + + val available = sut.validateExternalIdentitySource() + + assertFalse(available) + assertNull(sut.publicKey.value) + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + verifyBlocking(pubkyService, never()) { forceSignOut() } + } + + @Test + fun `source cleanup preserves marker when external session cleanup fails`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + val cleanupError = RuntimeException("cleanup failed") + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever { pubkyService.clearExternalSessionAccess() }.thenThrow(cleanupError) + clearInvocations(pubkyStore) + + val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() + + assertEquals(cleanupError.message, thrown?.message) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + verify(pubkyStore, never()).reset() + } + + @Test + fun `source cleanup keeps marker after reset failure and succeeds on retry`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + val resetError = RuntimeException("reset failed") + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + doThrow(resetError).whenever(pubkyStore).reset() + clearInvocations(pubkyService, pubkyStore) + + val thrown = runCatching { sut.validateExternalIdentitySource() }.exceptionOrNull() + + assertEquals(resetError.message, thrown?.message) + assertEquals(identity.toExternalRefForTest(), pubkyDataFlow.value.externalIdentityRef) + + doAnswer { + pubkyDataFlow.value = PubkyStoreData() + Unit + }.whenever(pubkyStore).reset() + assertFalse(sut.validateExternalIdentitySource()) + assertNull(pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, times(2)) { clearExternalSessionAccess() } + verifyBlocking(pubkyStore, times(2)) { reset() } + } + + @Test + fun `source cleanup quarantines but never deletes a conflicting managed secret`() = test { + val identity = stubRingIdentity() + assertTrue(sut.adoptRingIdentity(identity).isSuccess) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed-secret") + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)) + .thenReturn("1") + + assertFalse(sut.validateExternalIdentitySource()) + + verifyBlocking(keychain) { + upsertString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name, "1") + } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + + @Test + fun `Ring adoption cannot interleave with local identity restore`() = test { + val identity = stubRingIdentity() + val releaseExternalSignIn = CompletableDeferred() + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).doSuspendableAnswer { + releaseExternalSignIn.await() + VALID_SELF_KEY + } + + val adoption = async { sut.adoptRingIdentity(identity) } + runCurrent() + val restore = async { sut.restoreSessionBackupState(null) } + runCurrent() + + assertFalse(restore.isCompleted) + verifyBlocking(pubkyService, never()) { clearSessionAccess() } + + releaseExternalSignIn.complete(Unit) + advanceUntilIdle() + + assertTrue(adoption.await().isSuccess) + assertTrue(restore.await().isSuccess) + verifyBlocking(pubkyService) { clearSessionAccess() } + } + @Test fun `startAuthentication should return auth uri on success`() = test { val authUri = "pubky://auth?capabilities=..." @@ -200,7 +390,9 @@ class PubkyRepoTest : BaseUnitTest() { val authUrl = "pubkyauth://signin?caps=/pub/bitkit.to/:rw" val capabilities = "/pub/bitkit.to/:rw" 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) @@ -213,7 +405,9 @@ class PubkyRepoTest : BaseUnitTest() { val authUrl = "pubkyauth://signin?x-bitkit-claim=watch-only-account-v1" val secretKey = "local_secret" val payload = ByteArray(84) { it.toByte() } + authenticateForTesting(publicKey = VALID_SELF_KEY) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey) + whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY) val result = sut.approveAuthWithCompanionClaim(authUrl, payload) @@ -790,7 +984,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)) @@ -823,7 +1017,7 @@ class PubkyRepoTest : BaseUnitTest() { val session = "stale_session" whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(session) whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) - whenever(pubkyService.importSession(session)).thenAnswer { throw TestAppError("Expired") } + whenever(pubkyService.importExternalSession(session)).thenAnswer { throw TestAppError("Expired") } sut.initialize() @@ -832,6 +1026,49 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(keychain, never()) { delete(Keychain.Key.PAYKIT_SESSION.name) } } + @Test + fun `initialize retries quarantined external cleanup before removing its marker`() = test { + val identity = stubRingIdentity() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identity.toExternalRefForTest()) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("borrowed_session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("managed_secret") + whenever(keychain.loadString(Keychain.Key.PUBKY_MANAGED_SECRET_QUARANTINED.name)).thenReturn("1") + clearInvocations(pubkyService, pubkyStore) + + sut.initialize() + + assertNull(pubkyDataFlow.value.externalIdentityRef) + inOrder(pubkyService, pubkyStore) { + verify(pubkyService).clearExternalSessionAccess() + verify(pubkyStore).reset() + } + verifyBlocking(pubkyService, never()) { importExternalSession(any()) } + verifyBlocking(pubkyService, never()) { signInExternal(any()) } + verifyBlocking(keychain, never()) { delete(Keychain.Key.PUBKY_SECRET_KEY.name) } + } + + @Test + fun `initialize preserves external marker when source exists but sign in cannot recover`() = test { + val identity = stubRingIdentity() + val identityRef = identity.toExternalRefForTest() + pubkyDataFlow.value = PubkyStoreData(externalIdentityRef = identityRef) + whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("stale_session") + whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null) + whenever(pubkyService.importExternalSession("stale_session")) + .thenAnswer { throw TestAppError("Expired") } + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)) + .thenAnswer { throw TestAppError("Offline") } + clearInvocations(pubkyService, pubkyStore) + + sut.initialize() + + assertTrue(sut.sessionRestorationFailed.value) + assertFalse(sut.isAuthenticated.value) + assertEquals(identityRef, pubkyDataFlow.value.externalIdentityRef) + verifyBlocking(pubkyService, never()) { clearExternalSessionAccess() } + verifyBlocking(pubkyStore, never()) { reset() } + } + @Test fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test { val secretKey = "local_secret" @@ -860,6 +1097,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")) @@ -1287,6 +1525,32 @@ class PubkyRepoTest : BaseUnitTest() { assertEquals("pubky://avatar", contact.imageUrl) } + private suspend fun stubRingIdentity( + derivedPublicKey: String = VALID_SELF_KEY, + ): SharedPubkyIdentity { + val identity = SharedPubkyIdentity( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = VALID_SELF_KEY.removePrefix("pubky"), + ) + whenever(sharedPubkyDiscovery.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(sharedPubkyDiscovery.readRingCredential(identity.pubky)).thenReturn( + Result.success( + SharedPubkyCredential( + identity = identity, + secretKeyHex = SHARED_SECRET_KEY, + ), + ), + ) + whenever(pubkyService.publicKeyFromSecret(SHARED_SECRET_KEY)).thenReturn(derivedPublicKey) + whenever(pubkyService.signInExternal(SHARED_SECRET_KEY)).thenReturn(VALID_SELF_KEY) + whenever(pubkyService.resolveContactProfile(VALID_SELF_KEY, true)).thenReturn( + createResolution(VALID_SELF_KEY, pubkyProfile = createPubkyProfile(name = "Satoshi")), + ) + whenever(pubkyService.contactRecords()).thenReturn(emptyList()) + return identity + } + private suspend fun authenticateForTesting( publicKey: String = "test_pk_12345", secret: String = "test_secret", @@ -1385,3 +1649,9 @@ private class TestAppError(message: String) : AppError(message) private fun String.ensurePubkyPrefixForTest(): String = if (startsWith("pubky")) this else "pubky$this" + +private fun SharedPubkyIdentity.toExternalRefForTest() = ExternalPubkyIdentityRef( + protocolVersion = protocolVersion, + sourcePackage = sourcePackage, + pubky = pubky, +) diff --git a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt index 4e4e8aa325..fb91bcb203 100644 --- a/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt +++ b/app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt @@ -11,6 +11,7 @@ import to.bitkit.utils.AppError import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull class PaykitSdkServiceTest { @Test @@ -94,6 +95,52 @@ class PaykitSdkServiceTest { assertFailsWith { store.loadOrDeriveBytes() } } + @Test + fun `owned session requires and persists its exported local secret`() { + val secretKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + assertEquals( + secretKeyHex, + managedSecretForSessionPersistence( + shouldStoreLocalSecret = true, + exportedLocalSecretKeyHex = secretKeyHex, + existingManagedSecretKeyHex = null, + ), + ) + assertFailsWith { + managedSecretForSessionPersistence( + shouldStoreLocalSecret = true, + exportedLocalSecretKeyHex = null, + existingManagedSecretKeyHex = null, + ) + } + assertFailsWith { + managedSecretForSessionPersistence( + shouldStoreLocalSecret = true, + exportedLocalSecretKeyHex = secretKeyHex, + existingManagedSecretKeyHex = "different-secret", + ) + } + } + + @Test + fun `external session refuses to replace a managed local secret`() { + assertNull( + managedSecretForSessionPersistence( + shouldStoreLocalSecret = false, + exportedLocalSecretKeyHex = null, + existingManagedSecretKeyHex = null, + ), + ) + assertFailsWith { + managedSecretForSessionPersistence( + shouldStoreLocalSecret = false, + exportedLocalSecretKeyHex = null, + existingManagedSecretKeyHex = "managed-secret", + ) + } + } + private fun keyStore( loadBytes: () -> ByteArray?, upsertBytes: (ByteArray) -> Unit = {}, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index d338e9a84f..765db672d9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -1,19 +1,19 @@ package to.bitkit.ui.screens.profile import android.content.Context -import android.content.pm.PackageManager import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.R +import to.bitkit.data.sharing.SharedPubkyContract +import to.bitkit.data.sharing.SharedPubkyIdentity import to.bitkit.models.PubkyProfile import to.bitkit.models.Toast import to.bitkit.repositories.PubkyRepo @@ -21,124 +21,128 @@ import to.bitkit.test.BaseUnitTest import to.bitkit.ui.shared.toast.ToastEventBus import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class PubkyChoiceViewModelTest : BaseUnitTest() { + companion object { + private const val WIRE_PUBKY = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + } + private val context: Context = mock() - private val packageManager: PackageManager = mock() private val pubkyRepo: PubkyRepo = mock() private val pendingImportContacts = MutableStateFlow>(emptyList()) - private val isAuthenticated = MutableStateFlow(false) - private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) - - private lateinit var sut: PubkyChoiceViewModel @Before - fun setUp() { - whenever(context.packageManager).thenReturn(packageManager) - whenever(context.getString(R.string.common__error)).thenReturn("Error") - whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") + fun setUp() = runBlocking { + whenever(context.getString(R.string.profile__choice_error)).thenReturn("Couldn't use this pubky") whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) - whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) - whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents) - } - - private fun createSut() { - sut = PubkyChoiceViewModel( - context = context, - pubkyRepo = pubkyRepo, - ) + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(emptyList())) + Unit } @Test - fun `session restoration redirects to profile when already authenticated`() = test { - isAuthenticated.value = true - createSut() + fun `discovery resolves safe public profile data for Ring choices`() = test { + val identity = ringIdentity() + val profile = PubkyProfile.forDisplay( + publicKey = SharedPubkyContract.toBitkitPubky(WIRE_PUBKY), + name = "Satoshi", + imageUrl = "pubky://avatar", + ) + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile(profile.publicKey)).thenReturn(Result.success(profile)) + val sut = createSut() advanceUntilIdle() - assertTrue(sut.uiState.value.navigateToProfile) + assertFalse(sut.uiState.value.isDiscovering) + assertEquals(1, sut.uiState.value.identities.size) + assertEquals(WIRE_PUBKY, sut.uiState.value.identities.single().pubky) + assertEquals(profile, sut.uiState.value.identities.single().profile) } @Test - fun `clearProfileNavigation clears profile redirect`() = test { - createSut() - isAuthenticated.value = true + fun `selection adopts exact Ring identity then opens contact overview`() = test { + val identity = ringIdentity() + pendingImportContacts.value = listOf(PubkyProfile.placeholder("pubky$WIRE_PUBKY")) + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.prepareImport()).thenReturn(Result.success(Unit)) + val sut = createSut() advanceUntilIdle() - sut.clearProfileNavigation() + val effects = mutableListOf() + val job = launch { sut.effects.collect(effects::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) + advanceUntilIdle() - assertFalse(sut.uiState.value.navigateToProfile) + verify(pubkyRepo).adoptRingIdentity(identity) + verify(pubkyRepo).prepareImport() + assertEquals( + listOf(PubkyChoiceEffect.NavigateToContactImportOverview), + effects, + ) + assertNull(sut.uiState.value.selectedPubky) + job.cancel() } @Test - fun `waitForApproval prepareImport failure clears loading and emits no navigation`() = test { - createSut() - whenever(pubkyRepo.completeAuthentication()).thenReturn(Result.success(Unit)) - whenever(pubkyRepo.prepareImport()).thenReturn(Result.failure(RuntimeException("Import failed"))) + fun `selection with no contacts opens Pay Contacts`() = test { + val identity = ringIdentity() + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.success(Unit)) + whenever(pubkyRepo.prepareImport()).thenReturn(Result.success(Unit)) + val sut = createSut() + advanceUntilIdle() val effects = mutableListOf() - val toasts = mutableListOf() - val effectsJob = launch { sut.effects.collect { effects.add(it) } } - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - - sut.waitForApproval() + val job = launch { sut.effects.collect(effects::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) advanceUntilIdle() - assertFalse(sut.uiState.value.isLoadingAfterAuth) - assertFalse(sut.uiState.value.isWaitingForRing) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isNotEmpty()) - assertEquals(Toast.ToastType.ERROR, toasts.last().type) - assertEquals("Error", toasts.last().title) - assertEquals("Import failed", toasts.last().description) - - effectsJob.cancel() - toastJob.cancel() + assertEquals( + listOf(PubkyChoiceEffect.NavigateToPayContacts), + effects, + ) + job.cancel() } @Test - fun `startRingAuth shows dialog when Ring is not installed`() = test { - createSut() - whenever(packageManager.getLaunchIntentForPackage(PubkyChoiceViewModel.PUBKY_RING_PACKAGE)) - .thenReturn(null) + fun `failed selection clears loading and reports error`() = test { + val identity = ringIdentity() + val error = IllegalStateException("Credential mismatch") + whenever(pubkyRepo.discoverRingIdentities()).thenReturn(Result.success(listOf(identity))) + whenever(pubkyRepo.fetchRemoteProfile("pubky$WIRE_PUBKY")) + .thenReturn(Result.success(PubkyProfile.placeholder("pubky$WIRE_PUBKY"))) + whenever(pubkyRepo.adoptRingIdentity(identity)).thenReturn(Result.failure(error)) + val sut = createSut() + advanceUntilIdle() - val effects = mutableListOf() val toasts = mutableListOf() - val effectsJob = launch { sut.effects.collect { effects.add(it) } } - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } - - sut.startRingAuth() + val job = launch { ToastEventBus.events.collect(toasts::add) } + sut.selectRingIdentity(sut.uiState.value.identities.single()) advanceUntilIdle() - assertTrue(sut.uiState.value.showRingNotInstalledDialog) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isEmpty()) - verify(pubkyRepo, never()).startAuthentication() - - effectsJob.cancel() - toastJob.cancel() + assertNull(sut.uiState.value.selectedPubky) + assertTrue(toasts.isNotEmpty()) + assertEquals("Couldn't use this pubky", toasts.last().title) + assertEquals(error.message, toasts.last().description) + job.cancel() } - @Test - fun `onRingLaunchFailed shows dialog without toast`() = test { - createSut() - val effects = mutableListOf() - val toasts = mutableListOf() - val effectsJob = launch { sut.effects.collect { effects.add(it) } } - val toastJob = launch { ToastEventBus.events.collect { toasts.add(it) } } + private fun createSut() = PubkyChoiceViewModel( + context = context, + pubkyRepo = pubkyRepo, + ) - sut.onRingLaunchFailed() - advanceUntilIdle() - - assertFalse(sut.uiState.value.isWaitingForRing) - assertTrue(sut.uiState.value.showRingNotInstalledDialog) - assertTrue(effects.isEmpty()) - assertTrue(toasts.isEmpty()) - verify(pubkyRepo).cancelAuthentication() - - effectsJob.cancel() - toastJob.cancel() - } + private fun ringIdentity() = SharedPubkyIdentity( + protocolVersion = SharedPubkyContract.PROTOCOL_VERSION, + sourcePackage = SharedPubkyContract.RING_SOURCE, + pubky = WIRE_PUBKY, + ) } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 20067b502f..b81b72414f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -285,6 +285,16 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(hwWalletRepo).onAppForegrounded() } + @Test + fun `onAppResumed validates an external Pubky identity source`() = test { + whenever(pubkyRepo.validateExternalIdentitySource()).thenReturn(true) + + sut.onAppResumed() + advanceUntilIdle() + + verify(pubkyRepo).validateExternalIdentitySource() + } + @Test fun `hardware received tx details navigate directly to hardware activity`() = test { val txId = "hardware-tx" diff --git a/changelog.d/next/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.