mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+3
-1
@@ -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
|
||||
|
||||
+11
-2
@@ -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(
|
||||
|
||||
+65
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user