Skip to content
Draft
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
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 @@ -188,6 +194,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
2 changes: 2 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.ExternalPubkyIdentityRef
import to.bitkit.models.PubkyProfileData
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -38,4 +39,5 @@ data class PubkyStoreData(
val cachedName: String? = null,
val cachedImageUri: String? = null,
val contactProfileOverrides: Map<String, PubkyProfileData> = emptyMap(),
val externalIdentityRef: ExternalPubkyIdentityRef? = null,
)
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 @@ -234,6 +234,8 @@ class Keychain @Inject constructor(
PAYKIT_SESSION,
PAYKIT_RECEIVER_NOISE_SECRET_KEY,
PAYKIT_SDK_STATE,
PUBKY_MANAGED_SECRET_QUARANTINED,
PUBKY_SHARED_EXPORT_ENABLED,
PUBKY_SECRET_KEY,
}
}
Expand Down
107 changes: 107 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,107 @@
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
}
}

data class SharedPubkyIdentity(
val protocolVersion: Int,
val sourcePackage: String,
val pubky: String,
)

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

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

sealed class SharedPubkyError(message: String, cause: Throwable? = null) : AppError(message, cause) {
data object SourceUnavailable : SharedPubkyError("Pubky Ring identity sharing is unavailable")
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.SourceUnavailable
}
}

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.IdentityUnavailable
}
}

@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.moveToNext()) {
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