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
26 changes: 25 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
android:resource="@xml/shortcuts" />
</activity>

<!-- Enabled only while Bitkit can authorize pubkyauth requests locally. -->
<!-- Enabled only while Bitkit can authorize with a locally managed Pubky identity. -->
<activity-alias
android:name=".ui.MainActivityPubkyAuth"
android:targetActivity=".ui.MainActivity"
Expand All @@ -177,6 +177,30 @@
</intent-filter>
</activity-alias>

<!-- Enabled only while Bitkit can create a Pubky identity. -->
<activity-alias
android:name=".ui.MainActivityPubkySignup"
android:targetActivity=".ui.MainActivity"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="pubkyauth" />
<data android:host="signup" />
<data android:host="direct_signup" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="pubkyring"
android:host="signup" />
</intent-filter>
</activity-alias>

<service
android:name=".fcm.FcmService"
android:exported="false">
Expand Down
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
101 changes: 101 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,73 @@ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This new pubkyring branch classifies pubkyring://signup as a supported protocol URL, and the ViewModel tests exercise it as a deeplink, but the exported MainActivityPubkyAuth alias in AndroidManifest.xml only registers the pubkyauth scheme. Android therefore cannot deliver this signup link to the new handler, so the advertised deeplink path works only when the payload is scanned. Could we register the pubkyring signup scheme on the conditionally enabled alias and cover actual intent resolution?

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 a separate pubkyring://signup intent filter to the existing conditionally enabled Pubky alias. Other Ring hosts remain excluded, and the pubkyauth filter keeps its existing matching behavior. PubkyAuthManifestTest.kt queries PackageManager against the merged manifest to cover disabled/enabled routing, the target activity, existing pubkyauth links, and rejection of unrelated Ring links. The new regression fails with the original manifest and passes with the filter. Compilation and all 2,347 unit tests pass.

else -> false
}
}.getOrDefault(false)

fun isSignupUrl(rawUrl: String): Boolean = runCatching { URI(rawUrl).isSignupRequest() }.getOrDefault(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 +227,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() }
}
}
}
120 changes: 108 additions & 12 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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 @@ -553,25 +554,41 @@ class PubkyRepo @Inject constructor(
tags: List<String>,
avatarBytes: ByteArray?,
): Result<Unit> {
if (settingsStore.isPubkyProfileSetupPending.first() && _publicKey.value != null) {
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 {
withContext(ioDispatcher) {
val (publicKeyZ32, secretKeyHex) = deriveKeys().getOrThrow()
settingsStore.setPubkyProfileSetupPending(false)
Comment thread
jvsena42 marked this conversation as resolved.
val storedSecretKeyHex = keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)
val publicKeyZ32 = if (!storedSecretKeyHex.isNullOrEmpty()) {
pubkyService.signIn(storedSecretKeyHex)
pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()
} else {
val (publicKey, 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 }

shouldRevokeSessionOnFailure = true
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
runSuspendCatching {
pubkyService.signUp(secretKeyHex, signupDetails.first, signupDetails.second)
}.getOrElse {
Logger.warn("Retrying sign in after sign up failed", it, context = TAG)
pubkyService.signIn(secretKeyHex)
}
publicKey
}

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 @@ -584,6 +601,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 @@ -605,6 +634,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 @@ -946,8 +976,23 @@ class PubkyRepo @Inject constructor(
managedSecretKeyFor(publicKey) != null
}.getOrDefault(false)

suspend fun hasIdentity(): Boolean = withContext(ioDispatcher) {
_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 @@ -958,6 +1003,56 @@ 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() }
Comment thread
ben-kaufman marked this conversation as resolved.
.onFailure {
Logger.warn("Failed to roll back Pubky signup session", it, context = TAG)
}
clearLocalState()
}
}
}
notifyBackupStateChanged()
}
}
}

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

private fun requireAddableContactPublicKey(publicKey: String, allowExisting: Boolean = false): String {
Expand Down
Loading
Loading