Skip to content
Merged
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
f90abd9
feat: export bitkit pubky via content provider
Jasonvdb Sep 22, 2026
c84e83e
feat: list pubky ring identities on choice
Jasonvdb Sep 22, 2026
34bd1b4
feat: adopt a pubky ring identity
Jasonvdb Sep 22, 2026
fadceca
feat: clear adopted pubky on source loss
Jasonvdb Sep 22, 2026
372f440
refactor: remove pubky ring relay auth
Jasonvdb Sep 22, 2026
998cb30
chore: rename changelog fragment
Jasonvdb Sep 22, 2026
81ff214
fix: block profile save after failed lookup
Jasonvdb Sep 23, 2026
81a46c0
Merge remote-tracking branch 'origin/master' into feat/shared-pubky-keys
Jasonvdb Sep 23, 2026
703e7dd
fix: keep ring pubky when ring is unavailable
Jasonvdb Sep 24, 2026
90e5649
fix: recheck ring pubky on foreground
Jasonvdb Sep 24, 2026
d90c704
fix: restore contact import after pubky pick
Jasonvdb Sep 24, 2026
caacc15
fix: block save after lookup fail for stored key
Jasonvdb Sep 24, 2026
42bdc1b
fix: scroll pubky choice list
Jasonvdb Sep 24, 2026
078aacf
chore: document shared pubky contract
Jasonvdb Sep 24, 2026
bcff999
fix: drop stale screens on pubky loss
Jasonvdb Sep 24, 2026
d4f77e6
chore: log failed ring listing
Jasonvdb Sep 24, 2026
9b8c86a
fix: sign up ring keys only without a record
Jasonvdb Sep 24, 2026
6dca12c
fix: resume create profile after ring adopt
Jasonvdb Sep 24, 2026
c132f05
chore: log failed ring record check
Jasonvdb Sep 24, 2026
bd367e5
test: use app errors in profile tests
Jasonvdb Sep 24, 2026
aa6db63
fix: toast failed ring contact import
Jasonvdb Sep 24, 2026
6567fba
Merge remote-tracking branch 'origin/master' into feat/shared-pubky-keys
Jasonvdb Sep 24, 2026
0dc4e77
fix: clear stale ring reference on signup
Jasonvdb Sep 24, 2026
420fef9
fix: re-sign in adopted pubky with ring key
Jasonvdb Sep 24, 2026
94940c1
fix: ask for ring when pubky key is unreadable
Jasonvdb Sep 24, 2026
af88aec
Merge remote-tracking branch 'origin/master' into feat/shared-pubky-keys
Jasonvdb Sep 27, 2026
3ff19f5
fix: keep last pubky above the nav bar
Jasonvdb Sep 27, 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
19 changes: 14 additions & 5 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,17 @@
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>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="pubkyauth" />
</intent>
</queries>

<uses-feature
android:name="android.hardware.camera"
android:required="false" />

<uses-permission android:name="app.pubkyring.permission.READ_SHARED_PUBKY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CLIPBOARD" />
<uses-permission android:name="android.permission.INTERNET" />
Expand Down Expand Up @@ -52,6 +49,11 @@
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />

<!-- Guards the read-only pubky ContentProvider; only same-signature apps may hold it. -->
<permission
android:name="${applicationId}.permission.READ_SHARED_PUBKY"
android:protectionLevel="signature" />

<application
android:name=".App"
android:allowBackup="false"
Expand Down Expand Up @@ -244,6 +246,13 @@
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/app_notifications_channel_id" />

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

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ class Keychain @Inject constructor(
PAYKIT_PENDING_PAYMENT_PROOFS,
PAYKIT_PRESENTED_PAYMENT_REQUESTS,
PUBKY_SECRET_KEY,
SHARED_PUBKY_SOURCE,
}
}

Expand Down
75 changes: 75 additions & 0 deletions app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package to.bitkit.data.sharedpubky

import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import to.bitkit.data.sharedpubky.SharedPubkyContract.COLUMN_PUBKY
import to.bitkit.data.sharedpubky.SharedPubkyContract.COLUMN_SECRET_KEY
import to.bitkit.data.sharedpubky.SharedPubkyContract.PATH_CREDENTIAL
import to.bitkit.data.sharedpubky.SharedPubkyContract.PATH_IDENTITIES
import to.bitkit.data.sharedpubky.SharedPubkyContract.RING_AUTHORITY
import to.bitkit.data.sharedpubky.SharedPubkyContract.RING_PACKAGE
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.runSuspendCatching
import to.bitkit.models.PubkyPublicKeyFormat
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SharedPubkyClient @Inject constructor(
@ApplicationContext private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun listRingIdentities(): Result<ImmutableList<String>> = withContext(ioDispatcher) {
runSuspendCatching {
check(isRingProviderTrusted()) { "Ring provider unavailable" }

val uri = Uri.parse("content://$RING_AUTHORITY/$PATH_IDENTITIES")
val cursor = requireNotNull(context.contentResolver.query(uri, null, null, null, null)) {
"Ring identities query returned no cursor"
}
cursor.use {
buildList {
val column = it.getColumnIndexOrThrow(COLUMN_PUBKY)
while (it.moveToNext()) {
it.getString(column)?.let(::add)
}
}
}.toImmutableList()
}
}

suspend fun ringCredential(pubky: String): Result<String> = withContext(ioDispatcher) {
runSuspendCatching {
requireNotNull(readCredential(pubky)) { "Ring credential unavailable" }
}
}

internal fun readCredential(pubky: String): String? {
if (!isRingProviderTrusted()) return null

val uri = Uri.parse("content://$RING_AUTHORITY/$PATH_IDENTITIES/$pubky/$PATH_CREDENTIAL")
val cursor = requireNotNull(context.contentResolver.query(uri, null, null, null, null)) {
"Ring credential query returned no cursor"
}
return cursor.use {
if (it.count != 1 || !it.moveToFirst()) return null
val rowPubky = it.getString(it.getColumnIndexOrThrow(COLUMN_PUBKY))
val secretKeyHex = it.getString(it.getColumnIndexOrThrow(COLUMN_SECRET_KEY)).orEmpty()

if (!PubkyPublicKeyFormat.matches(rowPubky, pubky)) return null
secretKeyHex.takeIf { hex -> SharedPubkyContract.isValidSecret(hex, pubky) }
}
}

private fun isRingProviderTrusted(): Boolean {
val packageManager = context.packageManager
return packageManager.resolveContentProvider(RING_AUTHORITY, 0)?.packageName == RING_PACKAGE &&
packageManager.checkSignatures(context.packageName, RING_PACKAGE) == PackageManager.SIGNATURE_MATCH
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package to.bitkit.data.sharedpubky

import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.services.PaykitSdkService

private const val PUBKY_PREFIX = "pubky"
private val SECRET_KEY_PATTERN = Regex("[0-9a-f]{64}")

object SharedPubkyContract {
/** Appended to an applicationId to form its shared pubky provider authority, same as in Pubky Ring. */
const val AUTHORITY_SUFFIX = ".sharedpubky"
Comment thread
ovitrif marked this conversation as resolved.

/** Provider path listing the app's pubkys, one [COLUMN_PUBKY] row each. */
const val PATH_IDENTITIES = "v1/identities"

/** Final segment of `v1/identities/<pubky>/credential`, which returns that pubky's secret key. */
const val PATH_CREDENTIAL = "credential"

/** Public key column of both the identities and credential rows. */
const val COLUMN_PUBKY = "pubky"

/** Secret key column of the credential row, as 64 lowercase hex characters. */
const val COLUMN_SECRET_KEY = "secret_key"

/** Pubky Ring's Android applicationId. */
const val RING_PACKAGE = "app.pubkyring"

/** Authority of Pubky Ring's shared pubky provider. */
const val RING_AUTHORITY = RING_PACKAGE + AUTHORITY_SUFFIX

/** Prefix of the stored `app.pubkyring:<pubky>` source reference that marks an adopted Ring pubky. */
const val RING_SOURCE_PREFIX = "$RING_PACKAGE:"

fun isValidSecret(
secretKeyHex: String,
pubky: String,
derivePublicKey: (String) -> String = PaykitSdkService::publicKeyFromSecret,
): Boolean = PubkyPublicKeyFormat.matches(pubkyFromSecret(secretKeyHex, derivePublicKey), pubky)

internal fun pubkyFromSecret(
secretKeyHex: String,
derivePublicKey: (String) -> String,
): String? {
if (!SECRET_KEY_PATTERN.matches(secretKeyHex)) return null
return runCatching { derivePublicKey(secretKeyHex) }
.getOrNull()
?.let(PubkyPublicKeyFormat::normalized)
?.removePrefix(PUBKY_PREFIX)
}
}
116 changes: 116 additions & 0 deletions app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package to.bitkit.data.sharedpubky

import android.content.ContentProvider
import android.content.ContentValues
import android.content.UriMatcher
import android.content.pm.PackageManager
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Binder
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import to.bitkit.data.keychain.Keychain
import to.bitkit.data.sharedpubky.SharedPubkyContract.AUTHORITY_SUFFIX
import to.bitkit.data.sharedpubky.SharedPubkyContract.COLUMN_PUBKY
import to.bitkit.data.sharedpubky.SharedPubkyContract.COLUMN_SECRET_KEY
import to.bitkit.data.sharedpubky.SharedPubkyContract.PATH_CREDENTIAL
import to.bitkit.data.sharedpubky.SharedPubkyContract.PATH_IDENTITIES
import to.bitkit.models.PubkyPublicKeyFormat
import to.bitkit.services.PaykitSdkService
import to.bitkit.utils.Logger

private const val TAG = "SharedPubkyProvider"
private const val MATCH_IDENTITIES = 1
private const val MATCH_CREDENTIAL = 2
private const val MIME_SUBTYPE = "vnd.to.bitkit.sharedpubky"
private const val PUBKY_PATH_INDEX = 2

class SharedPubkyProvider : ContentProvider() {
internal var keychainProvider: () -> Keychain? = {
context?.applicationContext?.let {
EntryPointAccessors.fromApplication(it, SharedPubkyEntryPoint::class.java).keychain()
}
}

internal var derivePublicKey: (String) -> String = PaykitSdkService::publicKeyFromSecret

private val uriMatcher by lazy {
UriMatcher(UriMatcher.NO_MATCH).apply {
val authority = "${context?.packageName}$AUTHORITY_SUFFIX"
addURI(authority, PATH_IDENTITIES, MATCH_IDENTITIES)
addURI(authority, "$PATH_IDENTITIES/*/$PATH_CREDENTIAL", MATCH_CREDENTIAL)
}
}

override fun onCreate() = true

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? {
if (!isCallerTrusted(Binder.getCallingUid())) throw SecurityException("Caller signature mismatch")

val match = uriMatcher.match(uri)
val cursor = when (match) {
MATCH_IDENTITIES -> MatrixCursor(arrayOf(COLUMN_PUBKY))
MATCH_CREDENTIAL -> MatrixCursor(arrayOf(COLUMN_PUBKY, COLUMN_SECRET_KEY))
else -> return null
}

val secretKeyHex = loadSecretKey().getOrElse { return null } ?: return cursor
val pubky = SharedPubkyContract.pubkyFromSecret(secretKeyHex, derivePublicKey) ?: return cursor

when (match) {
MATCH_IDENTITIES -> cursor.addRow(arrayOf(pubky))
MATCH_CREDENTIAL -> if (PubkyPublicKeyFormat.matches(uri.pathSegments.getOrNull(PUBKY_PATH_INDEX), pubky)) {
cursor.addRow(arrayOf(pubky, secretKeyHex))
}
}
Logger.debug("Served shared pubky '${PubkyPublicKeyFormat.redacted(pubky)}'", context = TAG)

return cursor
}

override fun getType(uri: Uri): String? = when (uriMatcher.match(uri)) {
MATCH_IDENTITIES -> "vnd.android.cursor.dir/$MIME_SUBTYPE"
MATCH_CREDENTIAL -> "vnd.android.cursor.item/$MIME_SUBTYPE"
else -> null
}

override fun insert(uri: Uri, values: ContentValues?): Uri = throw UnsupportedOperationException()

override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = throw UnsupportedOperationException()

override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int =
throw UnsupportedOperationException()

internal fun isCallerTrusted(uid: Int): Boolean {
val context = context ?: return false
val packageManager = context.packageManager
val callerPackages = packageManager.getPackagesForUid(uid).orEmpty()

return callerPackages.isNotEmpty() && callerPackages.all {
packageManager.checkSignatures(context.packageName, it) == PackageManager.SIGNATURE_MATCH
}
}

private fun loadSecretKey(): Result<String?> =
runCatching { keychainProvider()?.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)?.takeIf { it.isNotBlank() } }
}

@EntryPoint
@InstallIn(SingletonComponent::class)
interface SharedPubkyEntryPoint {
fun keychain(): Keychain
}
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/models/BackupPayloads.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ data class MetadataBackupV1(
@Serializable
data class PubkySessionBackupV1(
val kind: PubkySessionBackupKind,
val sessionSecret: String? = null,
)

@Serializable
enum class PubkySessionBackupKind {
@SerialName("localSeed")
LocalSeed,

/** Legacy: sessions imported through the removed Pubky Ring relay flow; restored as no identity. */
@SerialName("externalSession")
ExternalSession,
}
Expand Down
70 changes: 0 additions & 70 deletions app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt

This file was deleted.

Loading
Loading