Skip to content
Open
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ dependencies {
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
testImplementation(libs.ktor.client.mock)
// Logging
runtimeOnly(libs.slf4j.simple)
implementation(libs.slf4j.api)
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum class PubkyAuthClaim(val wireValue: String) {

sealed class PubkyAuthRequestError(cause: Throwable? = null) : AppError(cause = cause) {
class InvalidUrl(cause: Throwable) : PubkyAuthRequestError(cause)
data object RequesterChanged : PubkyAuthRequestError()
data object MissingBitkitClaim : PubkyAuthRequestError()
data object DuplicateBitkitClaim : PubkyAuthRequestError()
data class UnsupportedBitkitClaim(val value: String) : PubkyAuthRequestError()
Expand All @@ -64,6 +65,7 @@ data class PubkyAuthPermission(

data class PubkyAuthRequest(
val rawUrl: String,
val clientId: String,
val relay: String,
val capabilities: String,
val permissions: List<PubkyAuthPermission>,
Expand All @@ -73,12 +75,14 @@ data class PubkyAuthRequest(
companion object {
fun parse(
rawUrl: String,
clientId: String,
relay: String,
capabilities: String,
): Result<PubkyAuthRequest> = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim ->
val permissions = parseCapabilities(capabilities)
PubkyAuthRequest(
rawUrl = rawUrl,
clientId = clientId,
relay = relay,
capabilities = capabilities,
permissions = permissions,
Expand Down
171 changes: 119 additions & 52 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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.paykitDisabled
import to.bitkit.di.IoDispatcher
import to.bitkit.env.Env
import to.bitkit.ext.runSuspendCatching
Expand Down Expand Up @@ -288,13 +289,15 @@ class PubkyRepo @Inject constructor(

suspend fun completeAuthentication(): Result<Unit> {
val attemptId = _activeAuthAttemptId.value ?: return Result.failure(PubkyAuthAttemptInactive())
var didCompleteAuth = false
var shouldRevokeSessionOnFailure = false
return try {
val result = runSuspendCatching {
waitForAuthApproval(attemptId)
withContext(ioDispatcher) {
pubkyService.completeAuth()
didCompleteAuth = true
withContext(NonCancellable) {
shouldRevokeSessionOnFailure = true
pubkyService.completeAuth()
}
ensureAuthAttemptActive(attemptId)
val pk = requireNotNull(pubkyService.currentPublicKey()?.ensurePubkyPrefix()) {
"No active Pubky session"
Expand All @@ -309,7 +312,7 @@ class PubkyRepo @Inject constructor(
}

if (result.isFailure) {
clearCompletedAuthSessionIfNeeded(didCompleteAuth)
revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure)
if (_activeAuthAttemptId.value == attemptId) {
_activeAuthAttemptId.update { null }
}
Expand All @@ -328,12 +331,13 @@ class PubkyRepo @Inject constructor(
}
_publicKey.update { pk }
_authState.update { PubkyAuthState.Authenticated }
shouldRevokeSessionOnFailure = false
Logger.info("Completed pubky auth for '${redacted(pk)}'", context = TAG)
loadProfile()
loadContacts()
}.map { }
} catch (e: CancellationException) {
clearCompletedAuthSessionIfNeeded(didCompleteAuth)
revokeCompletedAuthSessionIfNeeded(shouldRevokeSessionOnFailure)
if (_activeAuthAttemptId.value == attemptId) {
_activeAuthAttemptId.update { null }
}
Expand All @@ -345,14 +349,25 @@ class PubkyRepo @Inject constructor(
}
}

private suspend fun clearCompletedAuthSessionIfNeeded(didCompleteAuth: Boolean) {
if (!didCompleteAuth) return
private suspend fun revokeCompletedAuthSessionIfNeeded(shouldRevokeSession: Boolean) {
if (!shouldRevokeSession) return
discardAbandonedSession()
}

private suspend fun discardAbandonedSession() {
val revocationError = runSuspendCatching {
withContext(NonCancellable + ioDispatcher) {
pubkyService.signOut()
Comment thread
ben-kaufman marked this conversation as resolved.
}
}.exceptionOrNull() ?: return

Logger.warn("Failed to revoke abandoned Pubky session", revocationError, context = TAG)
runSuspendCatching {
withContext(NonCancellable + ioDispatcher) {
pubkyService.clearSessionAccess()
pubkyService.forgetSessionAccess()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback calls pubkyService.forgetSessionAccess() when the remote revoke fails, but its result only reaches .onFailure { Logger.warn(...) }, so a second failure is swallowed and nothing else clears the session. forgetSessionAccess() in PaykitSdkService.kt:802 only calls handle.forgetSessionAccess() and resetRuntime(); it never touches Keychain.Key.PAYKIT_SESSION. The base's clearSessionAccessLocked() did delete both keys directly on this path, and this PR removed that. What makes the gap concrete is that PubkyRepo still has a network-independent local teardown: clearLocalState() at :1299 deletes PAYKIT_SESSION and PUBKY_SECRET_KEY under runCatching, and signOut() calls it at :1118 while wipeLocalState() calls it at :1128. discardAbandonedSession() is the only cleanup path that never does, so it depends entirely on the SDK calling back into PaykitSdkSessionProvider.clearSessionAccess() (:1100), which has no in-app caller to guarantee it. When both calls fail, completeAuthentication() still reaches restoreAuthStateAfterAuthFlow() and shows PubkyAuthState.Idle while the grant survives in the keychain, and initialize() reads that key on the next launch and restores it as InitResult.Restored. That is the same ghost session this fallback was added to close, one level deeper. Could we call the existing clearLocalState() as a last resort when the forgetSessionAccess() fallback also fails, so the keychain always matches PubkyAuthState.Idle?

}
}.onFailure {
Logger.warn("Failed to clear canceled Pubky auth session", it, context = TAG)
Logger.warn("Failed to forget abandoned Pubky session access", it, context = TAG)
}
}

Expand Down Expand Up @@ -534,41 +549,68 @@ class PubkyRepo @Inject constructor(
links: List<PubkyProfileLink>,
tags: List<String>,
avatarBytes: ByteArray?,
): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()
): Result<Unit> {
var shouldRevokeSessionOnFailure = false
return try {
val result = runSuspendCatching {
withContext(ioDispatcher) {
val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()

val signupDetails: Pair<String, String?> = Env.e2eHomeserverPubky?.let { it to null }
?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode }
val signupDetails: Pair<String, String?> = Env.e2eHomeserverPubky?.let { it to null }
?: fetchHomegateSignupCode().let { it.homeserverPubky to it.signupCode }

runSuspendCatching {
pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
}.getOrElse {
Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
pubkyService.signIn(secretKeyHex)
shouldRevokeSessionOnFailure = true
Comment thread
ben-kaufman marked this conversation as resolved.
runSuspendCatching {
pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
}.getOrElse {
Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
pubkyService.signIn(secretKeyHex)
}

val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
writeProfile(name, bio, links, tags, imageUrl)
shouldRevokeSessionOnFailure = false
finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl)
}
}
if (result.isFailure) revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSessionOnFailure)
Comment thread
ben-kaufman marked this conversation as resolved.
result
} catch (error: CancellationException) {
revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSessionOnFailure)
throw error
}
}

val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
writeProfile(name, bio, links, tags, imageUrl)
private suspend fun finishIdentityCreation(
publicKey: String,
name: String,
bio: String,
links: List<PubkyProfileLink>,
tags: List<String>,
imageUrl: String?,
) {
val createdProfile = PubkyProfile(
publicKey = publicKey,
name = name,
bio = bio,
imageUrl = imageUrl,
links = links,
tags = tags,
status = null,
)
_publicKey.update { publicKey }
_authState.update { PubkyAuthState.Authenticated }
_profile.update { createdProfile }
cacheMetadata(createdProfile)
notifyBackupStateChanged()
Logger.info("Created identity for '${redacted(publicKey)}'", context = TAG)
loadProfile()
loadContacts()
}

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()
}
private suspend fun revokeIncompleteIdentitySessionIfNeeded(shouldRevokeSession: Boolean) {
if (!shouldRevokeSession) return
discardAbandonedSession()
}

suspend fun uploadAvatar(imageBytes: ByteArray): Result<String> = runSuspendCatching {
Expand Down Expand Up @@ -634,6 +676,7 @@ class PubkyRepo @Inject constructor(
Logger.info("Continuing sign out, bitkit profile storage already missing", context = TAG)
}
}
settingsStore.update { it.paykitDisabled(markPublicCleanupPending = it.hasPaykitState()) }
signOut().getOrThrow()
}

Expand Down Expand Up @@ -905,23 +948,29 @@ class PubkyRepo @Inject constructor(
val details = pubkyService.parseAuthUrl(authUrl)
PubkyAuthRequest.parse(
rawUrl = authUrl,
clientId = details.clientId.orEmpty(),
relay = details.relayUrl.orEmpty(),
capabilities = details.capabilities.orEmpty(),
).getOrThrow()
}
}

suspend fun approveAuth(authUrl: String, expectedCapabilities: String): Result<Unit> = runSuspendCatching {
suspend fun approveAuth(
authUrl: String,
expectedCapabilities: String,
approvedClientId: String,
): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
val secretKeyHex = requireNotNull(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)) {
"No secret key available — use Ring to manage authorizations"
}
pubkyService.approveAuth(authUrl, expectedCapabilities, secretKeyHex)
pubkyService.approveAuth(authUrl, expectedCapabilities, approvedClientId, secretKeyHex)
}
}

suspend fun approveAuthWithCompanionClaim(
authUrl: String,
approvedClientId: String,
unsignedPayload: ByteArray,
): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
Expand All @@ -931,6 +980,7 @@ class PubkyRepo @Inject constructor(
pubkyService.approveAuthWithCompanionClaim(
authUrl = authUrl,
expectedCapabilities = PubkyAuthClaim.WATCH_ONLY_ACCOUNT_CAPABILITIES,
approvedClientId = approvedClientId,
secretKeyHex = secretKeyHex,
claim = PubkyAuthCompanionClaim(
queryParameter = PubkyAuthClaim.QUERY_PARAMETER,
Expand Down Expand Up @@ -975,7 +1025,14 @@ class PubkyRepo @Inject constructor(
ensureServiceInitialized()

initializeMutex.withLock {
pubkyService.clearSessionAccess()
runSuspendCatching { pubkyService.forgetSessionAccess() }
.onFailure {
Logger.warn(
"Failed to forget existing Pubky session before restore",
it,
context = TAG,
)
}
clearAuthenticatedState()
runCatching { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) }
runCatching { keychain.delete(Keychain.Key.PUBKY_SECRET_KEY.name) }
Expand Down Expand Up @@ -1038,26 +1095,36 @@ class PubkyRepo @Inject constructor(

// region Sign out

suspend fun signOut(): Result<Unit> {
suspend fun signOut(): Result<Unit> = withContext(NonCancellable + ioDispatcher) {
Comment thread
ben-kaufman marked this conversation as resolved.
val hadPaykitState = settingsStore.data.first().hasPaykitState()
val endpointCleanupResult = removeBitkitPaymentEndpoints()
.onFailure { Logger.warn("Failed to remove Bitkit payment endpoints", it, context = TAG) }

val result = runSuspendCatching {
withContext(ioDispatcher) { pubkyService.signOut() }
}.fold(
onSuccess = { Result.success(it) },
onFailure = {
Logger.warn("Forcing local sign out after server sign out failed", it, context = TAG)
runSuspendCatching { withContext(ioDispatcher) { pubkyService.forceSignOut() } }
},
)
pubkyService.signOut()
}.onFailure { Logger.error("Failed to revoke Pubky session during sign out", it, context = TAG) }
Comment thread
ben-kaufman marked this conversation as resolved.

if (result.isFailure) {
if (hadPaykitState) {
runSuspendCatching {
settingsStore.update { it.copy(publicPaykitCleanupPending = true) }
}.onFailure {
Logger.warn("Failed to mark Paykit state for reconciliation", it, context = TAG)
}
}
return@withContext result
}

clearLocalState(publicPaykitCleanupPending = endpointCleanupResult.isFailure && hadPaykitState)
return result
result
}

suspend fun wipeLocalState() {
runSuspendCatching {
withContext(ioDispatcher) { pubkyService.forgetSessionAccess() }
}.onFailure {
Logger.warn("Failed to forget local Pubky session access", it, context = TAG)
}
clearLocalState()
}

Expand Down
Loading
Loading