Skip to content

Commit c0de815

Browse files
ben-kaufmanovitrif
authored andcommitted
fix: add pubky ring callbacks
1 parent c55355e commit c0de815

15 files changed

Lines changed: 375 additions & 16 deletions

File tree

app/src/main/java/to/bitkit/domain/commands/NotifyPaymentReceivedHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import to.bitkit.models.NewTransactionSheetType
1717
import to.bitkit.models.NotificationDetails
1818
import to.bitkit.models.PrimaryDisplay
1919
import to.bitkit.models.formatToModernDisplay
20-
import to.bitkit.repositories.ActivityRepo
2120
import to.bitkit.models.msatCeilOf
21+
import to.bitkit.repositories.ActivityRepo
2222
import to.bitkit.repositories.CurrencyRepo
2323
import to.bitkit.utils.Logger
2424
import javax.inject.Inject

app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import to.bitkit.ext.amountOnClose
2626
import to.bitkit.ext.toUserMessage
2727
import to.bitkit.models.BITCOIN_SYMBOL
2828
import to.bitkit.models.BlocktankNotificationType
29-
import to.bitkit.models.msatCeilOf
3029
import to.bitkit.models.BlocktankNotificationType.cjitPaymentArrived
3130
import to.bitkit.models.BlocktankNotificationType.incomingHtlc
3231
import to.bitkit.models.BlocktankNotificationType.mutualClose
@@ -36,6 +35,7 @@ import to.bitkit.models.NewTransactionSheetDetails
3635
import to.bitkit.models.NewTransactionSheetDirection
3736
import to.bitkit.models.NewTransactionSheetType
3837
import to.bitkit.models.NotificationDetails
38+
import to.bitkit.models.msatCeilOf
3939
import to.bitkit.repositories.ActivityRepo
4040
import to.bitkit.repositories.BlocktankRepo
4141
import to.bitkit.repositories.LightningRepo
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package to.bitkit.models
2+
3+
import android.net.Uri
4+
5+
sealed interface PubkyRingAuthCallback {
6+
companion object {
7+
private const val BITKIT_SCHEME = "bitkit"
8+
private const val PUBKY_AUTH_HOST = "pubky-auth"
9+
private const val SUCCESS_PATH = "/success"
10+
private const val CANCEL_PATH = "/cancel"
11+
private const val ERROR_PATH = "/error"
12+
private const val NONCE_PARAM = "nonce"
13+
private const val ERROR_MESSAGE_PARAM = "errorMessage"
14+
15+
fun parse(uri: Uri): PubkyRingAuthCallback? {
16+
if (uri.scheme != BITKIT_SCHEME || uri.host != PUBKY_AUTH_HOST) return null
17+
18+
val nonce = uri.getQueryParameter(NONCE_PARAM)
19+
return when (uri.path) {
20+
SUCCESS_PATH -> Success(nonce)
21+
CANCEL_PATH -> Cancel(nonce)
22+
ERROR_PATH -> Error(uri.getQueryParameter(ERROR_MESSAGE_PARAM), nonce)
23+
else -> null
24+
}
25+
}
26+
}
27+
28+
val nonce: String?
29+
30+
data class Success(override val nonce: String?) : PubkyRingAuthCallback
31+
data class Cancel(override val nonce: String?) : PubkyRingAuthCallback
32+
data class Error(val message: String?, override val nonce: String?) : PubkyRingAuthCallback
33+
}
34+
35+
sealed interface PubkyRingAuthCallbackHandlingResult {
36+
data object Ignored : PubkyRingAuthCallbackHandlingResult
37+
data object Handled : PubkyRingAuthCallbackHandlingResult
38+
data class TrustedError(val message: String?) : PubkyRingAuthCallbackHandlingResult
39+
data object UntrustedError : PubkyRingAuthCallbackHandlingResult
40+
}
41+
42+
object PubkyRingAuthUrlBuilder {
43+
const val SUCCESS_CALLBACK = "bitkit://pubky-auth/success"
44+
const val CANCEL_CALLBACK = "bitkit://pubky-auth/cancel"
45+
const val ERROR_CALLBACK = "bitkit://pubky-auth/error"
46+
const val SOURCE = "Bitkit"
47+
48+
fun addCallbacks(authUrl: String, nonce: String? = null): String? {
49+
val uri = Uri.parse(authUrl)
50+
if (uri.scheme.isNullOrBlank()) return null
51+
52+
return uri.buildUpon()
53+
.appendQueryParameter("x-success", callbackUrl(SUCCESS_CALLBACK, nonce))
54+
.appendQueryParameter("x-cancel", callbackUrl(CANCEL_CALLBACK, nonce))
55+
.appendQueryParameter("x-error", callbackUrl(ERROR_CALLBACK, nonce))
56+
.appendQueryParameter("x-source", SOURCE)
57+
.build()
58+
.toString()
59+
}
60+
61+
private fun callbackUrl(baseUrl: String, nonce: String?): String {
62+
if (nonce.isNullOrBlank()) return baseUrl
63+
64+
return Uri.parse(baseUrl)
65+
.buildUpon()
66+
.appendQueryParameter("nonce", nonce)
67+
.build()
68+
.toString()
69+
}
70+
}

app/src/main/java/to/bitkit/repositories/PubkyRepo.kt

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import kotlinx.coroutines.SupervisorJob
1212
import kotlinx.coroutines.async
1313
import kotlinx.coroutines.awaitAll
1414
import kotlinx.coroutines.coroutineScope
15+
import kotlinx.coroutines.flow.MutableSharedFlow
1516
import kotlinx.coroutines.flow.MutableStateFlow
1617
import kotlinx.coroutines.flow.SharingStarted
1718
import kotlinx.coroutines.flow.StateFlow
19+
import kotlinx.coroutines.flow.asSharedFlow
1820
import kotlinx.coroutines.flow.asStateFlow
1921
import kotlinx.coroutines.flow.combine
2022
import kotlinx.coroutines.flow.map
@@ -33,23 +35,33 @@ import to.bitkit.models.PubkyProfile
3335
import to.bitkit.models.PubkyProfileData
3436
import to.bitkit.models.PubkyProfileLink
3537
import to.bitkit.models.PubkyPublicKeyFormat
38+
import to.bitkit.models.PubkyRingAuthCallback
39+
import to.bitkit.models.PubkyRingAuthCallbackHandlingResult
3640
import to.bitkit.models.PubkySessionBackupKind
3741
import to.bitkit.models.PubkySessionBackupV1
3842
import to.bitkit.services.PubkyService
3943
import to.bitkit.utils.AppError
4044
import to.bitkit.utils.Logger
4145
import java.io.ByteArrayOutputStream
46+
import java.util.UUID
4247
import javax.inject.Inject
4348
import javax.inject.Singleton
4449
import kotlin.math.min
4550

4651
enum class PubkyAuthState { Idle, Authenticating, Authenticated }
4752

53+
data class PubkyRingAuthRequest(
54+
val authUrl: String,
55+
val callbackNonce: String,
56+
)
57+
4858
sealed class PubkyContactError(message: String) : AppError(message) {
4959
data object CannotAddSelf : PubkyContactError("Cannot add your own pubky as a contact")
5060
data object InvalidFormat : PubkyContactError("Invalid pubky key format")
5161
}
5262

63+
private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active")
64+
5365
@Suppress("TooManyFunctions", "LargeClass")
5466
@Singleton
5567
class PubkyRepo @Inject constructor(
@@ -76,6 +88,9 @@ class PubkyRepo @Inject constructor(
7688
private var isServiceInitialized = false
7789

7890
private val _authState = MutableStateFlow(PubkyAuthState.Idle)
91+
private val _activeAuthAttemptId = MutableStateFlow<String?>(null)
92+
private val _authCancelEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
93+
val authCancelEvents = _authCancelEvents.asSharedFlow()
7994

8095
private val _profile = MutableStateFlow<PubkyProfile?>(null)
8196
val profile: StateFlow<PubkyProfile?> = _profile.asStateFlow()
@@ -104,7 +119,7 @@ class PubkyRepo @Inject constructor(
104119
private val _backupStateVersion = MutableStateFlow(0L)
105120
val backupStateVersion: StateFlow<Long> = _backupStateVersion.asStateFlow()
106121

107-
val isAuthenticated: StateFlow<Boolean> = _authState.map { it == PubkyAuthState.Authenticated }
122+
val isAuthenticated: StateFlow<Boolean> = _publicKey.map { it != null }
108123
.stateIn(scope, SharingStarted.Eagerly, false)
109124

110125
val displayName: StateFlow<String?> = combine(_profile, pubkyStore.data) { profile, cached ->
@@ -236,20 +251,27 @@ class PubkyRepo @Inject constructor(
236251

237252
// region Ring auth flow
238253

239-
suspend fun startAuthentication(): Result<String> {
254+
suspend fun startAuthentication(): Result<PubkyRingAuthRequest> {
255+
val attemptId = UUID.randomUUID().toString()
256+
_activeAuthAttemptId.update { attemptId }
240257
_authState.update { PubkyAuthState.Authenticating }
241258
return runCatching {
242-
withContext(ioDispatcher) { pubkyService.startAuth() }
259+
val authUrl = withContext(ioDispatcher) { pubkyService.startAuth() }
260+
PubkyRingAuthRequest(authUrl = authUrl, callbackNonce = attemptId)
243261
}.onFailure {
244-
_authState.update { PubkyAuthState.Idle }
262+
_activeAuthAttemptId.update { null }
263+
restoreAuthStateAfterAuthFlow()
245264
}
246265
}
247266

248267
suspend fun completeAuthentication(): Result<Unit> {
268+
val attemptId = _activeAuthAttemptId.value
249269
return runCatching {
250270
withContext(ioDispatcher) {
251271
val sessionSecret = pubkyService.completeAuth()
272+
ensureAuthAttemptActive(attemptId)
252273
val pk = pubkyService.importSession(sessionSecret).ensurePubkyPrefix()
274+
ensureAuthAttemptActive(attemptId)
253275

254276
runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
255277
keychain.upsertString(Keychain.Key.PAYKIT_SESSION.name, sessionSecret)
@@ -258,8 +280,14 @@ class PubkyRepo @Inject constructor(
258280
pk
259281
}
260282
}.onFailure {
261-
_authState.update { PubkyAuthState.Idle }
283+
if (_activeAuthAttemptId.value == attemptId) {
284+
_activeAuthAttemptId.update { null }
285+
}
286+
restoreAuthStateAfterAuthFlow()
262287
}.onSuccess { pk ->
288+
if (_activeAuthAttemptId.value == attemptId) {
289+
_activeAuthAttemptId.update { null }
290+
}
263291
_publicKey.update { pk }
264292
_authState.update { PubkyAuthState.Authenticated }
265293
Logger.info("Completed pubky auth for '$pk'", context = TAG)
@@ -271,13 +299,84 @@ class PubkyRepo @Inject constructor(
271299
runCatching {
272300
withContext(ioDispatcher) { pubkyService.cancelAuth() }
273301
}.onFailure { Logger.warn("Failed to cancel auth", it, context = TAG) }
274-
_authState.update { PubkyAuthState.Idle }
302+
endAuthAttempt()
275303
}
276304

277305
fun cancelAuthenticationSync() {
278306
scope.launch { cancelAuthentication() }
279307
}
280308

309+
suspend fun handleAuthCallback(callback: PubkyRingAuthCallback): PubkyRingAuthCallbackHandlingResult {
310+
if (!isCurrentAuthCallback(callback)) {
311+
return handleInvalidAuthCallback(callback)
312+
}
313+
314+
return when (callback) {
315+
is PubkyRingAuthCallback.Success -> {
316+
Logger.info("Received Pubky Ring auth success callback", context = TAG)
317+
PubkyRingAuthCallbackHandlingResult.Handled
318+
}
319+
is PubkyRingAuthCallback.Cancel -> {
320+
Logger.info("Received Pubky Ring auth cancel callback", context = TAG)
321+
cancelAuthentication()
322+
PubkyRingAuthCallbackHandlingResult.Handled
323+
}
324+
is PubkyRingAuthCallback.Error -> {
325+
Logger.warn("Received Pubky Ring auth error callback", context = TAG)
326+
cancelAuthentication()
327+
PubkyRingAuthCallbackHandlingResult.TrustedError(callback.message)
328+
}
329+
}
330+
}
331+
332+
private fun handleInvalidAuthCallback(
333+
callback: PubkyRingAuthCallback,
334+
): PubkyRingAuthCallbackHandlingResult {
335+
if (_activeAuthAttemptId.value == null) {
336+
Logger.warn("Ignoring Pubky Ring auth callback with missing or invalid nonce", context = TAG)
337+
return PubkyRingAuthCallbackHandlingResult.Ignored
338+
}
339+
340+
return when (callback) {
341+
is PubkyRingAuthCallback.Success -> {
342+
Logger.warn("Ignoring Pubky Ring auth success callback with missing or invalid nonce", context = TAG)
343+
PubkyRingAuthCallbackHandlingResult.Ignored
344+
}
345+
is PubkyRingAuthCallback.Cancel -> {
346+
Logger.warn("Received Pubky Ring auth cancel callback with missing or invalid nonce", context = TAG)
347+
endAuthAttempt()
348+
PubkyRingAuthCallbackHandlingResult.Handled
349+
}
350+
is PubkyRingAuthCallback.Error -> {
351+
Logger.warn("Received Pubky Ring auth error callback with missing or invalid nonce", context = TAG)
352+
endAuthAttempt()
353+
PubkyRingAuthCallbackHandlingResult.UntrustedError
354+
}
355+
}
356+
}
357+
358+
private fun isCurrentAuthCallback(callback: PubkyRingAuthCallback): Boolean {
359+
val activeAuthAttemptId = _activeAuthAttemptId.value ?: return false
360+
return callback.nonce == activeAuthAttemptId
361+
}
362+
363+
private fun ensureAuthAttemptActive(attemptId: String?) {
364+
if (attemptId == null) return
365+
if (_activeAuthAttemptId.value == attemptId) return
366+
367+
throw PubkyAuthAttemptInactive()
368+
}
369+
370+
private fun endAuthAttempt() {
371+
_activeAuthAttemptId.update { null }
372+
_authCancelEvents.tryEmit(Unit)
373+
restoreAuthStateAfterAuthFlow()
374+
}
375+
376+
private fun restoreAuthStateAfterAuthFlow() {
377+
_authState.update { if (_publicKey.value == null) PubkyAuthState.Idle else PubkyAuthState.Authenticated }
378+
}
379+
281380
// endregion
282381

283382
// region Profile loading

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import to.bitkit.ext.toHex
3232
import to.bitkit.models.ALL_ADDRESS_TYPE_STRINGS
3333
import to.bitkit.models.AddressModel
3434
import to.bitkit.models.BalanceState
35-
import to.bitkit.models.msatFloorOf
3635
import to.bitkit.models.DEFAULT_ADDRESS_TYPE_STRING
36+
import to.bitkit.models.msatFloorOf
3737
import to.bitkit.models.toDerivationPath
3838
import to.bitkit.services.CoreService
3939
import to.bitkit.usecases.DeriveBalanceStateUseCase

app/src/main/java/to/bitkit/services/CoreService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ import to.bitkit.data.CacheStore
7272
import to.bitkit.data.SettingsStore
7373
import to.bitkit.env.Env
7474
import to.bitkit.ext.amountSats
75-
import to.bitkit.models.msatFloorOf
7675
import to.bitkit.ext.channelId
7776
import to.bitkit.ext.create
7877
import to.bitkit.ext.latestSpendingTxid
7978
import to.bitkit.models.addressTypeFromAddress
79+
import to.bitkit.models.msatFloorOf
8080
import to.bitkit.models.toCoreNetwork
8181
import to.bitkit.utils.AppError
8282
import to.bitkit.utils.Logger

app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,11 @@ import to.bitkit.ext.createChannelDetails
5050
import to.bitkit.ext.ellipsisMiddle
5151
import to.bitkit.ext.formatToString
5252
import to.bitkit.ext.uri
53-
import to.bitkit.models.msatFloorOf
5453
import to.bitkit.models.NodeLifecycleState
5554
import to.bitkit.models.NodePeer
5655
import to.bitkit.models.alias
5756
import to.bitkit.models.formatToModernDisplay
57+
import to.bitkit.models.msatFloorOf
5858
import to.bitkit.repositories.LightningState
5959
import to.bitkit.ui.components.BodyM
6060
import to.bitkit.ui.components.BodyMSB

app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow
1717
import kotlinx.coroutines.flow.update
1818
import kotlinx.coroutines.launch
1919
import to.bitkit.R
20+
import to.bitkit.models.PubkyRingAuthUrlBuilder
2021
import to.bitkit.models.Toast
2122
import to.bitkit.repositories.PubkyRepo
2223
import to.bitkit.ui.shared.toast.ToastEventBus
@@ -41,6 +42,16 @@ class PubkyChoiceViewModel @Inject constructor(
4142

4243
private var approvalJob: Job? = null
4344

45+
init {
46+
viewModelScope.launch {
47+
pubkyRepo.authCancelEvents.collect {
48+
approvalJob?.cancel()
49+
approvalJob = null
50+
_uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) }
51+
}
52+
}
53+
}
54+
4455
override fun onCleared() {
4556
super.onCleared()
4657
if (_uiState.value.isWaitingForRing) {
@@ -63,8 +74,12 @@ class PubkyChoiceViewModel @Inject constructor(
6374
}
6475

6576
pubkyRepo.startAuthentication()
66-
.onSuccess { authUrl ->
67-
val ringIntent = createRingAuthIntent(authUrl)
77+
.onSuccess { authRequest ->
78+
val callbackAuthUrl = PubkyRingAuthUrlBuilder.addCallbacks(
79+
authUrl = authRequest.authUrl,
80+
nonce = authRequest.callbackNonce,
81+
) ?: authRequest.authUrl
82+
val ringIntent = createRingAuthIntent(callbackAuthUrl)
6883
if (!canOpenWithRing(ringIntent)) {
6984
cancelAuthAndShowRingDialog()
7085
return@launch

app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ import to.bitkit.ext.DatePattern
6060
import to.bitkit.ext.amountOnClose
6161
import to.bitkit.ext.createChannelDetails
6262
import to.bitkit.ext.setClipboardText
63-
import to.bitkit.models.msatFloorOf
6463
import to.bitkit.models.Toast
64+
import to.bitkit.models.msatFloorOf
6565
import to.bitkit.ui.Routes
6666
import to.bitkit.ui.appViewModel
6767
import to.bitkit.ui.components.Caption13Up

app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ import kotlinx.collections.immutable.toImmutableList
4747
import to.bitkit.R
4848
import to.bitkit.ext.amountOnClose
4949
import to.bitkit.ext.createChannelDetails
50-
import to.bitkit.models.msatFloorOf
5150
import to.bitkit.models.formatToModernDisplay
51+
import to.bitkit.models.msatFloorOf
5252
import to.bitkit.ui.Routes
5353
import to.bitkit.ui.components.BodyM
5454
import to.bitkit.ui.components.BodyMSB

0 commit comments

Comments
 (0)