Merge pull request #207 from barrydeen/fix/nip42-auth-trusted-relays

fix: restrict NIP-42 AUTH to trusted relays only
This commit is contained in:
Barry Deen
2026-03-10 21:56:52 -04:00
committed by GitHub
7 changed files with 147 additions and 10 deletions
@@ -45,6 +45,7 @@ import androidx.compose.runtime.rememberUpdatedState
import com.wisp.app.ui.component.NotifBlipSound
import com.wisp.app.ui.component.WispBottomBar
import com.wisp.app.ui.component.ZapDialog
import com.wisp.app.ui.component.AuthApprovalDialog
import com.wisp.app.ui.screen.BlossomServersScreen
import com.wisp.app.ui.screen.AuthScreen
import com.wisp.app.ui.screen.ComposeScreen
@@ -417,6 +418,16 @@ fun WispNavHost(
)
}
// NIP-42 AUTH approval dialog — shown when a DM delivery relay requests authentication
val pendingAuth by feedViewModel.relayPool.pendingAuthRequest.collectAsState()
pendingAuth?.let { request ->
AuthApprovalDialog(
relayUrl = request.relayUrl,
onAllow = { feedViewModel.relayPool.approveAuth(request) },
onDeny = { feedViewModel.relayPool.denyAuth(request) }
)
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
@@ -13,7 +13,8 @@ enum class RelaySetType(val displayName: String, val eventKind: Int) {
data class RelayConfig(
val url: String,
val read: Boolean = true,
val write: Boolean = true
val write: Boolean = true,
val auth: Boolean = true
) {
companion object {
val DEFAULTS = listOf(
@@ -39,6 +39,46 @@ class RelayPool {
private var blockedUrls = emptySet<String>()
fun getBlockedUrls(): Set<String> = blockedUrls
/** URLs tagged as recipient DM delivery relays (tier 2 for AUTH). */
private val dmDeliveryTargets: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
/** Mark a relay URL as a DM delivery target (tier 2 AUTH). */
fun markDmDeliveryTarget(url: String) { dmDeliveryTargets.add(url) }
/** Session-scoped set of relay URLs the user has approved for AUTH. */
private val userApprovedAuthRelays: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
data class PendingAuthRequest(val relayUrl: String, val challenge: String)
private val _pendingAuthRequest = MutableStateFlow<PendingAuthRequest?>(null)
/** Emitted when a tier-2 (DM delivery) relay needs user approval to authenticate. */
val pendingAuthRequest: StateFlow<PendingAuthRequest?> = _pendingAuthRequest
/** Approve a pending AUTH request — signs and sends AUTH, remembers for session. */
fun approveAuth(request: PendingAuthRequest) {
userApprovedAuthRelays.add(request.relayUrl)
_pendingAuthRequest.value = null
scope.launch {
val signer = authSigner ?: return@launch
try {
val relay = relayIndex[request.relayUrl] ?: return@launch
val authEvent = signer(request.relayUrl, request.challenge)
relay.send(ClientMessage.auth(authEvent))
authenticatedRelays.add(request.relayUrl)
Log.d("RelayPool", "AUTH approved and sent to ${request.relayUrl}")
_authCompleted.tryEmit(request.relayUrl)
} catch (e: Exception) {
Log.e("RelayPool", "AUTH failed after approval for ${request.relayUrl}: ${e.message}")
}
}
}
/** Deny a pending AUTH request. */
fun denyAuth(request: PendingAuthRequest) {
_pendingAuthRequest.value = null
Log.d("RelayPool", "AUTH denied for ${request.relayUrl}")
}
/**
* Ensure the OkHttpClient matches the current Tor state.
* If Tor was enabled/disabled since the client was built, rebuild it.
@@ -383,16 +423,48 @@ class RelayPool {
scope.launch(parentJob) {
relay.authChallenges.collect { challenge ->
val signer = authSigner ?: return@collect
try {
val authEvent = signer(relay.config.url, challenge)
val msg = ClientMessage.auth(authEvent)
relay.send(msg)
authenticatedRelays.add(relay.config.url)
Log.d("RelayPool", "AUTH response sent to ${relay.config.url}")
_authCompleted.tryEmit(relay.config.url)
} catch (e: Exception) {
Log.e("RelayPool", "AUTH failed for ${relay.config.url}: ${e.message}")
val url = relay.config.url
val dmRelayUrls = dmRelays.map { it.config.url }.toSet()
// Tier 1: User's own relays — auto-sign silently (if auth enabled on config)
if (url in pinnedRelayUrls || url in dmRelayUrls) {
if (!relay.config.auth) {
Log.d("RelayPool", "AUTH challenge discarded — auth disabled for relay $url")
return@collect
}
try {
val authEvent = signer(url, challenge)
relay.send(ClientMessage.auth(authEvent))
authenticatedRelays.add(url)
Log.d("RelayPool", "AUTH auto-signed for trusted relay $url")
_authCompleted.tryEmit(url)
} catch (e: Exception) {
Log.e("RelayPool", "AUTH failed for trusted relay $url: ${e.message}")
}
return@collect
}
// Tier 2: Recipient DM delivery relays — prompt user (or auto-sign if already approved)
if (url in dmDeliveryTargets) {
if (url in userApprovedAuthRelays) {
try {
val authEvent = signer(url, challenge)
relay.send(ClientMessage.auth(authEvent))
authenticatedRelays.add(url)
Log.d("RelayPool", "AUTH auto-signed for approved DM delivery relay $url")
_authCompleted.tryEmit(url)
} catch (e: Exception) {
Log.e("RelayPool", "AUTH failed for approved DM delivery relay $url: ${e.message}")
}
} else {
Log.d("RelayPool", "AUTH challenge from DM delivery relay $url — prompting user")
_pendingAuthRequest.value = PendingAuthRequest(url, challenge)
}
return@collect
}
// Tier 3: Everything else — silently discard
Log.d("RelayPool", "AUTH challenge discarded from untrusted relay $url")
}
}
}
@@ -0,0 +1,37 @@
package com.wisp.app.ui.component
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@Composable
fun AuthApprovalDialog(
relayUrl: String,
onAllow: () -> Unit,
onDeny: () -> Unit
) {
AlertDialog(
onDismissRequest = onDeny,
title = { Text("Relay Authentication") },
text = {
Text(
text = "The relay $relayUrl is requesting authentication. " +
"This is a DM delivery relay for a recipient. " +
"Authenticating will reveal your identity to the relay operator.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = onAllow) {
Text("Allow")
}
},
dismissButton = {
TextButton(onClick = onDeny) {
Text("Deny")
}
}
)
}
@@ -168,6 +168,11 @@ private fun GeneralRelayList(relays: List<RelayConfig>, viewModel: RelayViewMode
onClick = { viewModel.toggleWrite(relay.url) },
label = { Text("write") }
)
FilterChip(
selected = relay.auth,
onClick = { viewModel.toggleAuth(relay.url) },
label = { Text("auth") }
)
}
}
IconButton(onClick = { viewModel.removeRelay(relay.url) }) {
@@ -351,6 +351,10 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
deliveryRelays: List<String>,
message: String
): Set<String> {
// Tag each delivery relay so RelayPool knows it's tier 2 for AUTH
for (url in deliveryRelays) {
relayPool.markDmDeliveryTarget(url)
}
val sentRelayUrls = mutableSetOf<String>()
for (url in deliveryRelays) {
try {
@@ -107,6 +107,13 @@ class RelayViewModel(app: Application) : AndroidViewModel(app) {
keyRepo.saveRelays(updated)
}
fun toggleAuth(url: String) {
val updated = relays.value.map {
if (it.url == url) it.copy(auth = !it.auth) else it
}
keyRepo.saveRelays(updated)
}
fun publishRelayList(relayPool: RelayPool, signer: NostrSigner? = null): Boolean {
val s = signer ?: keyRepo.getKeypair()?.let { LocalSigner(it.privkey, it.pubkey) } ?: return false
return try {