Add per-relay permissions for kind 22242 client authentication (NIP-42)

Kind 22242 events contain a ["relay", "wss://..."] tag identifying which
relay is requesting authentication. This change makes permissions for these
events per-relay URL rather than global:

- DB: Add `relay` field to ApplicationPermissionsEntity (migration 16→17)
  with a new unique index on (pkKey, type, kind, relay). Non-22242
  permissions keep relay="" to maintain backward compatibility.

- DAO: Add getPermissionForRelay() and getWildcardRelayPermission() for
  relay-specific lookups, plus deletePermissions(key, type, kind, relay)
  and deletePermissionsForKind() helpers.

- AmberUtils: acceptOrRejectPermission() and acceptPermission() accept an
  optional relay parameter. Wildcard "*" clears all relay-specific entries
  for a kind; a specific URL only clears its own entry.

- UI: New BunkerRelayAuthScreen composable shows the relay URL prominently
  and presents a 2-option "Authentication scope" toggle:
  · "This relay only" — stores permission for the specific relay URL
  · "All relays" — stores permission with wildcard "*"
  Both options still include the RememberMyChoice time-based memory control.

- BunkerSingleEventHomeScreen & IntentSingleEventHomeScreen: detect kind
  22242 requests and show BunkerRelayAuthScreen instead of the generic
  BunkerEventData. Permission lookup checks specific relay first, then the
  "*" wildcard fallback.

- SignerProvider (ContentProvider path): kind 22242 checks relay-specific
  permission first, then wildcard, before auto-approving.

- AmberToggles: add optional segmentWidth parameter (default 55.dp) so
  the relay scope toggle can use wider 120dp segments.

https://claude.ai/code/session_01Dm9zoUbbzdJzuXTNdAVhfW
This commit is contained in:
Claude
2026-03-08 00:24:53 +00:00
parent 1124f6d558
commit 0c81e17964
12 changed files with 402 additions and 10 deletions
@@ -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,
)
}
@@ -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)
@@ -0,0 +1,149 @@
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.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 {
@@ -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
@@ -643,4 +643,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>