Merge pull request #317 from greenart7c3/claude/per-relay-auth-permissions-9KU65

Add NIP-42 relay authentication (kind 22242) support
This commit is contained in:
greenart7c3
2026-03-09 09:59:20 -03:00
committed by GitHub
17 changed files with 744 additions and 13 deletions
@@ -0,0 +1,229 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "cc242d2c391a6dec67e7613458bffd30",
"entities": [
{
"tableName": "application",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `relays` TEXT NOT NULL, `url` TEXT NOT NULL, `icon` TEXT NOT NULL, `description` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `isConnected` INTEGER NOT NULL, `secret` TEXT NOT NULL, `useSecret` INTEGER NOT NULL, `signPolicy` INTEGER NOT NULL, `closeApplication` INTEGER NOT NULL, `deleteAfter` INTEGER NOT NULL, `lastUsed` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "relays",
"columnName": "relays",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pubKey",
"columnName": "pubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isConnected",
"columnName": "isConnected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useSecret",
"columnName": "useSecret",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signPolicy",
"columnName": "signPolicy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "closeApplication",
"columnName": "closeApplication",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfter",
"columnName": "deleteAfter",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [
{
"name": "index_key",
"unique": true,
"columnNames": [
"key"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_key` ON `${TABLE_NAME}` (`key`)"
},
{
"name": "index_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "applicationPermission",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `acceptable` INTEGER NOT NULL, `rememberType` INTEGER NOT NULL, `acceptUntil` INTEGER NOT NULL, `rejectUntil` INTEGER NOT NULL, `relay` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER"
},
{
"fieldPath": "acceptable",
"columnName": "acceptable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rememberType",
"columnName": "rememberType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "acceptUntil",
"columnName": "acceptUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rejectUntil",
"columnName": "rejectUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relay",
"columnName": "relay",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "permissions_by_pk_key",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `permissions_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "permissions_unique",
"unique": true,
"columnNames": [
"pkKey",
"type",
"kind",
"relay"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `permissions_unique` ON `${TABLE_NAME}` (`pkKey`, `type`, `kind`, `relay`)"
}
],
"foreignKeys": [
{
"table": "application",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"pkKey"
],
"referencedColumns": [
"key"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cc242d2c391a6dec67e7613458bffd30')"
]
}
}
@@ -12,6 +12,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
@@ -175,7 +176,12 @@ class SignerProvider : ContentProvider() {
val database = Amber.instance.getDatabase(account.npub)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub)
var permission =
var permission = if (event.kind == 22242) {
// Kind 22242 = relay client auth (NIP-42): check relay-specific permission first
val relayUrl = AmberEvent.relay(event) ?: ""
database.dao().getPermissionForRelay(packageName, "SIGN_EVENT", 22242, relayUrl)
?: database.dao().getWildcardRelayPermission(packageName, "SIGN_EVENT", 22242)
} else {
database
.dao()
.getPermission(
@@ -183,7 +189,8 @@ class SignerProvider : ContentProvider() {
"SIGN_EVENT",
event.kind,
)
if (permission == null) {
}
if (permission == null && event.kind != 22242) {
event.kind.kindToNip()?.let {
val nipNumber = it.toIntOrNull()
permission = if (nipNumber == null) {
@@ -148,12 +148,20 @@ val MIGRATION_15_16 = object : Migration(15, 16) {
}
}
val MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `applicationPermission` ADD COLUMN `relay` TEXT NOT NULL DEFAULT ''")
db.execSQL("DROP INDEX IF EXISTS `permissions_unique`")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `permissions_unique` ON `applicationPermission` (`pkKey`, `type`, `kind`, `relay`)")
}
}
@Database(
entities = [
ApplicationEntity::class,
ApplicationPermissionsEntity::class,
],
version = 16,
version = 17,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
@@ -190,6 +198,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_13_14)
.addMigrations(MIGRATION_14_15)
.addMigrations(MIGRATION_15_16)
.addMigrations(MIGRATION_16_17)
.build()
instance
@@ -59,7 +59,7 @@ interface ApplicationDao {
@Transaction
fun getAllAcceptedPermissions(): List<ApplicationPermissionsEntity>
@Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind")
@Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = ''")
fun getPermission(
key: String,
type: String,
@@ -72,6 +72,21 @@ interface ApplicationDao {
type: String,
): ApplicationPermissionsEntity?
@Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = :relay LIMIT 1")
fun getPermissionForRelay(
key: String,
type: String,
kind: Int,
relay: String,
): ApplicationPermissionsEntity?
@Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = '*' LIMIT 1")
fun getWildcardRelayPermission(
key: String,
type: String,
kind: Int,
): ApplicationPermissionsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Transaction
suspend fun insertApplication(event: ApplicationEntity): Long?
@@ -84,7 +99,17 @@ interface ApplicationDao {
suspend fun insertPermissions(permissions: List<ApplicationPermissionsEntity>): List<Long>? {
permissions.forEach {
if (it.kind != null) {
deletePermissions(it.pkKey, it.type, it.kind)
if (it.relay.isNotEmpty()) {
// For relay-specific permissions (kind 22242): wildcard "*" clears all relay entries,
// specific relay only clears its own entry
if (it.relay == "*") {
deletePermissionsForKind(it.pkKey, it.type, it.kind)
} else {
deletePermissions(it.pkKey, it.type, it.kind, it.relay)
}
} else {
deletePermissions(it.pkKey, it.type, it.kind)
}
} else {
deletePermissions(it.pkKey, it.type)
}
@@ -107,7 +132,7 @@ interface ApplicationDao {
type: String,
)
@Query("DELETE FROM applicationPermission WHERE pkKey = :key AND type = :type and kind = :kind")
@Query("DELETE FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = ''")
@Transaction
suspend fun deletePermissions(
key: String,
@@ -115,6 +140,23 @@ interface ApplicationDao {
kind: Int,
)
@Query("DELETE FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = :relay")
@Transaction
suspend fun deletePermissions(
key: String,
type: String,
kind: Int,
relay: String,
)
@Query("DELETE FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind")
@Transaction
suspend fun deletePermissionsForKind(
key: String,
type: String,
kind: Int,
)
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
suspend fun insertApplicationWithPermissions(application: ApplicationWithPermissions) {
@@ -21,7 +21,7 @@ import androidx.room.PrimaryKey
name = "permissions_by_pk_key",
),
Index(
value = ["pkKey", "type", "kind"],
value = ["pkKey", "type", "kind", "relay"],
name = "permissions_unique",
unique = true,
),
@@ -37,4 +37,5 @@ data class ApplicationPermissionsEntity(
val rememberType: Int,
var acceptUntil: Long,
var rejectUntil: Long,
val relay: String = "",
)
@@ -81,6 +81,7 @@ object AmberUtils {
value: Boolean,
rememberType: RememberType,
account: Account,
relay: String = "",
) {
val until = when (rememberType) {
RememberType.ALWAYS -> Long.MAX_VALUE / 1000
@@ -91,7 +92,17 @@ object AmberUtils {
}
if (kind != null) {
application.permissions.removeIf { it.kind == kind && it.type == signerType.toString() }
if (relay.isNotEmpty()) {
// For relay-specific permissions: wildcard "*" removes all relay entries for this kind,
// specific relay removes only its own entry
if (relay == "*") {
application.permissions.removeIf { it.kind == kind && it.type == signerType.toString() }
} else {
application.permissions.removeIf { it.kind == kind && it.type == signerType.toString() && it.relay == relay }
}
} else {
application.permissions.removeIf { it.kind == kind && it.type == signerType.toString() && it.relay.isEmpty() }
}
} else {
application.permissions.removeIf { it.type == signerType.toString() && it.type != "SIGN_EVENT" }
}
@@ -106,6 +117,7 @@ object AmberUtils {
rememberType.screenCode,
if (value) until else 0L,
if (!value) until else 0L,
relay,
),
)
@@ -173,6 +185,7 @@ object AmberUtils {
type: SignerType,
kind: Int?,
rememberType: RememberType,
relay: String = "",
) {
val until = when (rememberType) {
RememberType.ALWAYS -> Long.MAX_VALUE / 1000
@@ -183,7 +196,15 @@ object AmberUtils {
}
if (kind != null) {
application.permissions.removeIf { it.kind == kind && it.type == type.toString() }
if (relay.isNotEmpty()) {
if (relay == "*") {
application.permissions.removeIf { it.kind == kind && it.type == type.toString() }
} else {
application.permissions.removeIf { it.kind == kind && it.type == type.toString() && it.relay == relay }
}
} else {
application.permissions.removeIf { it.kind == kind && it.type == type.toString() && it.relay.isEmpty() }
}
} else {
application.permissions.removeIf { it.type == type.toString() && it.type != "SIGN_EVENT" }
}
@@ -198,6 +219,7 @@ object AmberUtils {
rememberType.screenCode,
until,
0,
relay,
),
)
}
@@ -240,6 +240,7 @@ object BunkerRequestUtils {
rememberType: RememberType,
oldKey: String = "",
deleteAfter: Long = 0L,
relay: String = "",
) {
onLoading(true)
Amber.instance.applicationIOScope.launch {
@@ -319,6 +320,7 @@ object BunkerRequestUtils {
type = type,
kind = kind,
rememberType = rememberType,
relay = relay,
)
}
@@ -434,6 +436,7 @@ object BunkerRequestUtils {
signerType: SignerType,
kind: Int?,
onLoading: (Boolean) -> Unit,
relay: String = "",
) {
onLoading(true)
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
@@ -481,6 +484,7 @@ object BunkerRequestUtils {
false,
rememberType,
account,
relay,
)
}
@@ -681,6 +681,7 @@ object IntentUtils {
shouldCloseApplication: Boolean? = null,
rememberType: RememberType,
deleteAfter: Long = 0L,
relay: String = "",
) {
onLoading(true)
Amber.instance.applicationIOScope.launch {
@@ -732,6 +733,7 @@ object IntentUtils {
type = intentData.type,
kind = kind,
rememberType = rememberType,
relay = relay,
)
}
@@ -864,6 +866,7 @@ object IntentUtils {
kind: Int?,
onLoading: (Boolean) -> Unit,
onRemoveIntentData: (List<IntentData>, IntentResultType) -> Unit,
relay: String = "",
) {
Amber.instance.applicationIOScope.launch(Dispatchers.IO) {
if (key == "null") {
@@ -901,6 +904,7 @@ object IntentUtils {
false,
rememberType,
account,
relay,
)
}
@@ -17,6 +17,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.jackson.toTypedArray
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -102,5 +103,7 @@ open class AmberEvent(
fun fromJson(json: String): AmberEvent = mapper.readValue(json, AmberEvent::class.java)
fun relay(event: Event): String? = event.tags.filter { it.size > 1 && it[0] == "relay" }.map { it[1] }.firstOrNull()
fun relay(event: EventTemplate<Event>): String? = event.tags.filter { it.size > 1 && it[0] == "relay" }.map { it[1] }.firstOrNull()
}
}
@@ -294,7 +294,7 @@ fun PermissionRow(
onDelete: (ApplicationPermissionsEntity) -> Unit,
) {
val context = LocalContext.current
val message = remember(permission.type, permission.kind, permission.acceptable) {
val message = remember(permission.type, permission.kind, permission.acceptable, permission.relay) {
val localPermission = Permission(
permission.type.toLowerCase(Locale.current),
permission.kind,
@@ -344,6 +344,22 @@ fun PermissionRow(
overflow = TextOverflow.Ellipsis,
)
if (permission.kind == 22242 && permission.relay.isNotEmpty()) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
text = if (permission.relay == "*") {
context.getString(R.string.for_all_relays)
} else {
permission.relay
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AmberToggles(
count = 3,
selectedIndex = optionIndex,
@@ -20,15 +20,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun AmberToggles(
selectedIndex: Int,
count: Int,
segmentWidth: Dp = 55.dp,
content: @Composable RowScope.() -> Unit,
) {
val fixedSegmentWidth = 55.dp
val fixedSegmentWidth = segmentWidth
val padding = 2.dp
val totalWidth = (fixedSegmentWidth * count) + (padding * 2)
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
@@ -38,6 +39,7 @@ import com.greenart7c3.nostrsigner.service.ApplicationNameCache
import com.greenart7c3.nostrsigner.service.BunkerRequestUtils
import com.greenart7c3.nostrsigner.service.EventNotificationConsumer
import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.navigation.Route
@@ -103,6 +105,8 @@ fun BunkerMultiEventHomeScreen(
var localAccount by remember { mutableStateOf("") }
val key = bunkerRequests.first().localKey
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var relayAuthScope by remember { mutableStateOf(RelayAuthScope.SPECIFIC) }
val hasRelayAuthEvents = groupedEvents.keys.contains(22242)
var appName by remember { mutableStateOf(ApplicationNameCache.names["$localAccount-$key"] ?: key.toShortenHex()) }
LaunchedEffect(Unit) {
@@ -223,6 +227,34 @@ fun BunkerMultiEventHomeScreen(
}
}
if (hasRelayAuthEvents) {
LabeledBorderBox(
label = stringResource(R.string.relay_auth_scope),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
) {
AmberToggles(
selectedIndex = if (relayAuthScope == RelayAuthScope.SPECIFIC) 0 else 1,
count = 2,
segmentWidth = 120.dp,
) {
ToggleOption(
modifier = Modifier.width(120.dp),
text = stringResource(R.string.for_this_relay_only),
isSelected = relayAuthScope == RelayAuthScope.SPECIFIC,
onClick = { relayAuthScope = RelayAuthScope.SPECIFIC },
)
ToggleOption(
modifier = Modifier.width(120.dp),
text = stringResource(R.string.for_all_relays),
isSelected = relayAuthScope == RelayAuthScope.ALL,
onClick = { relayAuthScope = RelayAuthScope.ALL },
)
}
}
}
RememberMyChoice(
alwaysShow = true,
shouldRunAcceptOrReject = null,
@@ -294,14 +326,21 @@ fun BunkerMultiEventHomeScreen(
)
if (request.rememberType.value != RememberType.NEVER && request.checked.value) {
val rejectKind = if (request.request is BunkerRequestSign) request.request.event.kind else null
val rejectRelay = if (request.request is BunkerRequestSign && request.request.event.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) "*" else (AmberEvent.relay(request.request.event) ?: "")
} else {
""
}
AmberUtils.acceptOrRejectPermission(
application,
localKey,
BunkerRequestUtils.getTypeFromBunker(request.request),
null,
rejectKind,
false,
request.rememberType.value,
thisAccount,
relay = rejectRelay,
)
}
@@ -376,6 +415,11 @@ fun BunkerMultiEventHomeScreen(
val localEvent = request.signedEvent!!
if (request.rememberType.value != RememberType.NEVER && request.checked.value) {
val signRelay = if (localEvent.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) "*" else (AmberEvent.relay(localEvent) ?: "")
} else {
""
}
AmberUtils.acceptOrRejectPermission(
application = application,
key = localKey,
@@ -384,6 +428,7 @@ fun BunkerMultiEventHomeScreen(
value = true,
rememberType = request.rememberType.value,
account = thisAccount,
relay = signRelay,
)
}
@@ -0,0 +1,150 @@
package com.greenart7c3.nostrsigner.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.ui.RememberType
/**
* Scope for a kind 22242 relay authentication permission.
* [SPECIFIC] means the permission applies only to the specific relay URL in the event.
* [ALL] means the permission applies to all relays (wildcard "*").
*/
enum class RelayAuthScope {
SPECIFIC,
ALL,
}
@Composable
fun BunkerRelayAuthScreen(
modifier: Modifier,
appName: String,
relayUrl: String,
shouldAcceptOrReject: Boolean?,
defaultScope: RelayAuthScope,
account: Account,
onAccept: (RememberType, RelayAuthScope) -> Unit,
onReject: (RememberType, RelayAuthScope) -> Unit,
) {
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var scope by remember { mutableStateOf(defaultScope) }
val scopeIndex by remember(scope) { mutableIntStateOf(if (scope == RelayAuthScope.SPECIFIC) 0 else 1) }
if (shouldAcceptOrReject != null) {
LaunchedEffect(Unit) {
if (shouldAcceptOrReject) {
onAccept(RememberType.entries[0], scope)
} else {
onReject(RememberType.entries[0], scope)
}
}
}
Column(
modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.size(16.dp))
Text(
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(appName)
}
append(" ")
append(stringResource(R.string.relay_auth_request, ""))
},
fontSize = 18.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.size(8.dp))
Text(
relayUrl,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(Modifier.size(16.dp))
SigningAs(account)
Spacer(modifier = Modifier.weight(1f))
LabeledBorderBox(
label = stringResource(R.string.relay_auth_scope),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
AmberToggles(
selectedIndex = scopeIndex,
count = 2,
segmentWidth = 120.dp,
content = {
ToggleOption(
modifier = Modifier.width(120.dp),
text = stringResource(R.string.for_this_relay_only),
isSelected = scope == RelayAuthScope.SPECIFIC,
onClick = { scope = RelayAuthScope.SPECIFIC },
)
ToggleOption(
modifier = Modifier.width(120.dp),
text = stringResource(R.string.for_all_relays),
isSelected = scope == RelayAuthScope.ALL,
onClick = { scope = RelayAuthScope.ALL },
)
},
)
}
Spacer(Modifier.size(8.dp))
RememberMyChoice(
shouldRunAcceptOrReject = null,
packageName = null,
alwaysShow = true,
onAccept = { onAccept(it, scope) },
onReject = { onReject(it, scope) },
) {
rememberType = it
}
AcceptRejectButtons(
onAccept = { onAccept(rememberType, scope) },
onReject = { onReject(rememberType, scope) },
)
}
}
@@ -35,6 +35,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.service.BunkerRequestUtils
import com.greenart7c3.nostrsigner.service.isPrivateEvent
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.RememberType
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -616,6 +617,82 @@ fun BunkerSingleEventHomeScreen(
Spacer(Modifier.size(8.dp))
Text("Not logged in")
}
} else if (event.kind == 22242) {
// Kind 22242 = relay client authentication (NIP-42)
// Permission is per-relay URL extracted from the event's "relay" tag
val relayUrl = AmberEvent.relay(event) ?: ""
// Check for a relay-specific permission first, then wildcard "*" (all relays)
val permission = applicationEntity?.permissions?.firstOrNull {
it.pkKey == key && it.type == type.toString() && it.kind == 22242 && it.relay == relayUrl
} ?: applicationEntity?.permissions?.firstOrNull {
it.pkKey == key && it.type == type.toString() && it.kind == 22242 && it.relay == "*"
}
val acceptUntil = permission?.acceptUntil ?: 0
val rejectUntil = permission?.rejectUntil ?: 0
val acceptOrReject = if (rejectUntil == 0L && acceptUntil == 0L) {
null
} else if (rejectUntil > TimeUtils.now() && rejectUntil > 0 && permission?.acceptable == false) {
false
} else if (acceptUntil > TimeUtils.now() && acceptUntil > 0 && permission?.acceptable == true) {
true
} else {
null
}
BunkerRelayAuthScreen(
modifier = modifier,
appName = appName,
relayUrl = relayUrl,
shouldAcceptOrReject = acceptOrReject,
defaultScope = RelayAuthScope.SPECIFIC,
account = account,
onAccept = { rememberType, scope ->
if (event.pubKey != account.hexKey && !isPrivateEvent(event.kind, event.tags)) {
coroutineScope.launch {
Toast.makeText(
context,
context.getString(R.string.event_pubkey_is_not_equal_to_current_logged_in_user),
Toast.LENGTH_SHORT,
).show()
}
return@BunkerRelayAuthScreen
}
val relayPermission = if (scope == RelayAuthScope.ALL) "*" else relayUrl
BunkerRequestUtils.sendResult(
context = context,
account = account,
key = key,
response = event.toJson(),
bunkerRequest = bunkerRequest,
kind = event.kind,
onLoading = onLoading,
permissions = null,
appName = appName,
signPolicy = null,
shouldCloseApplication = bunkerRequest.closeApplication,
rememberType = rememberType,
relay = relayPermission,
)
},
onReject = { rememberType, scope ->
val relayPermission = if (scope == RelayAuthScope.ALL) "*" else relayUrl
BunkerRequestUtils.sendRejection(
key = key,
account = account,
bunkerRequest = bunkerRequest,
appName = appName,
rememberType = rememberType,
signerType = type,
kind = event.kind,
onLoading = onLoading,
relay = relayPermission,
)
},
)
} else {
val permission =
applicationEntity?.permissions?.firstOrNull {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
@@ -41,6 +42,7 @@ import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
import com.greenart7c3.nostrsigner.service.AmberUtils
import com.greenart7c3.nostrsigner.service.ApplicationNameCache
import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.navigation.Route
@@ -105,6 +107,8 @@ fun IntentMultiEventHomeScreen(
var localAccount by remember { mutableStateOf("") }
val key = "$packageName"
var rememberType by remember { mutableStateOf(RememberType.NEVER) }
var relayAuthScope by remember { mutableStateOf(RelayAuthScope.SPECIFIC) }
val hasRelayAuthEvents = groupedEvents.keys.contains(22242)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
@@ -232,6 +236,34 @@ fun IntentMultiEventHomeScreen(
}
}
if (hasRelayAuthEvents) {
LabeledBorderBox(
label = stringResource(R.string.relay_auth_scope),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 8.dp),
) {
AmberToggles(
selectedIndex = if (relayAuthScope == RelayAuthScope.SPECIFIC) 0 else 1,
count = 2,
segmentWidth = 120.dp,
) {
ToggleOption(
modifier = Modifier.width(120.dp),
text = stringResource(R.string.for_this_relay_only),
isSelected = relayAuthScope == RelayAuthScope.SPECIFIC,
onClick = { relayAuthScope = RelayAuthScope.SPECIFIC },
)
ToggleOption(
modifier = Modifier.width(120.dp),
text = stringResource(R.string.for_all_relays),
isSelected = relayAuthScope == RelayAuthScope.ALL,
onClick = { relayAuthScope = RelayAuthScope.ALL },
)
}
}
}
RememberMyChoice(
alwaysShow = true,
shouldRunAcceptOrReject = null,
@@ -298,14 +330,21 @@ fun IntentMultiEventHomeScreen(
)
if (intentData.rememberType.value != RememberType.NEVER && intentData.checked.value) {
val rejectKind = if (intentData.type == SignerType.SIGN_EVENT) intentData.event?.kind else null
val rejectRelay = if (intentData.type == SignerType.SIGN_EVENT && intentData.event?.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) "*" else (intentData.event?.let { AmberEvent.relay(it) } ?: "")
} else {
""
}
AmberUtils.acceptOrRejectPermission(
application,
localKey,
intentData.type,
null,
rejectKind,
false,
intentData.rememberType.value,
thisAccount,
relay = rejectRelay,
)
}
@@ -388,6 +427,11 @@ fun IntentMultiEventHomeScreen(
val localEvent = intentData.event!!
if (intentData.rememberType.value != RememberType.NEVER && intentData.checked.value) {
val signRelay = if (localEvent.kind == 22242) {
if (relayAuthScope == RelayAuthScope.ALL) "*" else (AmberEvent.relay(localEvent) ?: "")
} else {
""
}
AmberUtils.acceptOrRejectPermission(
application,
localKey,
@@ -396,6 +440,7 @@ fun IntentMultiEventHomeScreen(
true,
intentData.rememberType.value,
thisAccount,
relay = signRelay,
)
}
@@ -30,6 +30,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.isPrivateEvent
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.ToastManager
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -242,6 +243,75 @@ fun IntentSingleEventHomeScreen(
Spacer(Modifier.size(8.dp))
Text("Not logged in")
}
} else if (event.kind == 22242) {
// Kind 22242 = relay client authentication (NIP-42)
// Permission is per-relay URL extracted from the event's "relay" tag
val relayUrl = AmberEvent.relay(event) ?: ""
// Check for relay-specific permission first, then wildcard "*" (all relays)
val permission = applicationEntity?.permissions?.firstOrNull {
it.pkKey == key && it.type == intentData.type.toString() && it.kind == 22242 && it.relay == relayUrl
} ?: applicationEntity?.permissions?.firstOrNull {
it.pkKey == key && it.type == intentData.type.toString() && it.kind == 22242 && it.relay == "*"
}
val acceptOrReject = if (permission == null) {
IntentUtils.isRemembered(applicationEntity?.application?.signPolicy, null)
} else {
IntentUtils.isRemembered(applicationEntity?.application?.signPolicy, permission)
}
BunkerRelayAuthScreen(
modifier = modifier,
appName = appName,
relayUrl = relayUrl,
shouldAcceptOrReject = acceptOrReject,
defaultScope = RelayAuthScope.SPECIFIC,
account = account,
onAccept = { rememberType, scope ->
if (intentData.unsignedEventKey.isNotBlank() && intentData.unsignedEventKey != account.hexKey && !isPrivateEvent(event.kind, event.tags)) {
ToastManager.toast(
title = context.getString(R.string.warning),
message = context.getString(R.string.event_pubkey_is_not_equal_to_current_logged_in_user),
)
return@BunkerRelayAuthScreen
}
val relayPermission = if (scope == RelayAuthScope.ALL) "*" else relayUrl
IntentUtils.sendResult(
context = context,
packageName = packageName,
account = account,
key = key,
clipboardManager = clipboardManager,
event = event.toJson(),
value = event.sig,
intentData = intentData,
kind = event.kind,
onLoading = onLoading,
onRemoveIntentData = onRemoveIntentData,
signPolicy = null,
appName = applicationName ?: appName,
permissions = null,
rememberType = rememberType,
relay = relayPermission,
)
},
onReject = { rememberType, scope ->
val relayPermission = if (scope == RelayAuthScope.ALL) "*" else relayUrl
IntentUtils.sendRejection(
key = key,
account = account,
intentData = intentData,
appName = appName,
rememberType = rememberType,
onLoading = onLoading,
onRemoveIntentData = onRemoveIntentData,
kind = event.kind,
relay = relayPermission,
)
},
)
} else {
val permission =
applicationEntity?.permissions?.firstOrNull {
+5
View File
@@ -645,4 +645,9 @@
<string name="account_picture">Account picture</string>
<string name="encrypt_and_copy_to_clipboard">Encrypt and copy to clipboard</string>
<string name="encrypt_and_show_qr_code">Encrypt and show QR Code</string>
<string name="relay_authentication">Relay authentication</string>
<string name="relay_auth_request">%1$s wants to authenticate to relay</string>
<string name="for_this_relay_only">This relay only</string>
<string name="for_all_relays">All relays</string>
<string name="relay_auth_scope">Authentication scope</string>
</resources>