Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9384065
feat: share pubky identities with ring
Jasonvdb Jul 24, 2026
6ec59bf
chore: rename changelog fragment
Jasonvdb Jul 24, 2026
71769c2
fix: align Pubky import overview with designs
Jasonvdb Jul 24, 2026
eeefb23
fix: keep the grant client id when rebuilding external session access
Jasonvdb Sep 17, 2026
2d7b4bd
fix: fail closed on shared pubky invariants in new identity paths
Jasonvdb Sep 17, 2026
0cc46f7
test: align shared pubky tests with upstream session and auth apis
Jasonvdb Sep 17, 2026
f07e09e
test: model persisted pubky secret in upstream identity fixtures
Jasonvdb Sep 17, 2026
b3a73fd
fix: abort the wallet wipe when shared Pubky export cannot be disabled
Jasonvdb Sep 17, 2026
b84bde8
fix: keep the managed secret quarantine marker when its secret survives
Jasonvdb Sep 17, 2026
4c8d799
fix: roll back the adopted Pubky Ring identity when adoption is cance…
Jasonvdb Sep 17, 2026
f6ccdde
fix: remove published payment endpoints when a Ring source disappears
Jasonvdb Sep 17, 2026
ac168bb
fix: keep the quarantine marker when local state teardown cannot dele…
Jasonvdb Sep 17, 2026
99338a0
refactor: remove obsolete ring requester flow
Jasonvdb Sep 18, 2026
913e102
refactor: unify shared pubky identity records
Jasonvdb Sep 18, 2026
8eddccc
fix: preserve cursor position when reading shared credentials
Jasonvdb Sep 18, 2026
9a84e2b
style: wrap pubky serializer assertion
Jasonvdb Sep 18, 2026
90f0eab
chore: merge master into shared pubky ring
Jasonvdb Sep 18, 2026
07ddf0b
chore: merge master into shared pubky ring
Jasonvdb Sep 21, 2026
ab53625
fix: preserve shared pubky on transient errors
Jasonvdb Sep 21, 2026
41ebbd7
chore: merge master into shared pubky ring
Jasonvdb Sep 21, 2026
25cbd40
fix: preserve restored pubky sessions
Jasonvdb Sep 21, 2026
3adc559
fix: quarantine stale private paykit state
Jasonvdb Sep 21, 2026
613bc78
chore: merge master into shared pubky ring
Jasonvdb Sep 21, 2026
d1e3ed2
fix: clear stale pubky quarantine on restore
Jasonvdb Sep 21, 2026
26e67c3
fix: complete external identity teardown
Jasonvdb Sep 21, 2026
f19a2c7
fix: fail closed on unreadable pubky quarantine
Jasonvdb Sep 21, 2026
f37c8cd
fix: retain pubky cleanup session until teardown
Jasonvdb Sep 21, 2026
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
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">

<queries>
<package android:name="to.pubky.ring" />
<package android:name="app.pubkyring" />
<intent>
<action android:name="android.settings.APPLICATION_DETAILS_SETTINGS" />
</intent>
Expand All @@ -13,6 +13,12 @@
</intent>
</queries>

<permission
android:name="${applicationId}.permission.READ_SHARED_PUBKY"
android:protectionLevel="signature" />

<uses-permission android:name="app.pubkyring.permission.READ_SHARED_PUBKY" />

<uses-feature
android:name="android.hardware.camera"
android:required="false" />
Expand Down Expand Up @@ -227,6 +233,13 @@
android:exported="false"
tools:node="remove" />

<provider
android:name=".data.sharing.SharedPubkyProvider"
android:authorities="${applicationId}.sharedpubky"
android:exported="true"
android:readPermission="${applicationId}.permission.READ_SHARED_PUBKY"
android:writePermission="${applicationId}.permission.READ_SHARED_PUBKY" />

<!-- Set custom default icon.
This is used when no icon is set for incoming notification messages.
See https://goo.gl/l4GJaQ -->
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/to/bitkit/data/PubkyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import to.bitkit.data.serializers.PubkyStoreSerializer
import to.bitkit.data.sharing.SharedPubkyIdentity
import to.bitkit.models.PubkyProfileData
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -38,4 +39,6 @@ data class PubkyStoreData(
val cachedName: String? = null,
val cachedImageUri: String? = null,
val contactProfileOverrides: Map<String, PubkyProfileData> = emptyMap(),
val externalIdentityRef: SharedPubkyIdentity? = null,
val privatePaykitStateCleanupPending: Boolean = false,
)
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ class Keychain @Inject constructor(
PAYKIT_SDK_STATE,
PAYKIT_PENDING_PAYMENT_PROOFS,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PUBKY_MANAGED_SECRET_QUARANTINED,
PUBKY_SHARED_EXPORT_ENABLED,
PUBKY_SECRET_KEY,
}
}
Expand Down
102 changes: 102 additions & 0 deletions app/src/main/java/to/bitkit/data/sharing/SharedPubkyContract.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package to.bitkit.data.sharing

import android.net.Uri
import kotlinx.serialization.Serializable
import to.bitkit.utils.AppError
import java.util.Locale

object SharedPubkyContract {
const val PROTOCOL_VERSION = 1
const val BITKIT_SOURCE = "to.bitkit"
const val RING_SOURCE = "app.pubkyring"
const val RING_AUTHORITY = "app.pubkyring.sharedpubky"
const val RING_READ_PERMISSION = "app.pubkyring.permission.READ_SHARED_PUBKY"
const val IDENTITIES_PATH = "v1/identities"
const val RING_IDENTITIES_URI = "content://$RING_AUTHORITY/$IDENTITIES_PATH"

const val COLUMN_PROTOCOL_VERSION = "protocol_version"
const val COLUMN_SOURCE_PACKAGE = "source_package"
const val COLUMN_PUBKY = "pubky"
const val COLUMN_SECRET_KEY = "secret_key"

private const val BITKIT_PUBKY_PREFIX = "pubky"
private const val WIRE_PUBKY_LENGTH = 52
private const val CREDENTIAL_SEGMENT = "credential"
private val wirePubkyPattern = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$")
private val secretKeyPattern = Regex("^[0-9a-f]{64}$")

val publicColumns = arrayOf(
COLUMN_PROTOCOL_VERSION,
COLUMN_SOURCE_PACKAGE,
COLUMN_PUBKY,
)
val credentialColumns = publicColumns + COLUMN_SECRET_KEY

val ringIdentitiesUri: Uri
get() = Uri.parse(RING_IDENTITIES_URI)

fun ringCredentialUri(pubky: String): Uri = Uri.parse(ringCredentialUriString(pubky))

internal fun ringCredentialUriString(pubky: String): String =
"$RING_IDENTITIES_URI/${canonicalPubky(pubky)}/$CREDENTIAL_SEGMENT"

fun canonicalPubky(value: String): String {
val normalizedPubky = value.trim().lowercase(Locale.US)
val barePubky = if (
normalizedPubky.length == WIRE_PUBKY_LENGTH + BITKIT_PUBKY_PREFIX.length &&
normalizedPubky.startsWith(BITKIT_PUBKY_PREFIX)
) {
normalizedPubky.removePrefix(BITKIT_PUBKY_PREFIX)
} else {
normalizedPubky
}
require(wirePubkyPattern.matches(barePubky)) { "Invalid shared Pubky public key" }
return barePubky
}

fun requireWirePubky(value: String): String {
require(wirePubkyPattern.matches(value)) { "Invalid shared Pubky wire public key" }
return value
}

fun toBitkitPubky(value: String): String = "$BITKIT_PUBKY_PREFIX${canonicalPubky(value)}"

fun canonicalSecretKeyHex(value: String): String {
require(secretKeyPattern.matches(value)) { "Invalid shared Pubky secret key" }
return value
}
}

@Serializable
data class SharedPubkyIdentity(
val protocolVersion: Int,
val sourcePackage: String,
val pubky: String,
) {
fun validated(): SharedPubkyIdentity {
if (protocolVersion != SharedPubkyContract.PROTOCOL_VERSION) {
throw SharedPubkyError.UnsupportedVersion(protocolVersion)
}
if (sourcePackage != SharedPubkyContract.RING_SOURCE) {
throw SharedPubkyError.UntrustedSource(sourcePackage)
}
return copy(pubky = SharedPubkyContract.requireWirePubky(pubky))
}
}

class SharedPubkyCredential(
val identity: SharedPubkyIdentity,
secretKeyHex: String,
) {
val secretKeyHex = SharedPubkyContract.canonicalSecretKeyHex(secretKeyHex)
}

sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) {
data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable")
data object ProviderQueryFailed : SharedPubkyError("Pubky Ring identity sharing query failed")
class UntrustedSource(source: String) : SharedPubkyError("Untrusted Pubky identity source '$source'")
class UnsupportedVersion(version: Int) : SharedPubkyError("Unsupported Pubky sharing version '$version'")
data object InvalidResponse : SharedPubkyError("Pubky Ring returned an invalid shared identity")
data object IdentityUnavailable : SharedPubkyError("The selected Pubky Ring identity is unavailable")
data object IdentityConflict : SharedPubkyError("Another Pubky profile is already connected")
}
137 changes: 137 additions & 0 deletions app/src/main/java/to/bitkit/data/sharing/SharedPubkyDiscovery.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package to.bitkit.data.sharing

import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.runSuspendCatching
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SharedPubkyDiscovery @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun discoverRingIdentities(): Result<List<SharedPubkyIdentity>> = runSuspendCatching {
withContext(ioDispatcher) {
verifyRingProvider()
context.contentResolver.query(
SharedPubkyContract.ringIdentitiesUri,
SharedPubkyContract.publicColumns,
null,
null,
null,
)?.use(::readPublicIdentities) ?: throw SharedPubkyError.ProviderQueryFailed
}
}

suspend fun readRingCredential(pubky: String): Result<SharedPubkyCredential> = runSuspendCatching {
withContext(ioDispatcher) {
verifyRingProvider()
val expectedPubky = SharedPubkyContract.canonicalPubky(pubky)
context.contentResolver.query(
SharedPubkyContract.ringCredentialUri(expectedPubky),
SharedPubkyContract.credentialColumns,
null,
null,
null,
)?.use { readCredential(it, expectedPubky) } ?: throw SharedPubkyError.ProviderQueryFailed
}
}

@Suppress("ThrowsCount")
private fun verifyRingProvider() {
val packageManager = context.packageManager
val provider = packageManager.resolveContentProvider(
SharedPubkyContract.RING_AUTHORITY,
PackageManager.MATCH_ALL,
) ?: throw SharedPubkyError.SourceUnavailable
if (provider.packageName != SharedPubkyContract.RING_SOURCE) {
throw SharedPubkyError.UntrustedSource(provider.packageName)
}
if (
provider.authority != SharedPubkyContract.RING_AUTHORITY ||
provider.readPermission != SharedPubkyContract.RING_READ_PERMISSION
) {
throw SharedPubkyError.UntrustedSource(provider.packageName)
}
if (
packageManager.checkSignatures(context.packageName, provider.packageName) !=
PackageManager.SIGNATURE_MATCH
) {
throw SharedPubkyError.UntrustedSource(provider.packageName)
}
}

private fun readPublicIdentities(cursor: Cursor): List<SharedPubkyIdentity> {
val columns = cursor.requiredPublicColumns()
val identities = buildList {
while (cursor.moveToNext()) {
add(cursor.readIdentity(columns))
}
}
return identities.distinctBy { it.pubky }
}

@Suppress("ThrowsCount")
private fun readCredential(cursor: Cursor, expectedPubky: String): SharedPubkyCredential {
val publicColumns = cursor.requiredPublicColumns()
val secretKeyColumn = cursor.getColumnIndex(SharedPubkyContract.COLUMN_SECRET_KEY)
if (secretKeyColumn < 0 || !cursor.moveToFirst()) throw SharedPubkyError.IdentityUnavailable

val identity = cursor.readIdentity(publicColumns)
if (identity.pubky != expectedPubky || cursor.count != 1) {
throw SharedPubkyError.InvalidResponse
}
return runCatching {
SharedPubkyCredential(
identity = identity,
secretKeyHex = cursor.getString(secretKeyColumn).orEmpty(),
)
}.getOrElse {
throw SharedPubkyError.InvalidResponse
}
}

private fun Cursor.requiredPublicColumns() = PublicColumnIndexes(
protocolVersion = getColumnIndex(SharedPubkyContract.COLUMN_PROTOCOL_VERSION),
sourcePackage = getColumnIndex(SharedPubkyContract.COLUMN_SOURCE_PACKAGE),
pubky = getColumnIndex(SharedPubkyContract.COLUMN_PUBKY),
).also {
if (it.protocolVersion < 0 || it.sourcePackage < 0 || it.pubky < 0) {
throw SharedPubkyError.InvalidResponse
}
}

@Suppress("ThrowsCount")
private fun Cursor.readIdentity(columns: PublicColumnIndexes): SharedPubkyIdentity {
val version = getInt(columns.protocolVersion)
if (version != SharedPubkyContract.PROTOCOL_VERSION) {
throw SharedPubkyError.UnsupportedVersion(version)
}
val sourcePackage = getString(columns.sourcePackage).orEmpty()
if (sourcePackage != SharedPubkyContract.RING_SOURCE) {
throw SharedPubkyError.UntrustedSource(sourcePackage)
}
val pubky = runCatching {
SharedPubkyContract.requireWirePubky(getString(columns.pubky).orEmpty())
}.getOrElse {
throw SharedPubkyError.InvalidResponse
}
return SharedPubkyIdentity(
protocolVersion = version,
sourcePackage = sourcePackage,
pubky = pubky,
)
}
}

private data class PublicColumnIndexes(
val protocolVersion: Int,
val sourcePackage: Int,
val pubky: Int,
)
Loading
Loading