From 36af9e2346844f8085c7c9ec8b5831a3d5df9936 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 03:24:49 +0000 Subject: [PATCH] feat: add NIP-89 client tag to all signed events via NostrSignerWithClientTag decorator Introduces a signer decorator that automatically appends the NIP-89 client tag to every event before signing, ensuring all events carry client identification without modifying individual event creation sites. Works transparently with internal, external (NIP-55), and remote (NIP-46) signers. https://claude.ai/code/session_01WRtuk1ySCfqXzfFrieKsDs --- .../model/accountsCache/AccountCacheState.kt | 9 +- .../amethyst/desktop/model/DesktopIAccount.kt | 7 +- .../clientTag/NostrSignerWithClientTag.kt | 97 +++++++++++++++ .../clientTag/NostrSignerWithClientTagTest.kt | 117 ++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 754d801de..28e417e46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -91,9 +92,11 @@ class AccountCacheState( val cached = accounts.value[signer.pubKey] if (cached != null) return cached + val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME) + return Account( settings = accountSettings, - signer = signer, + signer = signerWithClientTag, geolocationFlow = geolocationFlow, nwcFilterAssembler = nwcFilterAssembler, otsResolverBuilder = otsResolverBuilder, @@ -122,4 +125,8 @@ class AccountCacheState( emptyMap() } } + + companion object { + const val CLIENT_TAG_NAME = "Amethyst" + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 5e3108b2e..f33e60f26 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -62,7 +63,7 @@ class DesktopIAccount( val dmSendTracker: DmSendTracker, private val scope: CoroutineScope, ) : IAccount { - override val signer: NostrSigner = accountState.signer + override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME) override val pubKey: String = accountState.pubKeyHex @@ -221,4 +222,8 @@ class DesktopIAccount( } chatroomList.addMessage(roomKey, note) } + + companion object { + const val CLIENT_TAG_NAME = "Amethyst" + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt new file mode 100644 index 000000000..1aa8b4de6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -0,0 +1,97 @@ +/* + * 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.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +/** + * A [NostrSigner] decorator that automatically appends a NIP-89 client tag + * to every event before delegating signing to the wrapped [inner] signer. + * + * This ensures all events produced by the application carry the client + * identification tag without requiring each call site to add it manually. + * + * Works with any signer implementation: internal, external (NIP-55/Amber), + * or remote (NIP-46/Bunker). + */ +class NostrSignerWithClientTag( + val inner: NostrSigner, + val clientTag: Array, +) : NostrSigner(inner.pubKey) { + constructor( + inner: NostrSigner, + clientName: String, + ) : this(inner, ClientTag.assemble(clientName)) + + constructor( + inner: NostrSigner, + clientName: String, + addressId: String?, + relayHint: NormalizedRelayUrl?, + ) : this(inner, ClientTag.assemble(clientName, addressId, relayHint)) + + override fun isWriteable(): Boolean = inner.isWriteable() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = inner.sign(createdAt, kind, appendClientTag(tags), content) + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = inner.nip04Encrypt(plaintext, toPublicKey) + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = inner.nip04Decrypt(ciphertext, fromPublicKey) + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = inner.nip44Encrypt(plaintext, toPublicKey) + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = inner.nip44Decrypt(ciphertext, fromPublicKey) + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event) + + override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + + override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() + + private fun appendClientTag(tags: Array>): Array> { + // Don't add if a client tag already exists + if (tags.any { it.size >= 2 && it[0] == ClientTag.TAG_NAME }) return tags + + return tags + arrayOf(clientTag) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt new file mode 100644 index 000000000..d6d6ed873 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTagTest.kt @@ -0,0 +1,117 @@ +/* + * 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.nip89AppHandlers.clientTag + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NostrSignerWithClientTagTest { + private val keyPair = KeyPair() + private val innerSigner = NostrSignerInternal(keyPair) + + @Test + fun addsClientTagToSignedEvent() = + runTest { + val signer = NostrSignerWithClientTag(innerSigner, "Amethyst") + + val template = + eventTemplate( + kind = TextNoteEvent.KIND, + description = "Hello Nostr", + ) + + val event = signer.sign(template) + + assertTrue(event.verify()) + + val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" } + assertEquals(1, clientTags.size) + assertEquals("Amethyst", clientTags[0][1]) + } + + @Test + fun doesNotDuplicateExistingClientTag() = + runTest { + val signer = NostrSignerWithClientTag(innerSigner, "Amethyst") + + val template = + eventTemplate( + kind = TextNoteEvent.KIND, + description = "Hello Nostr", + ) { + client("OtherClient") + } + + val event = signer.sign(template) + + assertTrue(event.verify()) + + val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" } + assertEquals(1, clientTags.size) + assertEquals("OtherClient", clientTags[0][1]) + } + + @Test + fun addsClientTagWithAddressAndRelay() = + runTest { + val signer = + NostrSignerWithClientTag( + innerSigner, + "Amethyst", + "31990:abc123:amethyst", + null, + ) + + val template = + eventTemplate( + kind = TextNoteEvent.KIND, + description = "Hello Nostr", + ) + + val event = signer.sign(template) + + assertTrue(event.verify()) + + val clientTags = event.tags.filter { it.size >= 2 && it[0] == "client" } + assertEquals(1, clientTags.size) + assertEquals("Amethyst", clientTags[0][1]) + assertEquals("31990:abc123:amethyst", clientTags[0][2]) + } + + @Test + fun preservesSamePubKey() { + val signer = NostrSignerWithClientTag(innerSigner, "Amethyst") + assertEquals(innerSigner.pubKey, signer.pubKey) + } + + @Test + fun delegatesIsWriteable() { + val signer = NostrSignerWithClientTag(innerSigner, "Amethyst") + assertEquals(innerSigner.isWriteable(), signer.isWriteable()) + } +}