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
12 changes: 12 additions & 0 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,18 @@ class PubkyRepo @Inject constructor(
keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name) && !keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)
}

/**
* The identity of the key Bitkit stores for the saved session, read without restoring the session.
* Null for a Ring-sourced identity, or without both a saved session and a stored key.
*/
suspend fun localIdentityPublicKey(): String? = runSuspendCatching {
withContext(ioDispatcher) {
if (!hasSavedSession() || !hasLocalSecretKey()) return@withContext null
val secretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name) ?: return@withContext null
pubkyService.publicKeyFromSecret(secretKeyHex).ensurePubkyPrefix()
}
}.getOrNull()

suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) {
_publicKey.value != null ||
!keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() ||
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ class PaykitSdkService @Inject constructor(
approvedClientId: String,
secretKeyHex: String,
) {
isSetup.await()
ensurePlatformSetup()
return operationLock.withLock {
approvalBootstrap(authUrl, approvedClientId).approveAuth(
authUrl = authUrl,
Expand All @@ -552,10 +552,10 @@ class PaykitSdkService @Inject constructor(
secretKeyHex: String,
claim: PubkyAuthCompanionClaim,
) {
isSetup.await()
val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow()
?: throw PubkyAuthRequestError.MissingBitkitClaim
if (requestedClaim.includesPaykitAccess) isSetup.await() else ensurePlatformSetup()
Comment thread
ovitrif marked this conversation as resolved.
return operationLock.withLock {
val requestedClaim = PubkyAuthRequest.parseBitkitClaim(authUrl, expectedCapabilities).getOrThrow()
?: throw PubkyAuthRequestError.MissingBitkitClaim
require(
claim.queryParameter == PubkyAuthClaim.QUERY_PARAMETER && claim.claimType == requestedClaim.wireValue
) {
Expand Down Expand Up @@ -1557,6 +1557,10 @@ class PaykitSdkService @Inject constructor(

private fun bootstrap() = cachedBootstrap

private fun ensurePlatformSetup() {
if (!isSetup.isCompleted || setupFailed) platformInitializer()
}

private fun approvalBootstrap(authUrl: String, approvedClientId: String): PubkySessionBootstrap {
val requestClientId = parsePubkyAuthUrl(authUrl).clientId.orEmpty()
return PubkySessionBootstrap.withPubkyClientConfig(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ class PubkyAuthApprovalViewModel @Inject constructor(
it.isNotEmpty()
}?.joinToString(context.getString(R.string.profile__auth_approval_services_separator)) ?: unknownService
val usdtEnabled = usdtPreferences.enabled.first()
val identityPublicKey = pubkyRepo.publicKey.value ?: pubkyRepo.localIdentityPublicKey()
val profile = approvalProfile(identityPublicKey)
_uiState.update {
it.copy(
state = if (request.bitkitClaim?.sharesBitcoin == true ||
Expand All @@ -124,11 +126,11 @@ class PubkyAuthApprovalViewModel @Inject constructor(
},
clientId = request.clientId,
homeserverPublicKey = request.homeserverPublicKey,
createsIdentity = request.requiresIdentityCreation(pubkyRepo.publicKey.value != null),
createsIdentity = request.requiresIdentityCreation(identityPublicKey != null),
serviceName = serviceName,
permissions = request.permissions.toImmutableList(),
bitkitClaim = request.bitkitClaim,
profile = approvalProfile(),
profile = profile,
isUsdtEnabled = usdtEnabled,
shareUsdt = usdtEnabled,
)
Expand Down Expand Up @@ -416,8 +418,10 @@ class PubkyAuthApprovalViewModel @Inject constructor(
return context.getString(R.string.profile__auth_approval_watch_only_account_default_name, serviceName)
}

private fun approvalProfile() = pubkyRepo.profile.value ?: pubkyRepo.publicKey.value?.let { publicKey ->
PubkyProfile.forDisplay(
private fun approvalProfile(identityPublicKey: String?): PubkyProfile? {
pubkyRepo.profile.value?.let { return it }
val publicKey = identityPublicKey ?: return null
return PubkyProfile.forDisplay(
publicKey = publicKey,
name = pubkyRepo.displayName.value,
imageUrl = pubkyRepo.displayImageUri.value,
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3149,6 +3149,10 @@ class AppViewModel @Inject constructor(

private suspend fun awaitPubkyAuthIdentity() {
while (pubkyRepo.awaitIdentityReady() == PubkyIdentityReadiness.Unavailable) {
if (isOnline.value == ConnectivityState.CONNECTED && pubkyRepo.localIdentityPublicKey() != null) {
Logger.debug("Continuing sign-in link with the stored Pubky key", context = TAG)
return
}
delay(PUBKY_AUTH_DEEPLINK_RETRY_INTERVAL)
isOnline.first { it == ConnectivityState.CONNECTED }
Logger.debug("Retrying Pubky session restore for a sign-in link", context = TAG)
Expand Down Expand Up @@ -7659,7 +7663,8 @@ class AppViewModel @Inject constructor(
}

if (pubkyRepo.publicKey.value == null) {
showPubkyIdentityUnavailableToast()
if (pubkyRepo.localIdentityPublicKey() == null) return showPubkyIdentityUnavailableToast()
showSheet(Sheet.PubkyAuth(authUrl))
return
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1785,6 +1785,20 @@ class PubkyRepoTest : BaseUnitTest() {
assertFalse(sut.hasLocalSecretKey())
}

@Test
fun `localIdentityPublicKey needs a saved session and a stored key without a Ring source`() = test {
whenever(keychain.exists(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(true)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret")
whenever(pubkyService.publicKeyFromSecret("local_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
assertNull(sut.localIdentityPublicKey())

whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn("saved_session")
assertEquals(VALID_SELF_KEY, sut.localIdentityPublicKey())

whenever(keychain.exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)).thenReturn(true)
assertNull(sut.localIdentityPublicKey())
}

@Test
fun `adoptRingIdentity should reject a mismatching credential without changing the source`() = test {
val ringPubky = VALID_SELF_KEY.removePrefix("pubky")
Expand Down
50 changes: 50 additions & 0 deletions app/src/test/java/to/bitkit/services/PaykitSdkServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -1390,6 +1391,55 @@ class PaykitSdkServiceTest {
}
}

@Test
fun `approval waits only for platform setup unless its claim grants Paykit access`() = runTest {
var platformSetups = 0
val service = PaykitSdkService(
mock(),
mock(),
mock(),
platformInitializer = { platformSetups++ },
settingsStore = mock(),
) { mock() }
val paykitAccess = PubkyAuthClaim(Item.PAYKIT_ACCESS_V1)
val watchOnly = PubkyAuthClaim(Item.WATCH_ONLY_ACCOUNT_V1)
fun companion(claim: PubkyAuthClaim) = PubkyAuthCompanionClaim(
queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
claimType = claim.wireValue,
unsignedPayload = ByteArray(84).apply { this[0] = 1 },
)

val approvals = listOf(
async { runSuspendCatching { service.approveAuth("pubkyauth://signin", "/pub/a/:rw", "test", "00") } },
async {
runSuspendCatching {
service.approveAuthWithCompanionClaim(
"pubkyauth://signin?x-bitkit-claim=${watchOnly.wireValue}",
PubkyAuthClaim.REQUIRED_CAPABILITIES,
"test",
"00",
companion(watchOnly),
)
}
},
)
val paykitAccessApproval = async {
service.approveAuthWithCompanionClaim(
"pubkyauth://signin?x-bitkit-claim=${paykitAccess.wireValue}",
PubkyAuthClaim.REQUIRED_CAPABILITIES,
"test",
"00",
companion(paykitAccess),
)
}
advanceUntilIdle()

assertTrue(approvals.all { it.isCompleted })
assertEquals(2, platformSetups)
assertFalse(paykitAccessApproval.isCompleted)
paykitAccessApproval.cancel()
}

@Test
fun `registered identity activation persists credentials or clears partial activation`() = runTest {
for (failure in listOf(null, "session", "secret", "initialize", "authorize", "cancel")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,33 @@ class PubkyAuthApprovalViewModelTest : BaseUnitTest() {
verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) }
}

@Test
fun `grant signup with only a stored key shows that identity and authorizes without identity creation`() = test {
val authUrl = "pubkyauth://signup_grant?hs=homeserver"
val request = PubkyAuthRequest.parseGrantSignup(
rawUrl = authUrl,
clientId = clientId,
relay = "https://relay.example",
capabilities = "/pub/example/:rw",
homeserverPublicKey = "homeserver",
).getOrThrow()
whenever(pubkyRepo.localIdentityPublicKey()).thenReturn("pubky_stored")
whenever(pubkyRepo.parseAuthUrl(authUrl)).thenReturn(Result.success(request))
whenever(pubkyRepo.approveAuth(authUrl, request.capabilities, clientId)).thenReturn(Result.success(Unit))
val sut = createSut()

sut.load(authUrl)
advanceUntilIdle()
assertFalse(sut.uiState.value.createsIdentity)
assertEquals("pubky_stored", sut.uiState.value.profile?.publicKey)
sut.confirmAuthorize(authUrl)
advanceUntilIdle()

assertEquals(ApprovalState.Success, sut.uiState.value.state)
verifyBlocking(pubkyRepo) { approveAuth(authUrl, request.capabilities, clientId) }
verifyBlocking(pubkyRepo, never()) { approveSignupAuth(any()) }
}

@Test
fun `terminal signup outcomes allow the same URL to reload for consent`() = test {
listOf(Result.success(Unit), Result.failure(PubkyAlreadySignedInError)).forEach { outcome ->
Expand Down
49 changes: 49 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5015,6 +5015,55 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
verify(toastManager).dismiss(check { assertTrue(it.isLoading) })
}

@Test
fun `cold pubky auth deeplink opens the sheet with the stored key while the restore stays deferred`() = test {
enablePaykitUi()
advanceUntilIdle()
whenever(pubkyRepo.hasIdentity()).thenReturn(true)
whenever(pubkyRepo.awaitIdentityReady()).thenReturn(PubkyIdentityReadiness.Unavailable)
whenever(pubkyRepo.localIdentityPublicKey()).thenReturn(testPublicKey)
val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw"

sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri()))
advanceUntilIdle()

assertNull(pubkyPublicKey.value)
assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
verify(pubkyRepo, times(1)).awaitIdentityReady()
verify(pubkyRepo, never()).hasSecretKey()
verify(toastManager).dismiss(check { assertTrue(it.isLoading) })
}

@Test
fun `offline cold pubky auth deeplink keeps waiting for the restore despite a stored key`() = test {
enablePaykitUi()
advanceUntilIdle()
whenever(pubkyRepo.hasIdentity()).thenReturn(true)
whenever(pubkyRepo.hasSecretKey()).thenReturn(true)
whenever(pubkyRepo.localIdentityPublicKey()).thenReturn(testPublicKey)
var attempts = 0
whenever(pubkyRepo.awaitIdentityReady()).doSuspendableAnswer {
attempts++
if (attempts < 2) return@doSuspendableAnswer PubkyIdentityReadiness.Unavailable
pubkyPublicKey.value = testPublicKey
PubkyIdentityReadiness.Ready
}
connectivityState.value = ConnectivityState.DISCONNECTED
val authUrl = "pubkyauth://signin_grant?caps=/pub/paykit/v0/:rw"

sut.handleDeeplinkIntent(Intent(Intent.ACTION_VIEW, authUrl.toUri()))
advanceTimeBy(10_000)
runCurrent()

assertEquals(1, attempts)
assertNull(sut.currentSheet.value)
connectivityState.value = ConnectivityState.CONNECTED
advanceUntilIdle()

assertEquals(2, attempts)
assertEquals(Sheet.PubkyAuth(authUrl), sut.currentSheet.value)
}

@Test
fun `pubky auth deeplink shows a retryable error for a saved identity it cannot restore`() = test {
enablePaykitUi()
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1541.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pubky sign-in links that open Bitkit now show the Authorize sheet even while your Pubky profile is still loading.
3 changes: 2 additions & 1 deletion journeys/pubky-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
## Sign-in from a link

- `signin-cold-start.xml` checks a `pubkyauth://signin_grant` link opened while Bitkit is not running:
the loading toast while the saved session restores, then the authorization sheet. The iOS
the loading toast while the saved session restores, then the authorization sheet, which opens with
the stored key's identity when the restore is deferred while online. The iOS
counterpart is `open-signin-link.xml`, which opens the same flow through `bitkit://pubky-auth/signin`;
only Android has the raw `pubkyauth` scheme, the loading toast and Authorize through to
`PubkyAuthOK`. A restore that fails while
Expand Down
5 changes: 3 additions & 2 deletions journeys/pubky-auth/signin-cold-start.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<journey name="signin cold start">
<description>
Verifies that a sign-in link opened while Bitkit is not running waits visibly for the saved Pubky
session and then opens the authorization sheet. Precondition: an unlocked wallet with Paykit
Verifies that a sign-in link opened while Bitkit is not running shows a loading toast while the
saved Pubky session restores, then opens the authorization sheet; when the restore is deferred
while the device is online, the sheet opens with the stored key's identity. Precondition: an unlocked wallet with Paykit
enabled and a locally managed Pubky identity, and a requesting app that supplies a fresh
pubkyauth://signin_grant URL with a reachable homeserver and relay. The iOS counterpart is
pubky-auth/open-signin-link.xml (bitkit://pubky-auth/signin); only Android has the raw pubkyauth
Expand Down
Loading