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
8 changes: 8 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class SettingsStore @Inject constructor(

val data: Flow<SettingsData> = store.data
val isPaykitEnabled: Flow<Boolean> = localStore.data.map { it[PAYKIT_ENABLED_KEY] ?: false }
val isPubkyProfileSetupPending: Flow<Boolean> = localStore.data.map {
it[PUBKY_PROFILE_SETUP_PENDING_KEY] ?: false
}

@Volatile
var restoredMonitoredTypesFromBackup: Boolean = false
Expand All @@ -66,6 +69,10 @@ class SettingsStore @Inject constructor(
localStore.edit { it[PAYKIT_ENABLED_KEY] = value }
}

suspend fun setPubkyProfileSetupPending(value: Boolean) {
localStore.edit { it[PUBKY_PROFILE_SETUP_PENDING_KEY] = value }
}

suspend fun addLastUsedTag(newTag: String) {
store.updateData { currentSettings ->
val combinedTags = (listOf(newTag) + currentSettings.lastUsedTags).distinct()
Expand Down Expand Up @@ -98,6 +105,7 @@ class SettingsStore @Inject constructor(
private const val TAG = "SettingsStore"
private const val MAX_LAST_USED_TAGS = 10
private val PAYKIT_ENABLED_KEY = booleanPreferencesKey("paykit_enabled")
private val PUBKY_PROFILE_SETUP_PENDING_KEY = booleanPreferencesKey("pubky_profile_setup_pending")
}
}

Expand Down
104 changes: 104 additions & 0 deletions app/src/main/java/to/bitkit/models/PubkyAuthRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import to.bitkit.utils.AppError
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets

enum class PubkyAuthClaim(val wireValue: String) {
Expand Down Expand Up @@ -71,13 +72,23 @@ data class PubkyAuthRequest(
val permissions: List<PubkyAuthPermission>,
val serviceNames: List<String>,
val bitkitClaim: PubkyAuthClaim?,
val homeserverPublicKey: String? = null,
val signupToken: String? = null,
val authorizationUrl: String? = rawUrl,
) {
val isSignup: Boolean
get() = isSignupUrl(rawUrl)

companion object {
@Suppress("LongParameterList")
fun parse(
rawUrl: String,
clientId: String,
relay: String,
capabilities: String,
homeserverPublicKey: String? = null,
signupToken: String? = null,
authorizationUrl: String? = rawUrl,
): Result<PubkyAuthRequest> = parseBitkitClaim(rawUrl, capabilities).map { bitkitClaim ->
val permissions = parseCapabilities(capabilities)
PubkyAuthRequest(
Expand All @@ -88,9 +99,76 @@ data class PubkyAuthRequest(
permissions = permissions,
serviceNames = permissions.mapNotNull { extractServiceName(it.path) }.distinct(),
bitkitClaim = bitkitClaim,
homeserverPublicKey = homeserverPublicKey,
signupToken = signupToken,
authorizationUrl = authorizationUrl,
)
}

fun isProtocolUrl(rawUrl: String): Boolean = runCatching {
val uri = URI(rawUrl)
when (uri.scheme?.lowercase()) {
"pubkyauth" -> true
"pubkyring" -> uri.host.equals("signup", ignoreCase = true)
else -> false
}
}.getOrDefault(false)

fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(false)

fun isDirectSignupUrl(rawUrl: String): Boolean =
parseSignup(rawUrl).getOrNull()?.let { it.authorizationUrl == null } ?: false

fun parseSignup(rawUrl: String): Result<PubkyAuthRequest> = runCatching {
val uri = URI(rawUrl)
require(uri.isSignupRequest()) { "Unsupported Pubky signup URL" }
val query = parseQuery(uri)
val homeserver = query.requiredSingle("hs")
val authorizesApp = uri.authorizesApp(query)
val relay = if (authorizesApp) query.requiredSingle("relay") else ""
val secret = if (authorizesApp) query.requiredSingle("secret") else ""
val capabilities = if (authorizesApp) query.requiredSingle("caps") else ""
val authorizationUrl = if (authorizesApp) {
ringAuthorizationUrl(relay, secret, capabilities)
} else {
null
}

parse(
rawUrl = rawUrl,
clientId = "",
relay = relay,
capabilities = capabilities,
homeserverPublicKey = homeserver,
signupToken = query.optionalSingle("st"),
authorizationUrl = authorizationUrl,
).getOrThrow().also {
require(it.bitkitClaim == null) { "Pubky signup does not support Bitkit companion claims" }
}
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure(PubkyAuthRequestError.InvalidUrl(it)) },
)

private fun URI.isSignupRequest(): Boolean = when (scheme?.lowercase()) {
"pubkyring" -> host.equals("signup", ignoreCase = true)
"pubkyauth" -> isDirectSignupRequest()
else -> false
}

private fun URI.isDirectSignupRequest(): Boolean =
scheme.equals("pubkyauth", ignoreCase = true) && (host ?: rawAuthority).let {
it.equals("direct_signup", ignoreCase = true) || it.equals("signup", ignoreCase = true)
}

private fun URI.authorizesApp(query: Map<String, List<String>>): Boolean =
scheme.equals("pubkyring", ignoreCase = true) ||
(
scheme.equals("pubkyauth", ignoreCase = true) &&
(host ?: rawAuthority).equals("signup", ignoreCase = true) &&
listOf("relay", "secret", "caps").any(query::containsKey)
)

fun parseBitkitClaim(rawUrl: String, capabilities: String): Result<PubkyAuthClaim?> =
parseBitkitClaimValues(rawUrl).fold(
onSuccess = { claimValues -> validateBitkitClaim(claimValues, capabilities) },
Expand Down Expand Up @@ -152,5 +230,31 @@ data class PubkyAuthRequest(
}

private fun decodeQueryComponent(value: String) = URLDecoder.decode(value, StandardCharsets.UTF_8.name())

private fun ringAuthorizationUrl(relay: String, secret: String, capabilities: String): String =
"pubkyauth:///?relay=${encodeQueryComponent(relay)}" +
"&secret=${encodeQueryComponent(secret)}&caps=${encodeQueryComponent(capabilities)}"

private fun encodeQueryComponent(value: String) =
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")

private fun parseQuery(uri: URI): Map<String, List<String>> = uri.rawQuery.orEmpty()
.split("&")
.filter { it.isNotEmpty() }
.map { it.split("=", limit = 2) }
.groupBy(
keySelector = { decodeQueryComponent(it.first()) },
valueTransform = { decodeQueryComponent(it.getOrElse(1) { "" }) },
)

private fun Map<String, List<String>>.requiredSingle(name: String): String =
optionalSingle(name)?.takeIf { it.isNotBlank() }
?: throw IllegalArgumentException("Missing Pubky signup parameter: $name")

private fun Map<String, List<String>>.optionalSingle(name: String): String? {
val values = this[name].orEmpty()
require(values.size <= 1) { "Duplicate Pubky signup parameter: $name" }
return values.singleOrNull()?.takeIf { it.isNotBlank() }
}
}
}
93 changes: 91 additions & 2 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ sealed class PubkyContactError(message: String) : AppError(message) {
}

private class PubkyAuthAttemptInactive : AppError("Auth attempt is no longer active")
data object PubkyAlreadySignedInError : AppError("Already signed in")

private enum class AuthAttemptWaitResult { Approved, Inactive }

Expand Down Expand Up @@ -538,6 +539,16 @@ class PubkyRepo @Inject constructor(
tags: List<String>,
avatarBytes: ByteArray?,
): Result<Unit> {
if (settingsStore.isPubkyProfileSetupPending.first()) {

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.

createIdentity now short-circuits through isPubkyProfileSetupPending so QR signup can publish the already-activated session instead of calling Homegate signUp/signIn. The new approveSignupAuth tests assert that the pending flag is set, but the existing createIdentity tests leave that flag false, so deleting or inverting this branch would still pass. That matters because the Homegate path still revokes the session if profile publication fails, which would sign out a QR-registered identity. Could we add a repository test that pending setup publishes the profile without signUp or signIn and does not call signOut when publication fails?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in f331ad874. The test completes QR signup, forces profile publication to fail, and verifies the pending path neither re-registers nor signs out the activated session.

return runSuspendCatching {
withContext(ioDispatcher) {
val publicKey = requireNotNull(_publicKey.value) { "No active Pubky session" }
val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes)
finishIdentityCreation(publicKey, name, bio, links, tags, imageUrl)
}
}
}

var shouldRevokeSessionOnFailure = false
return try {
val result = runSuspendCatching {
Expand All @@ -555,8 +566,7 @@ class PubkyRepo @Inject constructor(
pubkyService.signIn(secretKeyHex)
}

val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
writeProfile(name, bio, links, tags, imageUrl)
val imageUrl = publishIdentityProfile(name, bio, links, tags, avatarBytes)
shouldRevokeSessionOnFailure = false
finishIdentityCreation(publicKeyZ32, name, bio, links, tags, imageUrl)
}
Expand All @@ -569,6 +579,18 @@ class PubkyRepo @Inject constructor(
}
}

private suspend fun publishIdentityProfile(
name: String,
bio: String,
links: List<PubkyProfileLink>,
tags: List<String>,
avatarBytes: ByteArray?,
): String? {
val imageUrl = avatarBytes?.let { uploadAvatar(it).getOrNull() }
writeProfile(name, bio, links, tags, imageUrl)
return imageUrl
}

private suspend fun finishIdentityCreation(
publicKey: String,
name: String,
Expand All @@ -590,6 +612,7 @@ class PubkyRepo @Inject constructor(
_authState.update { PubkyAuthState.Authenticated }
_profile.update { createdProfile }
cacheMetadata(createdProfile)
settingsStore.setPubkyProfileSetupPending(false)
notifyBackupStateChanged()
Logger.info("Created identity for '${redacted(publicKey)}'", context = TAG)
loadProfile()
Expand Down Expand Up @@ -936,8 +959,22 @@ class PubkyRepo @Inject constructor(
managedSecretKeyFor(publicKey) != null
}.getOrDefault(false)

fun hasIdentity(): Boolean =
_publicKey.value != null ||
!keychain.loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrEmpty() ||
!keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrEmpty()

suspend fun parseAuthUrl(authUrl: String): Result<PubkyAuthRequest> = runSuspendCatching {
withContext(ioDispatcher) {
if (PubkyAuthRequest.isSignupUrl(authUrl)) {
val request = PubkyAuthRequest.parseSignup(authUrl).getOrThrow()
pubkyService.validateSignupRequest(
authorizationUrl = request.authorizationUrl,
homeserverPublicKey = requireNotNull(request.homeserverPublicKey),
)
return@withContext request
}

val details = pubkyService.parseAuthUrl(authUrl)
PubkyAuthRequest.parse(
rawUrl = authUrl,
Expand All @@ -948,6 +985,57 @@ class PubkyRepo @Inject constructor(
}
}

suspend fun approveSignupAuth(request: PubkyAuthRequest): Result<Unit> = initializeMutex.withLock {
runSuspendCatching {
withContext(ioDispatcher) {
require(request.isSignup) { "Not a Pubky signup request" }
if (hasIdentity()) throw PubkyAlreadySignedInError

val (publicKey, secretKeyHex) = deriveKeys().getOrThrow()
if (hasIdentity()) throw PubkyAlreadySignedInError

settingsStore.update { it.copy(sharesPrivatePaykitEndpoints = false) }
val registeredSession = pubkyService.registerIdentity(
secretKeyHex = secretKeyHex,
homeserverZ32 = requireNotNull(request.homeserverPublicKey),
signupCode = request.signupToken,
)
request.authorizationUrl?.let { pubkyService.approveRingAuth(it, secretKeyHex) }
var activated = false
try {
pubkyService.activateRegisteredIdentity(registeredSession)
activated = true
} finally {
if (!activated) {
withContext(NonCancellable) {
settingsStore.setPubkyProfileSetupPending(false)
}
}
}

_publicKey.update { publicKey }
_authState.update { PubkyAuthState.Authenticated }
var pendingSaved = false
try {
settingsStore.setPubkyProfileSetupPending(true)
pendingSaved = true
} finally {
if (!pendingSaved) {
withContext(NonCancellable) {
runSuspendCatching { pubkyService.forgetSessionAccess() }
.onFailure {
Logger.warn("Failed to roll back Pubky signup session", it, context = TAG)
}
_publicKey.update { null }
_authState.update { PubkyAuthState.Idle }
}
}
}
notifyBackupStateChanged()
}
}
}

suspend fun approveAuth(
authUrl: String,
expectedCapabilities: String,
Expand Down Expand Up @@ -1303,6 +1391,7 @@ class PubkyRepo @Inject constructor(
publicPaykitCleanupPending = publicPaykitCleanupPending,
)
}
settingsStore.setPubkyProfileSetupPending(false)
}

private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String {
Expand Down
45 changes: 45 additions & 0 deletions app/src/main/java/to/bitkit/services/PaykitSdkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ import com.synonym.paykit.pubkySecretKeyFromBip39Mnemonic
import com.synonym.paykit.requiredSessionCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.Network
import to.bitkit.data.keychain.Keychain
import to.bitkit.env.Env
Expand Down Expand Up @@ -266,6 +268,40 @@ class PaykitSdkService @Inject constructor(
return result
}

suspend fun registerIdentity(
secretKeyHex: String,
homeserverPublicKey: String,
signupCode: String?,
): PubkySessionBootstrapResult {
isSetup.await()
return bootstrap().signUp(
localSecretKey = localSecretKey(secretKeyHex),
receiverNoiseSecretKey = sessionProvider.loadOrDeriveReceiverNoiseSecretKey(),
homeserverPublicKey = homeserverPublicKey,
signupCode = signupCode,
requiredCapabilities = requiredCapabilities(),
)
}
Comment thread
ben-kaufman marked this conversation as resolved.

suspend fun activateRegisteredIdentity(result: PubkySessionBootstrapResult) {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
operationMutex.withLock {
var activated = false
try {
activateBootstrapResult(
result = result,
previousPublicKey = previousPublicKey,
shouldStoreLocalSecret = true,
)
activated = true
} finally {
if (!activated) clearRegisteredIdentityActivationLocked()
}
}
notifyBackupStateChanged()
}

suspend fun signIn(secretKeyHex: String): PubkySessionBootstrapResult {
isSetup.await()
val previousPublicKey = operationMutex.withLock { currentSdkStatePublicKeyLocked() }
Expand Down Expand Up @@ -862,6 +898,15 @@ class PaykitSdkService @Inject constructor(
publishReceiverMarkerIfLiveSessionAvailable(handle)
}

private suspend fun clearRegisteredIdentityActivationLocked() = withContext(NonCancellable) {
runSuspendCatching { sessionProvider.clearSessionAccess() }
.onFailure { Logger.warn("Failed to clear incomplete Pubky signup session", it, context = TAG) }
runSuspendCatching { keychain.delete(Keychain.Key.PAYKIT_SDK_STATE.name) }
.onFailure { Logger.warn("Failed to clear incomplete Pubky signup state", it, context = TAG) }
resetRuntime()
notifyBackupStateChanged()
}

private suspend fun publishReceiverMarkerIfLiveSessionAvailable(handle: PaykitSdk) {
runSuspendCatching {
val capabilities = receiverCapabilities(handle)
Expand Down
Loading
Loading