-
Notifications
You must be signed in to change notification settings - Fork 5
feat: share pubky keys with pubky ring #1329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 c84e83e
feat: list pubky ring identities on choice
Jasonvdb 34bd1b4
feat: adopt a pubky ring identity
Jasonvdb fadceca
feat: clear adopted pubky on source loss
Jasonvdb 372f440
refactor: remove pubky ring relay auth
Jasonvdb 998cb30
chore: rename changelog fragment
Jasonvdb 81ff214
fix: block profile save after failed lookup
Jasonvdb 81a46c0
Merge remote-tracking branch 'origin/master' into feat/shared-pubky-keys
Jasonvdb 703e7dd
fix: keep ring pubky when ring is unavailable
Jasonvdb 90e5649
fix: recheck ring pubky on foreground
Jasonvdb d90c704
fix: restore contact import after pubky pick
Jasonvdb caacc15
fix: block save after lookup fail for stored key
Jasonvdb 42bdc1b
fix: scroll pubky choice list
Jasonvdb 078aacf
chore: document shared pubky contract
Jasonvdb bcff999
fix: drop stale screens on pubky loss
Jasonvdb d4f77e6
chore: log failed ring listing
Jasonvdb 9b8c86a
fix: sign up ring keys only without a record
Jasonvdb 6dca12c
fix: resume create profile after ring adopt
Jasonvdb c132f05
chore: log failed ring record check
Jasonvdb bd367e5
test: use app errors in profile tests
Jasonvdb aa6db63
fix: toast failed ring contact import
Jasonvdb 6567fba
Merge remote-tracking branch 'origin/master' into feat/shared-pubky-keys
Jasonvdb 0dc4e77
fix: clear stale ring reference on signup
Jasonvdb 420fef9
fix: re-sign in adopted pubky with ring key
Jasonvdb 94940c1
fix: ask for ring when pubky key is unreadable
Jasonvdb af88aec
Merge remote-tracking branch 'origin/master' into feat/shared-pubky-keys
Jasonvdb 3ff19f5
fix: keep last pubky above the nav bar
Jasonvdb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
75 changes: 75 additions & 0 deletions
75
app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyClient.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
50 changes: 50 additions & 0 deletions
50
app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyContract.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
|
|
||
| /** 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
116
app/src/main/java/to/bitkit/data/sharedpubky/SharedPubkyProvider.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 0 additions & 70 deletions
70
app/src/main/java/to/bitkit/models/PubkyRingAuthCallback.kt
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.