Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyContactLink.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package to.bitkit.models

import android.net.Uri

object PubkyContactLink {
fun matches(uri: Uri): Boolean =
uri.scheme.equals("bitkit", ignoreCase = true) && uri.host.equals("contact", ignoreCase = true)

fun publicKey(uri: Uri): String? {
if (!matches(uri)) return null
if (!uri.encodedAuthority.equals("contact", ignoreCase = true) ||
!uri.path.isNullOrEmpty() || uri.fragment != null
) {
return null
}
if (uri.queryParameterNames != setOf("pubky") || uri.encodedQuery.orEmpty().contains('&')) return null
val key = uri.getQueryParameters("pubky").singleOrNull()
?.takeIf { it.length <= PubkyPublicKeyFormat.maximumInputLength } ?: return null

return PubkyPublicKeyFormat.canonicalized(key)
}
}
13 changes: 13 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyPublicKeyFormat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import to.bitkit.ext.ellipsisMiddle
import java.util.Locale

object PubkyPublicKeyFormat {
/** Z-base-32 characters ordered by their five-bit values. */
private const val zBase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769"

/** Mask for the only data bit in the final symbol of a 32-byte key. */
private const val zBase32FinalSymbolDataMask = 0b10000

private const val displayEdgeLength = 4
private const val redactedLength = 16
const val maximumInputLength = 57
Expand All @@ -20,6 +26,13 @@ object PubkyPublicKeyFormat {
return runCatching { PaykitPublicKeys.normalize(bounded(input)) }.getOrNull()
}

fun canonicalized(input: String): String? {
val publicKey = normalized(input) ?: return null
val lastCharacterValue = zBase32Alphabet.indexOf(publicKey.last())
val canonicalLastCharacter = zBase32Alphabet[lastCharacterValue and zBase32FinalSymbolDataMask]
return publicKey.dropLast(1) + canonicalLastCharacter
}

fun matches(lhs: String?, rhs: String?): Boolean {
val normalizedLhs = lhs?.let(::normalized) ?: return false
val normalizedRhs = rhs?.let(::normalized) ?: return false
Expand Down
28 changes: 26 additions & 2 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ class PubkyRepo @Inject constructor(
private val _contactsLoadVersion = MutableStateFlow(0L)
val contactsLoadVersion: StateFlow<Long> = _contactsLoadVersion.asStateFlow()

private val _contactsLoadCompletionVersion = MutableStateFlow(0L)
val contactsLoadCompletionVersion: StateFlow<Long> = _contactsLoadCompletionVersion.asStateFlow()

private val _isLoadingContacts = MutableStateFlow(false)
val isLoadingContacts: StateFlow<Boolean> = _isLoadingContacts.asStateFlow()

Expand Down Expand Up @@ -805,6 +808,7 @@ class PubkyRepo @Inject constructor(
if (!loadContactsMutex.tryLock()) return

_isLoadingContacts.update { true }
var shouldMarkLoadCompleted = false
try {
runSuspendCatching {
withContext(ioDispatcher) {
Expand Down Expand Up @@ -836,17 +840,20 @@ class PubkyRepo @Inject constructor(
}
_contacts.update { loadedContacts }
markContactsLoaded()
shouldMarkLoadCompleted = true
}.onFailure {
shouldMarkLoadCompleted = _publicKey.value == pk
Logger.error("Failed to load contacts", it, context = TAG)
}
} finally {
_isLoadingContacts.update { false }
loadContactsMutex.unlock()
if (shouldMarkLoadCompleted && _publicKey.value == pk) markContactsLoadCompleted()
}
}

suspend fun fetchContactProfile(publicKey: String): Result<PubkyProfile> {
val prefixedKey = runCatching { requireAddableContactPublicKey(publicKey) }
val prefixedKey = runCatching { requireCanonicalAddableContactPublicKey(publicKey) }
.getOrElse { return Result.failure(it) }
return resolveContactProfile(prefixedKey)
.map { it ?: PubkyProfile.placeholder(prefixedKey) }
Expand All @@ -864,7 +871,7 @@ class PubkyRepo @Inject constructor(
existingProfile: PubkyProfile? = null,
): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
val prefixedKey = requireAddableContactPublicKey(
val prefixedKey = requireCanonicalAddableContactPublicKey(
publicKey = publicKey,
allowExisting = existingProfile != null,
)
Expand Down Expand Up @@ -1408,6 +1415,7 @@ class PubkyRepo @Inject constructor(
_profile.update { null }
_contacts.update { emptyList() }
_contactsLoadVersion.update { 0L }
_contactsLoadCompletionVersion.update { 0L }
clearPendingImport()
_sessionRestorationFailed.update { false }
_authState.update { PubkyAuthState.Idle }
Expand All @@ -1417,6 +1425,10 @@ class PubkyRepo @Inject constructor(
_contactsLoadVersion.update { it + 1 }
}

private fun markContactsLoadCompleted() {
_contactsLoadCompletionVersion.update { it + 1 }
}

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) }
Expand All @@ -1443,6 +1455,18 @@ class PubkyRepo @Inject constructor(

private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String {
val prefixedKey = PubkyPublicKeyFormat.normalized(publicKey)
return requireValidAddableContactPublicKey(prefixedKey, allowExisting)
}

private fun requireCanonicalAddableContactPublicKey(
publicKey: String,
allowExisting: Boolean = false,
): String {
val prefixedKey = PubkyPublicKeyFormat.canonicalized(publicKey)
return requireValidAddableContactPublicKey(prefixedKey, allowExisting)
}

private fun requireValidAddableContactPublicKey(prefixedKey: String?, allowExisting: Boolean): String {
contactValidationError(prefixedKey, allowExisting)?.let { throw it }
return checkNotNull(prefixedKey) { "Normalized pubky key is required" }
}
Expand Down
65 changes: 53 additions & 12 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ import to.bitkit.models.NewTransactionSheetDirection
import to.bitkit.models.NewTransactionSheetType
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.PubkyAuthRequest
import to.bitkit.models.PubkyContactLink
import to.bitkit.models.PubkyProfile
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.models.PubkyRingAuthCallback
Expand Down Expand Up @@ -2359,27 +2360,50 @@ class AppViewModel @Inject constructor(
data: String,
allowPubkyAuth: Boolean,
): Boolean {
if (source != ScanSource.DEEPLINK || !allowPubkyAuth) return true
if (!PubkyAuthRequest.isProtocolUrl(data)) return true
if (source != ScanSource.DEEPLINK) return true
val uri = Uri.parse(data)
val isContactLink = PubkyContactLink.matches(uri)
if (isContactLink && PubkyContactLink.publicKey(uri) == null) return true
if (!isContactLink && (!allowPubkyAuth || !PubkyAuthRequest.isProtocolUrl(data))) return true

if (!PubkyAuthRequest.isSignupUrl(data)) {
val isInitializationReady = withTimeoutOrNull(PubkyService.AUTHORIZATION_TIMEOUT) {
pubkyRepo.awaitInitialization()
true
awaitContactDataForDeeplink(isContactLink)
} ?: false
if (!isInitializationReady) {
Logger.warn("Timed out waiting for Pubky initialization", context = TAG)
Logger.warn("Failed to initialize Pubky deeplink", context = TAG)
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.profile__auth_error_title),
description = context.getString(R.string.profile__auth_error_timeout),
title = context.getString(
if (isContactLink) R.string.other__scan_err_decoding else R.string.profile__auth_error_title,
),
description = context.getString(
if (isContactLink) {
R.string.other__scan__error__generic
} else {
R.string.profile__auth_error_timeout
},
),
)
return false
}
}
return isPaykitUiEnabledFromSettings() && walletRepo.walletExists()
}

private suspend fun awaitContactDataForDeeplink(isContactLink: Boolean): Boolean {
if (!isContactLink || pubkyRepo.publicKey.value == null) return true
Comment thread
jvsena42 marked this conversation as resolved.

pubkyRepo.contactsLoadCompletionVersion.first { it > 0 }
if (pubkyRepo.contactsLoadVersion.value > 0L) return true
Comment thread
ovitrif marked this conversation as resolved.

val completionVersion = pubkyRepo.contactsLoadCompletionVersion.value
pubkyRepo.loadContacts()
Comment thread
ovitrif marked this conversation as resolved.
pubkyRepo.contactsLoadCompletionVersion.first { it > completionVersion }
return pubkyRepo.contactsLoadVersion.value > 0L
}

private suspend fun isPaykitUiEnabledFromSettings() =
PaykitFeatureFlags.isUiEnabled(settingsStore.isPaykitEnabled.first())

Expand Down Expand Up @@ -2880,7 +2904,18 @@ class AppViewModel @Inject constructor(
) = withContext(bgDispatcher) {
if (rejectPubkyAuthScan(result, allowPubkyAuth, contactPaymentContext)) return@withContext

val input = result.removeLightningSchemes()
val input = if (routePubkyKeys && PubkyContactLink.matches(Uri.parse(result))) {
PubkyContactLink.publicKey(Uri.parse(result)) ?: run {
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.other__scan_err_decoding),
description = context.getString(R.string.other__scan__error__generic),
)
return@withContext
}
} else {
result.removeLightningSchemes()
}

val contactPaymentProfile = activeContactPaymentProfile()
val incomingPaymentRequest = activeIncomingPaymentRequest()
Expand Down Expand Up @@ -2941,12 +2976,12 @@ class AppViewModel @Inject constructor(
return@withContext
}

if (routePubkyKeys && isPaykitEnabled.value) {
if (routePubkyKeys && isPaykitUiEnabledFromSettings()) {
val route = resolvePastedPubkyRoute(
input = input,
ownPublicKey = pubkyRepo.publicKey.value,
contacts = pubkyRepo.contacts.value,
isPaykitEnabled = isPaykitEnabled.value,
isPaykitEnabled = true,
)

if (route != null) {
Expand Down Expand Up @@ -5579,6 +5614,7 @@ class AppViewModel @Inject constructor(

private fun processDeeplink(uri: Uri) = viewModelScope.launch {
val value = uri.toString()
val isContactLink = PubkyContactLink.matches(uri)
if (SamRockSetupRequest.isProtocolUrl(value)) {
if (!walletRepo.walletExists()) return@launch

Expand All @@ -5596,7 +5632,7 @@ class AppViewModel @Inject constructor(
return@launch
}

if (uri.isRecoveryModeDeeplink()) {
if (!isContactLink && uri.isRecoveryModeDeeplink()) {
lightningRepo.setRecoveryMode(enabled = true)
delay(SCREEN_TRANSITION_DELAY)
mainScreenEffect(
Expand Down Expand Up @@ -5626,7 +5662,12 @@ class AppViewModel @Inject constructor(

if (!walletRepo.walletExists()) return@launch

launchScan(source = ScanSource.DEEPLINK, data = value, startDelay = SCREEN_TRANSITION_DELAY)
launchScan(
source = ScanSource.DEEPLINK,
data = value,
startDelay = SCREEN_TRANSITION_DELAY,
routePubkyKeys = isContactLink,
)
}

fun consumeScreenDeepLink() {
Expand Down Expand Up @@ -6008,7 +6049,7 @@ internal fun resolvePastedPubkyRoute(
): Routes? {
if (!isPaykitEnabled) return null

val normalizedKey = PubkyPublicKeyFormat.normalized(input) ?: return null
val normalizedKey = PubkyPublicKeyFormat.canonicalized(input) ?: return null

if (PubkyPublicKeyFormat.matches(normalizedKey, ownPublicKey)) {
return Routes.Profile
Expand Down
51 changes: 51 additions & 0 deletions app/src/test/java/to/bitkit/models/PubkyContactLinkTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package to.bitkit.models

import androidx.core.net.toUri
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PubkyContactLinkTest {
private val key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy"
private val nonCanonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"

@Test
fun `accepts raw prefixed and encoded keys`() {
listOf(
nonCanonicalKey.removePrefix("pubky"),
nonCanonicalKey,
nonCanonicalKey.uppercase(),
nonCanonicalKey.replace("pubky", "%70ubky"),
).forEach { value ->
assertEquals(key, PubkyContactLink.publicKey("bitkit://contact?pubky=$value".toUri()))
}
}

@Test
fun `rejects malformed links and non-key payloads`() {
listOf(
"https://contact?pubky=$key",
"bitkit://other?pubky=$key",
"bitkit://user@contact?pubky=$key",
"bitkit://contact:123?pubky=$key",
"bitkit://contact:invalid?pubky=$key",
"bitkit://contact/path?pubky=$key",
"bitkit://contact?pubky=$key#fragment",
"bitkit://contact",
"bitkit://contact?pubky=",
"bitkit://contact?pubky=$key&pubky=$key",
"bitkit://contact?pubky=$key&other=value",
"bitkit://contact?pubky=${key}extra",
"bitkit://contact?pubky=invalid",
"bitkit://contact?pubky=bitcoin%3Abc1example",
"bitkit://contact?pubky=pubkyauth%3A%2F%2Fsignin_grant",
).forEach { link ->
assertNull(PubkyContactLink.publicKey(link.toUri()), link)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ class PubkyPublicKeyFormatTest {
)
}

@Test
fun `canonicalized clears final padding bits`() {
val nonCanonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
val canonicalKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy"

assertEquals(canonicalKey, PubkyPublicKeyFormat.canonicalized(nonCanonicalKey))
assertEquals(canonicalKey, PubkyPublicKeyFormat.canonicalized(canonicalKey))
}

@Test
fun `redacted shortens normalized pubky keys`() {
val rawKey = "3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
Expand Down
Loading
Loading