feat: Add support for NIP-55 #18

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

View File

@@ -12,7 +12,6 @@ class AndroidExternalSigner(
private val launcher: ExternalSignerLauncher, private val launcher: ExternalSignerLauncher,
) : ExternalSignerHandler { ) : ExternalSignerHandler {
private var cachedPackageName: String? = null private var cachedPackageName: String? = null
private var cachedPublicKey: PublicKey? = null
private data class ContentResolverResult( private data class ContentResolverResult(
val result: String, val result: String,
@@ -26,9 +25,15 @@ class AndroidExternalSigner(
currentUser: PublicKey? = null, currentUser: PublicKey? = null,
): ContentResolverResult? { ): ContentResolverResult? {
val uri = "content://$cachedPackageName.${type.uppercase()}".toUri() val uri = "content://$cachedPackageName.${type.uppercase()}".toUri()
val projection = mutableListOf<String?>().apply {
add(payload)
add(pubkey?.toHex() ?: "")
add(currentUser?.toHex() ?: "")
}
val cursor = context.contentResolver.query( val cursor = context.contentResolver.query(
uri, uri,
listOf(payload, pubkey, currentUser) as Array<out String?>?, projection.toTypedArray(),
null, null, null, null, null, null,
) ?: return null ) ?: return null
@@ -79,6 +84,10 @@ class AndroidExternalSigner(
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
} }
override fun setPackageName(packageName: String) {
cachedPackageName = packageName
}
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? { override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply { val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
putExtra("type", "get_public_key") putExtra("type", "get_public_key")
@@ -93,8 +102,9 @@ class AndroidExternalSigner(
val pubkey = data.getStringExtra("result") ?: return null val pubkey = data.getStringExtra("result") ?: return null
val packageName = data.getStringExtra("package") ?: return null val packageName = data.getStringExtra("package") ?: return null
cachedPackageName = packageName
return ExternalSignerResult(pubkey, packageName) return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
} }
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? { override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
@@ -148,4 +158,4 @@ class AndroidExternalSigner(
): String? { ): String? {
return request("nip44_decrypt", ciphertext, pubkey, currentUser) return request("nip44_decrypt", ciphertext, pubkey, currentUser)
} }
} }

View File

@@ -1,166 +0,0 @@
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

@@ -81,4 +81,9 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
} }
override fun onDestroy() {
externalSignerLauncher.unregister()
super.onDestroy()
}
} }

View File

@@ -1,5 +1,6 @@
package su.reya.coop.screens package su.reya.coop.screens
import android.content.Intent
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -13,13 +14,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@@ -27,6 +32,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
@@ -34,10 +40,13 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
@@ -45,8 +54,11 @@ import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun OnboardingScreen() { fun OnboardingScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()
@@ -142,7 +154,44 @@ fun OnboardingScreen() {
) )
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
OutlinedButton( FilledTonalButton(
onClick = {
scope.launch {
if (viewModel.isExternalSignerAvailable()) {
try {
viewModel.connectExternalSigner()
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else {
val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install",
withDismissAction = true,
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
val intent = Intent(
Intent.ACTION_VIEW,
"https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
)
context.startActivity(intent)
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Connect with Amber",
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.size(8.dp))
TextButton(
onClick = { navigator.navigate(Screen.Import) }, onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -1,5 +1,7 @@
package su.reya.coop package su.reya.coop
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
@@ -9,7 +11,7 @@ import rust.nostr.sdk.UnsignedEvent
*/ */
interface ExternalSignerHandler { interface ExternalSignerHandler {
fun isAvailable(): Boolean fun isAvailable(): Boolean
fun setPackageName(packageName: String)
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult? suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
@@ -18,7 +20,25 @@ interface ExternalSignerHandler {
suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String? suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
} }
@Serializable
data class SignerPermission(
val type: String,
val kind: Int? = null,
)
object SignerPermissions {
fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
fun toJson(permissions: List<SignerPermission>): String {
return Json.encodeToString(permissions)
}
}
data class ExternalSignerResult( data class ExternalSignerResult(
val pubkey: String, val pubkey: PublicKey,
val packageName: String, val packageName: String,
) )

View File

@@ -7,7 +7,6 @@ import rust.nostr.sdk.UnsignedEvent
class ExternalSignerProxy( class ExternalSignerProxy(
private val handler: ExternalSignerHandler, private val handler: ExternalSignerHandler,
private val packageName: String,
private val currentUser: PublicKey, private val currentUser: PublicKey,
) : AsyncNostrSigner { ) : AsyncNostrSigner {
override suspend fun getPublicKeyAsync(): PublicKey { override suspend fun getPublicKeyAsync(): PublicKey {

View File

@@ -372,33 +372,6 @@ class NostrViewModel(
return keys return keys
} }
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)
val timeout = 50.seconds // or Duration.parse("50s")
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")
}
}
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? { private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try { try {
// Upload picture to Blossom // Upload picture to Blossom
@@ -471,6 +444,34 @@ 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)
val timeout = 50.seconds // or Duration.parse("50s")
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])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
suspend fun verifyIdentity(secret: String): PublicKey? { suspend fun verifyIdentity(secret: String): PublicKey? {
try { try {
val signer = createSigner(secret) val signer = createSigner(secret)
@@ -498,6 +499,39 @@ class NostrViewModel(
} }
} }
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_isLoggedIn.value = true
try {
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(),
SignerPermissions.nip04Encrypt(),
SignerPermissions.nip04Decrypt(),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_signerRequired.value = false
_isLoggedIn.value = false
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun useDefaultMsgRelayList() { suspend fun useDefaultMsgRelayList() {
try { try {
val defaultRelays = nostr.getDefaultMsgRelayList() val defaultRelays = nostr.getDefaultMsgRelayList()