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,
) : ExternalSignerHandler {
private var cachedPackageName: String? = null
private var cachedPublicKey: PublicKey? = null
private data class ContentResolverResult(
val result: String,
@@ -26,9 +25,15 @@ class AndroidExternalSigner(
currentUser: PublicKey? = null,
): ContentResolverResult? {
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(
uri,
listOf(payload, pubkey, currentUser) as Array<out String?>?,
projection.toTypedArray(),
null, null, null,
) ?: return null
@@ -79,6 +84,10 @@ class AndroidExternalSigner(
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
}
override fun setPackageName(packageName: String) {
cachedPackageName = packageName
}
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
putExtra("type", "get_public_key")
@@ -93,8 +102,9 @@ class AndroidExternalSigner(
val pubkey = data.getStringExtra("result") ?: 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? {
@@ -148,4 +158,4 @@ class AndroidExternalSigner(
): String? {
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)
setIntent(intent)
}
override fun onDestroy() {
externalSignerLauncher.unregister()
super.onDestroy()
}
}

View File

@@ -1,5 +1,6 @@
package su.reya.coop.screens
import android.content.Intent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily
@@ -45,8 +54,11 @@ import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily()
@@ -142,7 +154,44 @@ fun OnboardingScreen() {
)
}
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) },
modifier = Modifier
.fillMaxWidth()

View File

@@ -1,5 +1,7 @@
package su.reya.coop
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
@@ -9,7 +11,7 @@ import rust.nostr.sdk.UnsignedEvent
*/
interface ExternalSignerHandler {
fun isAvailable(): Boolean
fun setPackageName(packageName: String)
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
suspend fun signEvent(event: UnsignedEvent, currentUser: 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?
}
@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(
val pubkey: String,
val pubkey: PublicKey,
val packageName: String,
)

View File

@@ -7,7 +7,6 @@ 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 {

View File

@@ -372,33 +372,6 @@ class NostrViewModel(
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? {
try {
// 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? {
try {
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() {
try {
val defaultRelays = nostr.getDefaultMsgRelayList()