feat: Add support for NIP-55 #18

Merged
reya merged 5 commits from nip55 into master 2026-06-09 07:53:49 +00:00
8 changed files with 457 additions and 7 deletions
Showing only changes of commit a46063d8c4 - Show all commits

View File

@@ -11,6 +11,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
</queries>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"

View File

@@ -0,0 +1,151 @@
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.PublicKey
import rust.nostr.sdk.UnsignedEvent
class AndroidExternalSigner(
private val context: Context,
private val launcher: ExternalSignerLauncher,
) : ExternalSignerHandler {
private var cachedPackageName: String? = null
private var cachedPublicKey: PublicKey? = null
private data class ContentResolverResult(
val result: String,
val event: String? = null,
)
private fun queryContentResolver(
type: String,
payload: String,
pubkey: PublicKey? = null,
currentUser: PublicKey? = null,
): ContentResolverResult? {
val uri = "content://$cachedPackageName.${type.uppercase()}".toUri()
val cursor = context.contentResolver.query(
uri,
listOf(payload, pubkey, currentUser) as Array<out String?>?,
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)
}
}

View File

@@ -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<String>()
projection.add(payload)
if (pubkey != null) projection.add(pubkey.toHex())
projection.add(currentUser.toHex())
val cursor = context.contentResolver.query(
uri,
projection.toList() as Array<out String?>?,
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<String, String> = 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<String, String> = 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
)
}
}

View File

@@ -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<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null
fun register(launcher: ActivityResultLauncher<Intent>) {
this.launcher = launcher
}
fun unregister() {
launcher = null
pendingResult?.cancel()
pendingResult = null
}
suspend fun launch(intent: Intent): ActivityResult {
val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred
launcher?.launch(intent)
?: throw IllegalStateException("ExternalSignerLauncher not registered")
return deferred.await()
}
fun onResult(result: ActivityResult) {
pendingResult?.complete(result)
pendingResult = null
}
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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()

View File

@@ -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,
)

View File

@@ -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")
}
}

View File

@@ -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")
}
}