From 55bc75512a59f1467210d07c91346022fe70539a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 16:17:19 +0000 Subject: [PATCH 1/3] feat(nip46): support optional client metadata in connect request Implements nostr-protocol/nips#2381: a client MAY attach an optional 4th positional parameter to the NIP-46 `connect` request carrying a JSON-stringified `{name, url, image}` object, mirroring the fields already present in `nostrconnect://` URIs. This lets a bunker:// paired signer show who is asking to connect. - quartz: add BunkerClientMetadata and a clientMetadata field on BunkerRequestConnect; serialize it as the 4th param (omitted when empty) and parse it back, degrading malformed/empty JSON to null. - quartz: NostrSignerRemote carries and sends clientMetadata on connect() and threads it through fromBunkerUri(). - commons: BunkerLoginUseCase.execute() accepts optional clientMetadata. - desktopApp: advertise Amethyst's metadata on bunker login. - cli: the receiving bunker logs the connecting client's identity (display-only; never gates the ACK on it, since the client pubkey is unauthenticated in bunker:// pairing). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi --- .../amethyst/cli/commands/BunkerCommand.kt | 10 ++- .../domain/nip46/BunkerLoginUseCase.kt | 4 +- .../desktop/account/AccountManager.kt | 13 +++- .../nip46RemoteSigner/BunkerRequestConnect.kt | 48 ++++++++++++- .../signer/NostrSignerRemote.kt | 9 +++ .../nip46RemoteSigner/BunkerRequestTest.kt | 68 +++++++++++++++++++ 6 files changed, 146 insertions(+), 6 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index 12f68685d7..d5105c803a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -233,12 +233,20 @@ object BunkerCommand { val response: BunkerResponse = try { when (request) { - is BunkerRequestConnect -> + is BunkerRequestConnect -> { + // NIP-46 client metadata is a display-only hint (the client + // pubkey is unauthenticated in bunker:// pairing), so we only + // surface it — never gate the ACK on it. + request.clientMetadata?.let { meta -> + val label = listOfNotNull(meta.name, meta.url).joinToString(" — ").ifEmpty { "unnamed client" } + System.err.println("[bunker] connect request identifies as: $label") + } if (request.secret == secret) { BunkerResponseAck(request.id) } else { BunkerResponseError(request.id, "invalid secret") } + } is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) }) is BunkerRequestPing -> BunkerResponsePong(request.id) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt index 4962f5e3ee..075f82f972 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 6aa6d4eb20..114a4cc885 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -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( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt index c73b44cb93..51f734553f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt @@ -21,23 +21,67 @@ 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, + listOfNotNull(remoteKey, secret, permissions, clientMetadata?.takeUnless { it.isEmpty() }?.let { JsonMapper.toJson(it) }).toTypedArray(), + ) { companion object { val METHOD_NAME = "connect" fun parse( id: String, params: Array, - ): BunkerRequestConnect = BunkerRequestConnect(id, params[0], params.getOrNull(1), params.getOrNull(2)) + ): BunkerRequestConnect = + BunkerRequestConnect( + id = id, + remoteKey = params[0], + secret = params.getOrNull(1), + permissions = params.getOrNull(2), + 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(json).takeUnless { it.isEmpty() } + } catch (_: Exception) { + null + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index a1f419ca2d..38ca469718 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -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, ) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index bdb52d619b..6526a2001d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -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,71 @@ 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(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(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(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(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 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) + } } From 4ae2debd6bc591d0177556a8bf5561f0deca0fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 11:28:14 +0000 Subject: [PATCH 2/3] refactor(cli): drop client-metadata log from bunker connect handler Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi --- .../amethyst/cli/commands/BunkerCommand.kt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index d5105c803a..12f68685d7 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -233,20 +233,12 @@ object BunkerCommand { val response: BunkerResponse = try { when (request) { - is BunkerRequestConnect -> { - // NIP-46 client metadata is a display-only hint (the client - // pubkey is unauthenticated in bunker:// pairing), so we only - // surface it — never gate the ACK on it. - request.clientMetadata?.let { meta -> - val label = listOfNotNull(meta.name, meta.url).joinToString(" — ").ifEmpty { "unnamed client" } - System.err.println("[bunker] connect request identifies as: $label") - } + is BunkerRequestConnect -> if (request.secret == secret) { BunkerResponseAck(request.id) } else { BunkerResponseError(request.id, "invalid secret") } - } is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) }) is BunkerRequestPing -> BunkerResponsePong(request.id) From da4207319b9f822c774de4c6aabff0f71e859c47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 12:08:06 +0000 Subject: [PATCH 3/3] fix(nip46): pin client metadata to 4th connect param, backfill empties Per NIP-46 (nostr-protocol/nips#2381), client metadata is the 4th positional connect param. When it is present but the optional secret or permissions are not, those slots are now back-filled with empty strings so the metadata always lands at index 3. Parsing maps empty placeholders back to null. When no metadata is sent, the array stays as short as possible for backward compatibility. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi --- .../nip46RemoteSigner/BunkerRequestConnect.kt | 33 +++++++++++++++---- .../nip46RemoteSigner/BunkerRequestTest.kt | 23 +++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt index 51f734553f..40c49a438d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestConnect.kt @@ -53,14 +53,33 @@ class BunkerRequestConnect( val secret: HexKey? = null, val permissions: String? = null, val clientMetadata: BunkerClientMetadata? = null, -) : BunkerRequest( - id, - METHOD_NAME, - listOfNotNull(remoteKey, secret, permissions, clientMetadata?.takeUnless { it.isEmpty() }?.let { JsonMapper.toJson(it) }).toTypedArray(), - ) { +) : 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 { + 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, @@ -68,8 +87,8 @@ class BunkerRequestConnect( BunkerRequestConnect( id = id, remoteKey = params[0], - secret = params.getOrNull(1), - permissions = params.getOrNull(2), + secret = params.getOrNull(1)?.takeIf { it.isNotEmpty() }, + permissions = params.getOrNull(2)?.takeIf { it.isNotEmpty() }, clientMetadata = params.getOrNull(3)?.let(::parseMetadata), ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index 6526a2001d..bcc724acd7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -89,6 +89,29 @@ class BunkerRequestTest { 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(OptimizedJsonMapper.toJson(request)) + assertTrue(roundTripped is BunkerRequestConnect) + assertNull(roundTripped.secret) + assertNull(roundTripped.permissions) + assertEquals("Amethyst", roundTripped.clientMetadata?.name) + } + @Test fun testConnectEmptyMetadataNotSerialized() { val request =