Merge pull request #3346 from greenart7c3/claude/nip-2381-bunker-connect-psiq2t

NIP-46: Add optional client metadata to connect requests
This commit is contained in:
Vitor Pamplona
2026-06-24 08:58:28 -04:00
committed by GitHub
5 changed files with 179 additions and 5 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.domain.nip46
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerClientMetadata
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
@@ -33,8 +34,9 @@ object BunkerLoginUseCase {
bunkerUri: String,
ephemeralSigner: NostrSignerInternal,
client: INostrClient,
clientMetadata: BunkerClientMetadata? = null,
): BunkerLoginResult {
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client, clientMetadata = clientMetadata)
remoteSigner.openSubscription()
// Wait for websocket to be ready before sending connect request
@@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerClientMetadata
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -105,6 +106,14 @@ class AccountManager internal constructor(
internal const val LEGACY_BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
// Advertised on the NIP-46 `connect` request so a bunker:// signer can
// show who is asking to connect (NIP-46 client metadata).
internal val NIP46_CLIENT_METADATA =
BunkerClientMetadata(
name = "Amethyst Desktop",
url = "https://amethyst.social",
)
}
private val amethystDir: File by lazy {
@@ -324,7 +333,7 @@ class AccountManager internal constructor(
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val nip46Client = getOrCreateNip46Client()
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client, clientMetadata = NIP46_CLIENT_METADATA)
remoteSigner.openSubscription()
val pubKeyHex =
@@ -377,7 +386,7 @@ class AccountManager internal constructor(
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
)
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client)
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client, clientMetadata = NIP46_CLIENT_METADATA)
val state =
AccountState.LoggedIn(
@@ -21,23 +21,86 @@
package com.vitorpamplona.quartz.nip46RemoteSigner
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Optional client metadata a client MAY attach to a NIP-46 `connect` request
* (the 4th, optional `connect` parameter — https://github.com/nostr-protocol/nips/pull/2381).
*
* It mirrors the `name`/`url`/`image` fields already carried by `nostrconnect://`
* URIs so a `bunker://`-paired signer can also show who is asking to connect.
*
* Display-only: in the `bunker://` pairing flow the client's pubkey is not
* authenticated, so a signer MUST treat these fields as a UI hint, never as an
* authorization mechanism.
*/
@Serializable
data class BunkerClientMetadata(
val name: String? = null,
val url: String? = null,
val image: String? = null,
) {
fun isEmpty() = name == null && url == null && image == null
}
@OptIn(ExperimentalUuidApi::class)
class BunkerRequestConnect(
id: String = Uuid.random().toString(),
val remoteKey: HexKey,
val secret: HexKey? = null,
val permissions: String? = null,
) : BunkerRequest(id, METHOD_NAME, listOfNotNull(remoteKey, secret, permissions).toTypedArray()) {
val clientMetadata: BunkerClientMetadata? = null,
) : BunkerRequest(id, METHOD_NAME, buildParams(remoteKey, secret, permissions, clientMetadata)) {
companion object {
val METHOD_NAME = "connect"
/**
* The connect params are positional:
* `[remote-signer-pubkey, optional_secret, optional_requested_perms, optional_client_metadata]`.
*
* Client metadata MUST occupy the 4th position, so when it is present we
* back-fill the optional secret/permissions slots with empty strings
* (per NIP-46). When it is absent we keep the array as short as possible
* for backward compatibility.
*/
private fun buildParams(
remoteKey: HexKey,
secret: HexKey?,
permissions: String?,
clientMetadata: BunkerClientMetadata?,
): Array<String> {
val metadataJson = clientMetadata?.takeUnless { it.isEmpty() }?.let { JsonMapper.toJson(it) }
return if (metadataJson != null) {
arrayOf(remoteKey, secret ?: "", permissions ?: "", metadataJson)
} else {
listOfNotNull(remoteKey, secret, permissions).toTypedArray()
}
}
fun parse(
id: String,
params: Array<String>,
): BunkerRequestConnect = BunkerRequestConnect(id, params[0], params.getOrNull(1), params.getOrNull(2))
): BunkerRequestConnect =
BunkerRequestConnect(
id = id,
remoteKey = params[0],
secret = params.getOrNull(1)?.takeIf { it.isNotEmpty() },
permissions = params.getOrNull(2)?.takeIf { it.isNotEmpty() },
clientMetadata = params.getOrNull(3)?.let(::parseMetadata),
)
// The metadata rides in as a JSON-stringified object. A hostile or
// malformed value must never abort parsing of an otherwise valid
// connect request, so failures degrade to "no metadata".
private fun parseMetadata(json: String): BunkerClientMetadata? =
try {
JsonMapper.fromJson<BunkerClientMetadata>(json).takeUnless { it.isEmpty() }
} catch (_: Exception) {
null
}
}
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerClientMetadata
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
@@ -57,6 +58,11 @@ class NostrSignerRemote(
val client: INostrClient,
val permissions: String? = null,
val secret: String? = null,
/**
* Optional NIP-46 client metadata (name/url/image) advertised on the
* `connect` request so the bunker can show who is asking to connect.
*/
val clientMetadata: BunkerClientMetadata? = null,
/**
* Invoked with the authorization URL when the bunker answers with a NIP-46
* `auth_url` challenge. Surface it (open a browser / print it); the pending
@@ -263,6 +269,7 @@ class NostrSignerRemote(
remoteKey = remotePubkey,
permissions = permissions,
secret = secret,
clientMetadata = clientMetadata,
)
},
parser = ConnectResponse::parse,
@@ -330,6 +337,7 @@ class NostrSignerRemote(
signer: NostrSignerInternal,
client: INostrClient,
permissions: String? = null,
clientMetadata: BunkerClientMetadata? = null,
): NostrSignerRemote {
if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri")
val splitData = bunkerUri.split("?")
@@ -355,6 +363,7 @@ class NostrSignerRemote(
client = client,
permissions = permissions,
secret = secret,
clientMetadata = clientMetadata,
)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BunkerRequestTest {
@@ -34,4 +35,94 @@ class BunkerRequestTest {
assertTrue(bunkerRequest is BunkerRequestSign)
assertEquals(1, bunkerRequest.event.kind)
}
@Test
fun testConnectWithoutMetadata() {
val requestJson = """{"id":"1","method":"connect","params":["abc","mysecret","sign_event"]}"""
val request = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(requestJson)
assertTrue(request is BunkerRequestConnect)
assertEquals("abc", request.remoteKey)
assertEquals("mysecret", request.secret)
assertEquals("sign_event", request.permissions)
assertNull(request.clientMetadata)
}
@Test
fun testConnectWithClientMetadata() {
val requestJson =
"""{"id":"1","method":"connect","params":["abc","mysecret","sign_event","{\"name\":\"Amethyst\",\"url\":\"https://amethyst.social\",\"image\":\"https://amethyst.social/logo.png\"}"]}"""
val request = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(requestJson)
assertTrue(request is BunkerRequestConnect)
assertEquals("Amethyst", request.clientMetadata?.name)
assertEquals("https://amethyst.social", request.clientMetadata?.url)
assertEquals("https://amethyst.social/logo.png", request.clientMetadata?.image)
}
@Test
fun testConnectWithMalformedMetadataDegradesGracefully() {
val requestJson = """{"id":"1","method":"connect","params":["abc","mysecret","sign_event","not-json"]}"""
val request = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(requestJson)
assertTrue(request is BunkerRequestConnect)
assertNull(request.clientMetadata)
}
@Test
fun testConnectMetadataRoundTrip() {
val original =
BunkerRequestConnect(
id = "42",
remoteKey = "abc",
secret = "mysecret",
permissions = "sign_event",
clientMetadata = BunkerClientMetadata(name = "Amethyst", url = "https://amethyst.social"),
)
assertEquals(4, original.params.size)
val roundTripped = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(OptimizedJsonMapper.toJson(original))
assertTrue(roundTripped is BunkerRequestConnect)
assertEquals("Amethyst", roundTripped.clientMetadata?.name)
assertEquals("https://amethyst.social", roundTripped.clientMetadata?.url)
assertNull(roundTripped.clientMetadata?.image)
}
@Test
fun testConnectMetadataBackfillsEmptyOptionalParams() {
val request =
BunkerRequestConnect(
remoteKey = "abc",
clientMetadata = BunkerClientMetadata(name = "Amethyst"),
)
// Metadata MUST sit at index 3; the omitted secret/permissions are
// back-filled with empty strings to hold the positions.
assertEquals(4, request.params.size)
assertEquals("abc", request.params[0])
assertEquals("", request.params[1])
assertEquals("", request.params[2])
assertTrue(request.params[3].contains("Amethyst"))
val roundTripped = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(OptimizedJsonMapper.toJson(request))
assertTrue(roundTripped is BunkerRequestConnect)
assertNull(roundTripped.secret)
assertNull(roundTripped.permissions)
assertEquals("Amethyst", roundTripped.clientMetadata?.name)
}
@Test
fun testConnectEmptyMetadataNotSerialized() {
val request =
BunkerRequestConnect(
remoteKey = "abc",
secret = "mysecret",
permissions = "sign_event",
clientMetadata = BunkerClientMetadata(),
)
// An all-null metadata object adds no 4th param.
assertEquals(3, request.params.size)
}
}