From a46063d8c4a2251e81eb7863ef3b35b9867653bb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 9 Jun 2026 08:59:16 +0700 Subject: [PATCH] wip: external signer --- .../src/androidMain/AndroidManifest.xml | 8 + .../su/reya/coop/AndroidExternalSigner.kt | 151 ++++++++++++++++ .../kotlin/su/reya/coop/ExternalSigner.kt | 166 ++++++++++++++++++ .../su/reya/coop/ExternalSignerLauncher.kt | 34 ++++ .../kotlin/su/reya/coop/MainActivity.kt | 24 ++- .../su/reya/coop/ExternalSignerHandler.kt | 24 +++ .../su/reya/coop/ExternalSignerProxy.kt | 41 +++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 16 +- 8 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/AndroidExternalSigner.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt create mode 100644 shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c4d8237..b396cd3 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -11,6 +11,14 @@ + + + + + + + + ?, + null, null, null, + ) ?: return null + + return cursor.use { + if (it.getColumnIndex("rejected") > -1) return null + if (it.moveToFirst()) { + val resultIndex = it.getColumnIndex("result") + val result = if (resultIndex > -1) it.getString(resultIndex) else null + + val eventIndex = it.getColumnIndex("event") + val event = if (eventIndex > -1) it.getString(eventIndex) else null + + ContentResolverResult(result = result!!, event = event) + } else null + } + } + + private suspend fun request( + type: String, + payload: String, + pubkey: PublicKey? = null, + currentUser: PublicKey? = null, + ): String? { + // Try Content Resolver first + queryContentResolver(type, payload, pubkey, currentUser)?.let { + return it.result + } + + // Fall back to Intent + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply { + `package` = cachedPackageName + putExtra("type", type) + if (pubkey != null) putExtra("pubkey", pubkey.toHex()) + if (currentUser != null) putExtra("current_user", currentUser.toHex()) + } + + val result = launcher.launch(intent) + if (result.resultCode != Activity.RESULT_OK) return null + + val data = result.data ?: return null + if (data.getBooleanExtra("rejected", false)) return null + + return data.getStringExtra("result") + } + + override fun isAvailable(): Boolean { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()) + return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() + } + + override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply { + putExtra("type", "get_public_key") + if (permissions != null) putExtra("permissions", permissions) + } + + val result = launcher.launch(intent) + if (result.resultCode != Activity.RESULT_OK) return null + + val data = result.data ?: return null + if (data.getBooleanExtra("rejected", false)) return null + + val pubkey = data.getStringExtra("result") ?: return null + val packageName = data.getStringExtra("package") ?: return null + + return ExternalSignerResult(pubkey, packageName) + } + + override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? { + // Try Content Resolver first + queryContentResolver( + "SIGN_EVENT", + event.asJson(), + null, + currentUser + )?.let { result -> + if (result.event != null) return result.event + } + + // Fall back to Intent + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:${event.asJson()}".toUri()).apply { + `package` = cachedPackageName + putExtra("type", "sign_event") + putExtra("current_user", currentUser.toHex()) + if (event.id() != null) putExtra("id", event.id()!!.toHex()) + } + + val result = launcher.launch(intent) + if (result.resultCode != Activity.RESULT_OK) return null + + val data = result.data ?: return null + if (data.getBooleanExtra("rejected", false)) return null + + return data.getStringExtra("event") + } + + override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? { + return request("nip04_encrypt", plaintext, pubkey) + } + + override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? { + return request("nip04_decrypt", ciphertext, pubkey) + } + + override suspend fun nip44Encrypt( + plaintext: String, + pubkey: PublicKey, + currentUser: PublicKey + ): String? { + return request("nip44_encrypt", plaintext, pubkey, currentUser) + } + + override suspend fun nip44Decrypt( + ciphertext: String, + pubkey: PublicKey, + currentUser: PublicKey + ): String? { + return request("nip44_decrypt", ciphertext, pubkey, currentUser) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt new file mode 100644 index 0000000..4c08304 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSigner.kt @@ -0,0 +1,166 @@ +package su.reya.coop + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Event +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent + +data class SignerResult( + val result: String, + val event: Event? = null, +) + +class ExternalSigner( + private val context: Context, + private val packageName: String, + private val currentUser: PublicKey, + private val launcher: ExternalSignerLauncher, +) : AsyncNostrSigner { + private fun queryContentResolver( + type: String, + payload: String, + pubkey: PublicKey? = null + ): SignerResult? { + val uri = "content://$packageName.${type.uppercase()}".toUri() + val projection = mutableListOf() + projection.add(payload) + if (pubkey != null) projection.add(pubkey.toHex()) + projection.add(currentUser.toHex()) + + val cursor = context.contentResolver.query( + uri, + projection.toList() as Array?, + null, null, null, + ) ?: return null + + return cursor.use { + if (it.getColumnIndex("rejected") > -1) return null + + if (it.moveToFirst()) { + val resultIndex = it.getColumnIndex(payload) + val result = if (resultIndex > -1) it.getString(resultIndex) else null + + val eventIndex = it.getColumnIndex("event") + val event = if (eventIndex > -1) it.getString(eventIndex) else null + + SignerResult(result = result!!, event = Event.fromJson(event!!)) + } else { + null + } + } + } + + private suspend fun requestViaIntent( + type: String, + payload: String, + extras: Map = emptyMap() + ): String { + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply { + `package` = packageName + putExtra("type", type) + putExtra("current_user", currentUser.toHex()) + extras.forEach { (k, v) -> putExtra(k, v) } + } + + val result = launcher.launch(intent) + + if (result.resultCode != Activity.RESULT_OK) { + throw Exception("Signer returned error (resultCode=${result.resultCode})") + } + + val data = result.data ?: throw Exception("Signer returned no data") + + if (data.getBooleanExtra("rejected", false)) { + throw Exception("User rejected the request") + } + + return data.getStringExtra("result") ?: throw Exception("Signer returned no result") + } + + private suspend fun request( + type: String, + payload: String, + pubkey: PublicKey? = null, + currentUser: PublicKey? = null, + extras: Map = emptyMap() + ): String { + // Try silent Content Resolver first + queryContentResolver(type, payload, pubkey)?.let { return it.result } + + // Fall back to Intent + val allExtras = extras.toMutableMap().apply { + if (pubkey != null) put("pubkey", pubkey.toHex()) + if (currentUser != null) put("current_user", currentUser.toHex()) + } + + return requestViaIntent(type, payload, allExtras) + } + + override suspend fun getPublicKeyAsync(): PublicKey { + return currentUser + } + + override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? { + val eventJson = unsignedEvent.asJson() + + // Try Content Resolver first + val contentResult = queryContentResolver("sign_event", eventJson) + contentResult?.event?.let { return it } + + // Fall back to Intent + val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$eventJson".toUri()).apply { + `package` = packageName + putExtra("type", "sign_event") + putExtra("current_user", currentUser.toHex()) + putExtra("id", unsignedEvent.id()?.toHex() ?: "") + } + + val result = launcher.launch(intent) + if (result.resultCode != Activity.RESULT_OK) return null + + val data = result.data ?: return null + if (data.getBooleanExtra("rejected", false)) return null + + val signedEventJson = data.getStringExtra("event") + + return signedEventJson?.let { Event.fromJson(it) } + } + + override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String { + return request( + "nip04_encrypt", + payload = content, + pubkey = publicKey + ) + } + + override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String { + return request( + "nip04_decrypt", + payload = encryptedContent, + pubkey = publicKey + ) + } + + override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String { + return request( + "nip44_encrypt", + payload = content, + pubkey = publicKey, + currentUser = currentUser + ) + } + + override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { + return request( + "nip44_decrypt", + payload = payload, + pubkey = publicKey, + currentUser = currentUser + ) + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt new file mode 100644 index 0000000..c05f578 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/ExternalSignerLauncher.kt @@ -0,0 +1,34 @@ +package su.reya.coop + +import android.content.Intent +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import kotlinx.coroutines.CompletableDeferred + +class ExternalSignerLauncher { + private var launcher: ActivityResultLauncher? = null + private var pendingResult: CompletableDeferred? = null + + fun register(launcher: ActivityResultLauncher) { + this.launcher = launcher + } + + fun unregister() { + launcher = null + pendingResult?.cancel() + pendingResult = null + } + + suspend fun launch(intent: Intent): ActivityResult { + val deferred = CompletableDeferred() + pendingResult = deferred + launcher?.launch(intent) + ?: throw IllegalStateException("ExternalSignerLauncher not registered") + return deferred.await() + } + + fun onResult(result: ActivityResult) { + pendingResult?.complete(result) + pendingResult = null + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 0e51ee7..ce3609f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -2,9 +2,12 @@ package su.reya.coop import android.content.Intent import android.os.Bundle +import android.os.Process +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.ProcessLifecycleOwner @@ -14,11 +17,14 @@ import su.reya.coop.coop.storage.SecretStore import kotlin.system.exitProcess class MainActivity : ComponentActivity() { + private val externalSignerLauncher = ExternalSignerLauncher() + private val viewModel: NostrViewModel by viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { val secretStore = SecretStore(this@MainActivity) - return NostrViewModel(NostrManager.instance, secretStore) as T + val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) + return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T } } } @@ -26,10 +32,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> throwable.printStackTrace() - android.util.Log.e( - "CoopCrash", - "Uncaught exception in thread ${thread.name}", - throwable + + Log.e( + "CoopCrash", "Uncaught exception in thread ${thread.name}", throwable ) // Start the Crash Activity @@ -40,10 +45,17 @@ class MainActivity : ComponentActivity() { startActivity(intent) // Exit - android.os.Process.killProcess(android.os.Process.myPid()) + Process.killProcess(Process.myPid()) exitProcess(1) } + val resultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + externalSignerLauncher.onResult(result) + } + externalSignerLauncher.register(resultLauncher) + val splashScreen = installSplashScreen() enableEdgeToEdge() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt new file mode 100644 index 0000000..94391c0 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerHandler.kt @@ -0,0 +1,24 @@ +package su.reya.coop + +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent + +/** + * Platform interface for NIP-55 external signer communication. + * Implemented on Android; no-op/null on other platforms. + */ +interface ExternalSignerHandler { + fun isAvailable(): Boolean + + suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult? + suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? + suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? + suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? + suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String? + suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String? +} + +data class ExternalSignerResult( + val pubkey: String, + val packageName: String, +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt new file mode 100644 index 0000000..4c0ea37 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/ExternalSignerProxy.kt @@ -0,0 +1,41 @@ +package su.reya.coop + +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Event +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent + +class ExternalSignerProxy( + private val handler: ExternalSignerHandler, + private val packageName: String, + private val currentUser: PublicKey, +) : AsyncNostrSigner { + override suspend fun getPublicKeyAsync(): PublicKey { + return currentUser + } + + override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? { + val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null + return Event.fromJson(signedJson) + } + + override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String { + return handler.nip04Encrypt(content, publicKey) + ?: throw Exception("NIP-04 encrypt rejected") + } + + override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String { + return handler.nip04Decrypt(encryptedContent, publicKey) + ?: throw Exception("NIP-04 decrypt rejected") + } + + override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String { + return handler.nip44Encrypt(content, publicKey, currentUser) + ?: throw Exception("NIP-44 encrypt rejected") + } + + override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String { + return handler.nip44Decrypt(payload, publicKey, currentUser) + ?: throw Exception("NIP-44 decrypt rejected") + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 2de4218..58592a5 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -44,7 +44,8 @@ import kotlin.time.Duration.Companion.seconds class NostrViewModel( private val nostr: Nostr, - private val secretStore: SecretStorage + private val secretStore: SecretStorage, + private val externalSignerHandler: ExternalSignerHandler? = null, ) : ViewModel() { private val _isNotificationBannerDismissed = MutableStateFlow(false) val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow() @@ -374,6 +375,7 @@ class NostrViewModel( private suspend fun createSigner(secret: String): AsyncNostrSigner { return when { secret.startsWith("nsec1") -> Keys.parse(secret) + secret.startsWith("bunker://") -> { val appKeys = getOrInitAppKeys() val bunker = NostrConnectUri.parse(secret) @@ -381,6 +383,18 @@ class NostrViewModel( NostrConnect(uri = bunker, appKeys, timeout, null) } + secret.startsWith("nip55://") -> { + val handler = externalSignerHandler + ?: throw IllegalStateException("External signer not available on this platform") + + // Format: nip55://packageName/hexPubkey + val parts = secret.removePrefix("nip55://").split("/", limit = 2) + val packageName = parts[0] + val pubkey = PublicKey.parse(parts[1]) + + ExternalSignerProxy(handler, packageName, pubkey) + } + else -> throw IllegalArgumentException("Invalid secret format") } }