From 99513a92b670ea6011b0a8a7cea1d4cd7ccd4c95 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 29 Sep 2026 12:47:53 -0400 Subject: [PATCH] fix(concord): seal rumors without a sig field The envelope serialized the rumor with Event.toJson, which writes "sig": "". A rumor is unsigned (NIP-59), and applesauce-based clients such as Accordion read an object with a string sig as a signed event, fail its signature and drop it, so Accordion never showed a single Concord message, Join or edition we sent. Serialize through Rumor, as NIP-59 seals already do. Co-Authored-By: Claude Opus 5.5 --- .../concord/envelope/ConcordStreamEnvelope.kt | 8 ++++++-- .../envelope/ConcordStreamEnvelopeTest.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index a2bda26d86..a3e9ee9172 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -78,11 +79,14 @@ object ConcordStreamEnvelope { authorSigner: NostrSigner, encrypted: Boolean, ): Event { + // A rumor is unsigned (NIP-59): serialize it without `sig`. applesauce clients read + // `"sig": ""` as a signed event with a bad signature and drop it. + val rumorJson = Rumor.toJson(Rumor.create(rumor)) val content = if (encrypted) { - encryptChecked(rumor.toJson(), stream.conversationKey) + encryptChecked(rumorJson, stream.conversationKey) } else { - rumor.toJson() + rumorJson } val kind = if (encrypted) KIND_SEAL_ENCRYPTED else KIND_SEAL_PLAINTEXT return authorSigner.sign(rumor.createdAt, kind, EMPTY_TAGS, content) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt index e9fc72f3fb..50e0f9e1fa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt @@ -28,8 +28,12 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -69,6 +73,21 @@ class ConcordStreamEnvelopeTest { assertEquals(9, opened.rumor.kind) } + /** + * A rumor is unsigned (NIP-59): its JSON carries no `sig`. applesauce-based clients + * (Accordion) treat an object with `"sig": ""` as a signed event whose signature fails, + * and silently dropped every Concord message and Guestbook Join we sent. + */ + @Test + fun sealedRumorJsonCarriesNoSig() = + runTest { + val seal = ConcordStreamEnvelope.seal(chatRumor("no sig"), stream, authorSigner, encrypted = false) + val rumorJson = Json.parseToJsonElement(seal.content).jsonObject + + assertFalse("sig" in rumorJson, "rumor JSON must not carry a sig: ${seal.content}") + assertEquals(chatRumor("no sig").id, rumorJson["id"]?.jsonPrimitive?.content) + } + @Test fun encryptedSealRoundTrips() = runTest {