fix(quartz): never let signer timeouts crash from auto-signing launches

An ignored external NIP-55 signer prompt surfaces as
SignerExceptions.TimedOutException. Relay auth (NIP-42) signs replies in a
fire-and-forget scope.launch whose host scope (e.g. viewModelScope) carries no
CoroutineExceptionHandler, so an uncaught timeout there reached the platform
default handler and crashed the app ("Could not sign: User didn't accept or
reject in time.").

Guard the launch in RelayAuthenticator so signing failures are swallowed and
logged (re-throwing only CancellationException). Apply the same guard to
NostrSignerRemote's incoming-bunker-response launch, which decrypts untrusted
relay data on a handler-less scope. Add RelayAuthenticatorTimeoutTest covering
the swallowed-timeout and happy-path-still-sends-AUTH cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RM8zAKJNE8aAQL5nUboso
This commit is contained in:
Claude
2026-06-19 21:21:28 +00:00
parent a99e67d8be
commit 889b620b44
3 changed files with 184 additions and 6 deletions
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -37,6 +38,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
interface IAuthStatus {
fun hasFinishedAuthentication(relay: NormalizedRelayUrl): Boolean
@@ -83,12 +85,26 @@ class RelayAuthenticator(
msg: AuthMessage,
) {
scope.launch {
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
signWithAllLoggedInUsers(ev).forEach { authEvent ->
// only send replies to new challenges to avoid infinite loop:
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
relay.sendIfConnected(AuthCmd(authEvent))
// Relay auth is automatic and not user-initiated. Signing can fail in
// benign, expected ways — e.g. an external NIP-55 signer prompt that the
// user ignores surfaces as SignerExceptions.TimedOutException. Those must
// never escape this fire-and-forget launch: the host's scope may not carry
// a CoroutineExceptionHandler (viewModelScope, rememberCoroutineScope, …),
// so an uncaught throwable here crashes the whole app. Swallow + log them.
try {
val ev = RelayAuthEvent.build(relay.url, msg.challenge)
signWithAllLoggedInUsers(ev).forEach { authEvent ->
// only send replies to new challenges to avoid infinite loop:
if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) {
relay.sendIfConnected(AuthCmd(authEvent))
}
}
} catch (e: CancellationException) {
throw e
} catch (e: SignerExceptions) {
Log.d("RelayAuthenticator") { "Could not sign auth for ${relay.url}: ${e.message}" }
} catch (e: Exception) {
Log.w("RelayAuthenticator", "Failed to authenticate with ${relay.url}", e)
}
}
}
@@ -42,11 +42,13 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NostrSignerRemote(
val signer: NostrSignerInternal,
@@ -80,7 +82,19 @@ class NostrSignerRemote(
) { event ->
if (event is NostrConnectEvent) {
scope.launch {
manager.newResponse(event)
// Incoming bunker responses come straight off the relay and are
// decrypted here on a fire-and-forget launch whose scope carries no
// CoroutineExceptionHandler. A malformed/hostile event (or a benign
// SignerExceptions from decryption) must not escape and crash the app.
try {
manager.newResponse(event)
} catch (e: CancellationException) {
throw e
} catch (e: SignerExceptions) {
Log.d("NostrSignerRemote") { "Could not decrypt bunker response ${event.id}: ${e.message}" }
} catch (e: Exception) {
Log.w("NostrSignerRemote", "Failed to process bunker response ${event.id}", e)
}
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.auth
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Relay auth is automatic — the relay sends the AUTH challenge and
* [RelayAuthenticator] signs a reply in a fire-and-forget `scope.launch`. With an
* external NIP-55 Android signer, the user can simply ignore the approval prompt,
* which surfaces as [SignerExceptions.TimedOutException] from the signing lambda.
*
* The host scope passed in by Amethyst (`viewModelScope` + SupervisorJob) carries
* no [CoroutineExceptionHandler], so before the fix an uncaught throwable from that
* launch reached the platform default handler and crashed the whole app
* (TimedOutException: "Could not sign: User didn't accept or reject in time.").
*
* These tests pin the contract: signing failures during auth must be swallowed,
* and the happy path must still send the AUTH reply.
*/
class RelayAuthenticatorTimeoutTest {
private class CapturingClient(
private val delegate: INostrClient = EmptyNostrClient(),
) : INostrClient by delegate {
@Volatile var captured: RelayConnectionListener? = null
override fun addConnectionListener(listener: RelayConnectionListener) {
captured = listener
}
}
private class FakeRelayClient(
override val url: NormalizedRelayUrl,
) : IRelayClient {
val sent = mutableListOf<Command>()
override fun connect() = Unit
override fun needsToReconnect() = false
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
override fun isConnected() = true
override fun sendOrConnectAndSync(cmd: Command) {
sent.add(cmd)
}
override fun sendIfConnected(cmd: Command) {
sent.add(cmd)
}
override fun disconnect() = Unit
}
@Test
fun signerTimeoutDuringAuthIsSwallowedAndDoesNotReachExceptionHandler() =
runBlocking {
var escaped: Throwable? = null
val handler = CoroutineExceptionHandler { _, t -> escaped = t }
// Unconfined runs the launched body eagerly so the assertion is deterministic.
val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob() + handler)
val client = CapturingClient()
RelayAuthenticator(
client = client,
scope = scope,
signWithAllLoggedInUsers = {
throw SignerExceptions.TimedOutException("User didn't accept or reject in time.")
},
)
val listener = client.captured ?: error("RelayAuthenticator did not register a listener")
val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/"))
listener.onConnecting(relay)
listener.onIncomingMessage(relay, "", AuthMessage("challenge-123"))
assertNull(escaped, "TimedOutException must not escape the auth launch")
assertTrue(relay.sent.isEmpty(), "No AUTH reply should be sent when signing fails")
}
@Test
fun successfulAuthStillSendsAuthCommand() =
runBlocking {
var escaped: Throwable? = null
val handler = CoroutineExceptionHandler { _, t -> escaped = t }
val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob() + handler)
val signer = NostrSignerInternal(KeyPair())
val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/"))
val client = CapturingClient()
RelayAuthenticator(
client = client,
scope = scope,
signWithAllLoggedInUsers = { template ->
listOf(RelayAuthEvent.create(relay.url, "challenge-123", signer))
},
)
val listener = client.captured ?: error("RelayAuthenticator did not register a listener")
listener.onConnecting(relay)
listener.onIncomingMessage(relay, "", AuthMessage("challenge-123"))
assertNull(escaped, "Happy path must not surface any exception")
assertTrue(
relay.sent.any { it is AuthCmd },
"A signed AUTH reply should be sent on the happy path",
)
}
}