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..40c49a438d 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,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 { + 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, - ): 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(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..bcc724acd7 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,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(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 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 = + BunkerRequestConnect( + remoteKey = "abc", + secret = "mysecret", + permissions = "sign_event", + clientMetadata = BunkerClientMetadata(), + ) + + // An all-null metadata object adds no 4th param. + assertEquals(3, request.params.size) + } }