diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 42ae4f03ea..318dfff466 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -22,11 +22,15 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce @@ -66,12 +70,13 @@ object ChannelChat { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( authorPubKey, ChatEvent.build(text, createdAt) { - channelBinding(channelId, epoch) - extraTags.forEach { addUnique(it) } + channelBinding(channelId, epoch, ms) + withoutBinding(extraTags).forEach { add(it) } }, ) @@ -82,6 +87,10 @@ object ChannelChat { * into a minichat), an inline quote stays in the main chat timeline — the two * reply modes the composer offers. Matches Armada, where a kind-9 `q` is an * inline quote deliberately kept out of threads. + * + * The `q` tag is the four-element NIP-C7 form `["q", , "", ]` the spec's + * examples (§2.1) and Armada write: an empty relay hint (a rumor lives on no relay) and the + * quoted author, so a reader can render the card before the quoted rumor arrives. */ fun inlineReply( authorPubKey: HexKey, @@ -92,6 +101,7 @@ object ChannelChat { parentAuthor: HexKey, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = message( authorPubKey = authorPubKey, @@ -99,7 +109,8 @@ object ChannelChat { epoch = epoch, text = text, createdAt = createdAt, - extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, + extraTags = arrayOf(arrayOf("q", parentId, "", parentAuthor), arrayOf("p", parentAuthor)) + extraTags, + ms = ms, ) /** @@ -124,20 +135,48 @@ object ChannelChat { newText: String, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, kind = ConcordChatEditEvent.KIND, - tags = - arrayOf( - ChannelTag.assemble(channelId), - EpochTag.assemble(epoch), - arrayOf("e", targetId), - ) + extraTags, + tags = bindingTags(channelId, epoch, ms) + arrayOf(arrayOf("e", targetId)) + withoutBinding(extraTags), content = newText, ) + /** + * Builds an unsigned kind-5 **delete** rumor (CORD-01 Deletions, examples §2.4) retracting + * the author's own [targets] inside the channel, bound to [channelId]/[epoch]. + * + * NIP-09 shape: one `["e", ]` per target, then one `["k", ]` per distinct + * target kind (`9` for a message, `1111` for a thread reply, `7` for a reaction), and the + * optional [reason] as content. It names *rumor* ids relays never saw, so it must be wrapped + * on the channel plane like any other Chat rumor — never published as a signed kind 5 or a + * NIP-17 DM, both of which would leak the rumor ids outside the community. Receivers honor it + * only for targets the delete's own author wrote. A delete never expires (CORD-08). + */ + fun delete( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + targets: List, + createdAt: Long, + reason: String = "", + ms: Int = MsTag.remainderFor(createdAt), + ): Event { + require(targets.isNotEmpty()) { "A delete must name at least one target" } + val eTags = targets.map { arrayOf("e", it.id) } + val kTags = targets.map { it.kind }.distinct().map { arrayOf("k", it.toString()) } + return RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = DeletionRequestEvent.KIND, + tags = bindingTags(channelId, epoch, ms) + eTags.toTypedArray() + kTags.toTypedArray(), + content = reason, + ) + } + /** * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to * [parent], bound to [channelId]/[epoch]. @@ -160,12 +199,13 @@ object ChannelChat { parent: Event, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( authorPubKey, CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) { - channelBinding(channelId, epoch) - extraTags.forEach { add(it) } + channelBinding(channelId, epoch, ms) + withoutBinding(extraTags).forEach { add(it) } }, ) @@ -186,6 +226,7 @@ object ChannelChat { parent: Event, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event { val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") @@ -197,6 +238,7 @@ object ChannelChat { parent = parent, createdAt = createdAt, extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ms = ms, ) } @@ -218,19 +260,19 @@ object ChannelChat { content: String, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, kind = ReactionEvent.KIND, tags = - arrayOf( - ChannelTag.assemble(channelId), - EpochTag.assemble(epoch), - arrayOf("e", targetId), - arrayOf("p", targetAuthor), - arrayOf("k", targetKind.toString()), - ) + extraTags, + bindingTags(channelId, epoch, ms) + + arrayOf( + arrayOf("e", targetId), + arrayOf("p", targetAuthor), + arrayOf("k", targetKind.toString()), + ) + withoutBinding(extraTags), content = content, ) @@ -250,6 +292,7 @@ object ChannelChat { imetas: List, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event { val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") @@ -260,6 +303,7 @@ object ChannelChat { text = finalText, createdAt = createdAt, extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ms = ms, ) } @@ -337,12 +381,13 @@ object ChannelChat { channelId: HexKey, epoch: Long, createdAt: Long, + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, kind = KIND_TYPING, - tags = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), + tags = bindingTags(channelId, epoch, ms), content = "", ) @@ -364,6 +409,75 @@ object ChannelChat { channelId: HexKey, epoch: Long, ): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch) + + /** Timer notice (CORD-08 §4), a Chat Plane kind. */ + const val KIND_TIMER_NOTICE = 1740 + + /** + * Every rumor kind a Chat Plane may carry into the app (CORD-02 Appendix B): messages, + * thread replies, reactions, deletes, edits, the typing heartbeat and the CORD-08 timer + * notice. Chat ingest refuses anything else — above all the other planes' kinds (a Control + * edition 3308, a Guestbook 3306/3309/3312, a rekey 3303, a direct invite 3313): the planes + * share one store, so without this a channel key-holder could inject a rumor another reader + * would take for a Control edition (the reference client's `PLANE_KINDS` refusal). + */ + val CHAT_KINDS: Set = + setOf( + ChatEvent.KIND, + CommentEvent.KIND, + ReactionEvent.KIND, + DeletionRequestEvent.KIND, + ConcordChatEditEvent.KIND, + KIND_TYPING, + KIND_TIMER_NOTICE, + ) + + /** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */ + fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS + + /** + * The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]: + * returns its rumor only when every Chat rule holds, else null (drop it). + * - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only); + * - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's; + * - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's + * ([isBoundTo], CORD-03 §3); + * - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never + * interpreted). + */ + fun acceptOpened( + opened: OpenedStreamEvent, + channelId: HexKey, + epoch: Long, + ): Event? { + if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null + val rumor = opened.rumor + if (!isChatKind(rumor.kind)) return null + if (!isBoundTo(rumor, channelId, epoch)) return null + if (orderingMs(rumor) == null) return null + return rumor + } + + /** + * The rumor's CORD-02 §4 ordering time, `createdAt * 1000 + ms`, or null when its `ms` tag is + * malformed or duplicated (such a rumor is dropped, never interpreted). See [MsTag]. + */ + fun orderingMs(rumor: Event): Long? = MsTag.orderingMs(rumor.createdAt, rumor.tags) + + /** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */ + private fun bindingTags( + channelId: HexKey, + epoch: Long, + ms: Int, + ): Array> = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms)) + + private val BINDING_TAG_NAMES = setOf(ChannelTag.TAG_NAME, EpochTag.TAG_NAME, MsTag.TAG_NAME) + + /** + * [extraTags] minus any binding tag: a caller's extra `channel`/`epoch`/`ms` would make the + * binding ambiguous, and strict receivers (ours included) drop a duplicated binding. + */ + private fun withoutBinding(extraTags: Array>): Array> = extraTags.filterNot { it.isNotEmpty() && it[0] in BINDING_TAG_NAMES }.toTypedArray() } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt index 01c413f27d..2bbe2cec53 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent @@ -65,16 +66,7 @@ class ConcordChatEditEvent( * remainder tag (CORD-02 §4). Used to order competing edits at sub-second precision, matching the * reference client (an absent/malformed `ms` tag reads as 0). "Latest edit wins" compares this. */ - fun orderingMs(): Long { - val remainder = - tags - .firstOrNull { it.size > 1 && it[0] == "ms" } - ?.get(1) - ?.toIntOrNull() - ?.takeIf { it in 0..999 } - ?: 0 - return createdAt * 1000 + remainder - } + fun orderingMs(): Long = MsTag.orderingMs(createdAt, tags) ?: (createdAt * 1000) companion object { const val KIND = 3302 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt index d1f517ad72..0b17cc4220 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -36,6 +37,9 @@ fun TagArrayBuilder.channel(channelId: HexKey) = addUnique(Channe fun TagArrayBuilder.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch)) +/** The CORD-02 §4 sub-second remainder (0..999) of the rumor's send time. */ +fun TagArrayBuilder.ms(ms: Int) = addUnique(MsTag.assemble(ms)) + /** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */ fun TagArrayBuilder.channelBinding( channelId: HexKey, @@ -44,3 +48,14 @@ fun TagArrayBuilder.channelBinding( channel(channelId) epoch(epoch) } + +/** [channelBinding] plus the `["ms", …]` remainder every Chat rumor carries (CORD-02 §4). */ +fun TagArrayBuilder.channelBinding( + channelId: HexKey, + epoch: Long, + ms: Int, +) = apply { + channel(channelId) + epoch(epoch) + ms(ms) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt index 7158224348..471d8f55f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt @@ -25,17 +25,39 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray -/** The channel id this Chat Plane rumor is bound to, or null if unbound. */ -fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse) +/** + * The value of the one tag named [name], or null when it is absent **or appears more than once** + * (a binding must be unambiguous — the reference client's `uniqueTag`). Every tag whose name + * matches counts toward the duplicate check, even one too short to carry a value. + */ +private fun TagArray.uniqueTagValue(name: String): String? { + var found: String? = null + var count = 0 + for (tag in this) { + if (tag.isEmpty() || tag[0] != name) continue + count++ + if (count > 1) return null + found = tag.getOrNull(1) + } + return found +} -/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */ -fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse) +/** The channel id this Chat Plane rumor is bound to, or null if unbound or ambiguous (duplicated). */ +fun TagArray.concordChannel(): HexKey? = uniqueTagValue(ChannelTag.TAG_NAME)?.takeIf { it.isNotEmpty() } /** - * True when these tags bind to exactly [channelId] and [epoch]. Recipients must - * reject any Chat Plane event whose binding does not match the plane it arrived on. + * The epoch this Chat Plane rumor is bound to, or null if unbound, ambiguous (duplicated), or not + * in canonical decimal form ([EpochTag.parse]). + */ +fun TagArray.concordEpoch(): Long? = uniqueTagValue(EpochTag.TAG_NAME)?.let { EpochTag.parse(arrayOf(EpochTag.TAG_NAME, it)) } + +/** + * True when these tags bind to exactly [channelId] and [epoch] (CORD-03 §3): exactly one + * `channel` tag strict-equal to [channelId], and exactly one `epoch` tag strict-equal to the + * canonical decimal of [epoch] (`"04"` or `"+4"` never match 4). Recipients must reject any Chat + * Plane event whose binding does not match the plane it arrived on. */ fun TagArray.isConcordBoundTo( channelId: HexKey, epoch: Long, -): Boolean = concordChannel() == channelId && concordEpoch() == epoch +): Boolean = uniqueTagValue(ChannelTag.TAG_NAME) == channelId && uniqueTagValue(EpochTag.TAG_NAME) == epoch.toString() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt index 804697e196..9c5fac6d71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt @@ -35,11 +35,19 @@ class EpochTag { fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + /** + * The epoch, or null when the value is not its canonical decimal form (CORD-01 Encoding: + * "no leading zeros"). `"04"`, `"+4"`, `"-1"` and `" 4"` are all refused: the binding is a + * strict string comparison, so a spelling that merely parses to the same number is a + * different binding. + */ fun parse(tag: Array): Long? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return tag[1].toLongOrNull() + val epoch = tag[1].toLongOrNull() ?: return null + ensure(epoch >= 0 && epoch.toString() == tag[1]) { return null } + return epoch } fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt new file mode 100644 index 0000000000..d976ae4d4c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt @@ -0,0 +1,86 @@ +/* + * 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.concord.cord03Channels.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The `["ms", "<0..999>"]` sub-second remainder every Concord Chat rumor carries (CORD-02 §4): + * `created_at` stays whole unix seconds, untweaked (CORD-01), and the true send time is + * `created_at * 1000 + ms`. Every comparison the protocol makes (message order, edit recency) + * uses that basis. + * + * Parsing is strict decimal, like the reference client's `resolveMs`: `"0"` or `"1"`…`"999"` + * with no leading zero, sign, whitespace or exponent. A tag outside that shape is malformed, and + * a malformed rumor is dropped rather than interpreted (CORD-02 §5), so the excess can never + * smuggle ordering the author's clock did not produce. + */ +class MsTag { + companion object { + const val TAG_NAME = "ms" + + private val CANONICAL = Regex("^(0|[1-9][0-9]{0,2})$") + + /** The remainder in [tag], or null when it is not an `ms` tag or is malformed. */ + fun parse(tag: Array): Int? { + if (tag.isEmpty() || tag[0] != TAG_NAME) return null + val raw = tag.getOrNull(1) ?: return null + if (!CANONICAL.matches(raw)) return null + return raw.toInt() + } + + fun assemble(ms: Int): Array { + require(ms in 0..999) { "ms remainder must be in 0..999, was $ms" } + return arrayOf(TAG_NAME, ms.toString()) + } + + /** + * The sub-second remainder of "now" when [createdAt] is the current second, else 0. A + * builder handed the current `TimeUtils.now()` thus stamps the real millisecond; one + * handed any other second (a fixed test time, a backdated rumor, a rollover between the + * two clock reads) stamps 0, which is still a well-formed, ordering-safe tag. + */ + fun remainderFor(createdAt: Long): Int { + val nowMs = TimeUtils.nowMillis() + return if (nowMs / 1000 == createdAt) (nowMs % 1000).toInt() else 0 + } + + /** + * The rumor's ordering basis `createdAt * 1000 + ms` (CORD-02 §4). A missing tag counts + * as 0; a malformed or duplicated one yields null, and the caller drops the rumor. + */ + fun orderingMs( + createdAt: Long, + tags: TagArray, + ): Long? { + var found: Int? = null + var count = 0 + for (tag in tags) { + if (tag.isEmpty() || tag[0] != TAG_NAME) continue + count++ + found = parse(tag) ?: return null + } + if (count > 1) return null + return createdAt * 1000 + (found ?: 0) + } + } +} 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 7975d3aa37..a2bda26d86 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 @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verifyId 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.utils.TimeUtils /** @@ -79,7 +80,7 @@ object ConcordStreamEnvelope { ): Event { val content = if (encrypted) { - Nip44.v2.encrypt(rumor.toJson(), stream.conversationKey).encodePayload() + encryptChecked(rumor.toJson(), stream.conversationKey) } else { rumor.toJson() } @@ -115,7 +116,7 @@ object ConcordStreamEnvelope { createdAt: Long = TimeUtils.now(), ): Event { val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey)) - val content = Nip44.v2.encrypt(seal.toJson(), readConversationKey).encodePayload() + val content = encryptChecked(seal.toJson(), readConversationKey) val ephemeralP = KeyPair().pubKey.toHexKey() val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) @@ -200,7 +201,7 @@ object ConcordStreamEnvelope { } require(wrap.verify()) { "Wrap signature/id is invalid" } - val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, readConversationKey)) + val seal = Event.fromJson(decryptChecked(wrap.content, readConversationKey)) require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) { "Not a Concord seal: kind ${seal.kind}" } @@ -208,7 +209,7 @@ object ConcordStreamEnvelope { val rumorJson = if (seal.kind == KIND_SEAL_ENCRYPTED) { - Nip44.v2.decrypt(seal.content, readConversationKey) + decryptChecked(seal.content, readConversationKey) } else { seal.content } @@ -257,6 +258,47 @@ object ConcordStreamEnvelope { ): OpenedStreamEvent? = openOrNull(wrap, keys.address, keys.readKey.conversationKey) private val EMPTY_TAGS = emptyArray>() + + /** + * NIP-44's hard plaintext cap (CORD-02 Appendix B). Every layer of a Concord event is a NIP-44 + * plaintext, and the spec makes enforcing the cap each implementation's job: quartz's NIP-44 + * silently switches to its extended (u32-prefixed) format past it, which strict readers — + * the reference client among them — cannot decrypt. + */ + const val NIP44_MAX_PLAINTEXT = 65_535 + + /** The largest standard-format NIP-44 v2 ciphertext: the u16 prefix plus the 64 KiB pad bucket. */ + private const val MAX_STANDARD_CIPHERTEXT = 2 + 65_536 + + /** base64 of version (1) + nonce (32) + [MAX_STANDARD_CIPHERTEXT] + mac (32): anything longer is not standard NIP-44. */ + private const val MAX_STANDARD_PAYLOAD = 87_472 + + /** + * NIP-44 v2 encrypt that refuses a plaintext over [NIP44_MAX_PLAINTEXT] UTF-8 bytes instead of + * minting an extended-format payload (the reference client's `encryptChecked`). + */ + private fun encryptChecked( + plaintext: String, + conversationKey: ByteArray, + ): String { + val size = plaintext.encodeToByteArray().size + require(size <= NIP44_MAX_PLAINTEXT) { "Concord plaintext is $size bytes, over the NIP-44 cap of $NIP44_MAX_PLAINTEXT (CORD-02 Appendix B)" } + return Nip44.v2.encrypt(plaintext, conversationKey).encodePayload() + } + + /** + * NIP-44 v2 decrypt that only accepts the standard format: a payload or ciphertext too large + * for the u16 length prefix is the extended format, which no strict Concord client can read + * and none of ours ever writes, so it is refused before any decryption work. + */ + private fun decryptChecked( + payload: String, + conversationKey: ByteArray, + ): String { + val info = Nip44v2.EncryptedInfo.decodePayload(payload, MAX_STANDARD_PAYLOAD) + require(info.ciphertext.size <= MAX_STANDARD_CIPHERTEXT) { "Extended-format NIP-44 payload refused (CORD-02 Appendix B)" } + return Nip44.v2.decrypt(info, conversationKey) + } } /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt new file mode 100644 index 0000000000..1a4228e159 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt @@ -0,0 +1,243 @@ +/* + * 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.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-01/02/03 Chat Plane conformance: the strict binding (I13), the `ms` tag (I15), the + * four-element inline quote (I16), the in-stream delete (S3), the Chat ingest gate (S9) and the + * private-channel keying (S2), each pinned against the spec text and examples.md §2. + */ +class ChannelChatConformanceTest { + private val communityRoot = ByteArray(32) { 0x5A } + private val channelId = ByteArray(32) { 0x42 } + private val channelIdHex = channelId.toHexKey() + private val author = KeyPair().pubKey.toHexKey() + + private fun rumorWithTags(vararg tags: Array): Event = RumorAssembler.assembleRumor(author, 1_700_000_000L, 9, arrayOf(*tags), "x") + + // ---- I13 strict binding ------------------------------------------------------------------- + + @Test + fun bindingRequiresExactlyOneChannelAndOneCanonicalEpoch() { + val ok = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4")) + assertTrue(ok.tags.isConcordBoundTo(channelIdHex, 4)) + + // Non-canonical spellings of 4 are a different binding (CORD-01 Encoding: no leading zeros). + for (spelling in listOf("04", "+4", " 4", "4 ", "4.0", "0x4")) { + val bad = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", spelling)) + assertFalse(bad.tags.isConcordBoundTo(channelIdHex, 4), "epoch \"$spelling\" must not bind to 4") + assertNull(bad.tags.concordEpoch(), "epoch \"$spelling\" must not parse") + } + assertNull(EpochTag.parse(arrayOf("epoch", "-1"))) + assertEquals(0L, EpochTag.parse(arrayOf("epoch", "0"))) + + // A duplicated tag is ambiguous, even when both copies agree. + val dupChannel = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel", channelIdHex), arrayOf("epoch", "4")) + assertFalse(dupChannel.tags.isConcordBoundTo(channelIdHex, 4)) + assertNull(dupChannel.tags.concordChannel()) + val dupEpoch = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4"), arrayOf("epoch", "4")) + assertFalse(dupEpoch.tags.isConcordBoundTo(channelIdHex, 4)) + assertNull(dupEpoch.tags.concordEpoch()) + // A valueless duplicate still counts as a second binding tag (Armada's uniqueTag). + val shortDup = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel"), arrayOf("epoch", "4")) + assertFalse(shortDup.tags.isConcordBoundTo(channelIdHex, 4)) + } + + @Test + fun extraTagsCannotSmuggleASecondBinding() { + val rumor = + ChannelChat.message( + author, + channelIdHex, + 0, + "hi", + createdAt = 1L, + extraTags = arrayOf(arrayOf("channel", "00".repeat(32)), arrayOf("epoch", "9"), arrayOf("ms", "5"), arrayOf("emoji", "a", "u1"), arrayOf("emoji", "b", "u2")), + ) + assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0)) + assertEquals(1, rumor.tags.count { it[0] == "ms" }) + // Every extra (non-binding) tag survives, including repeated names. + assertEquals(2, rumor.tags.count { it[0] == "emoji" }) + } + + // ---- I15 ms tag --------------------------------------------------------------------------- + + @Test + fun everyChatBuilderStampsAWellFormedMsTag() { + val parent = ChannelChat.message(author, channelIdHex, 0, "root", createdAt = 1L, ms = 417) + val built = + listOf( + parent, + ChannelChat.inlineReply(author, channelIdHex, 0, "q", parent.id, parent.pubKey, 2L), + ChannelChat.reply(author, channelIdHex, 0, "t", parent, 3L), + ChannelChat.reaction(author, channelIdHex, 0, parent.id, parent.pubKey, 9, "+", 4L), + ChannelChat.edit(author, channelIdHex, 0, parent.id, "e", 5L), + ChannelChat.delete(author, channelIdHex, 0, listOf(parent), 6L), + ChannelChat.typing(author, channelIdHex, 0, 7L), + ChannelChat.imageMessage(author, channelIdHex, 0, "i", emptyList(), 8L), + ) + for (rumor in built) { + val ms = rumor.tags.filter { it[0] == "ms" } + assertEquals(1, ms.size, "kind ${rumor.kind} must carry exactly one ms tag") + assertNotNull(MsTag.parse(ms.single()), "kind ${rumor.kind} ms tag must be well formed") + } + // The examples' order: channel, epoch, ms first. + assertContentEquals(arrayOf("channel", channelIdHex), parent.tags[0]) + assertContentEquals(arrayOf("epoch", "0"), parent.tags[1]) + assertContentEquals(arrayOf("ms", "417"), parent.tags[2]) + assertEquals(1_417L, ChannelChat.orderingMs(parent)) + } + + @Test + fun msParsingIsStrictAndOrderingUsesTheMillisecondBasis() { + assertEquals(0, MsTag.parse(arrayOf("ms", "0"))) + assertEquals(999, MsTag.parse(arrayOf("ms", "999"))) + for (bad in listOf("1000", "-1", "007", "+5", " 5", "1e2", "0x1f", "")) { + assertNull(MsTag.parse(arrayOf("ms", bad)), "ms \"$bad\" is malformed") + } + assertFailsWith { MsTag.assemble(1000) } + + assertEquals(5_000L, MsTag.orderingMs(5, emptyArray())) // absent = 0 + assertEquals(5_123L, MsTag.orderingMs(5, arrayOf(arrayOf("ms", "123")))) + assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1000")))) + assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1"), arrayOf("ms", "2")))) + + // Same second, the ms remainder decides the order. + val early = ChannelChat.message(author, channelIdHex, 0, "a", createdAt = 10L, ms = 900) + val late = ChannelChat.message(author, channelIdHex, 0, "b", createdAt = 11L, ms = 5) + val mid = ChannelChat.message(author, channelIdHex, 0, "c", createdAt = 10L, ms = 950) + assertEquals(listOf("a", "c", "b"), listOf(late, mid, early).sortedBy { ChannelChat.orderingMs(it) }.map { it.content }) + } + + @Test + fun msRemainderTracksTheCurrentSecondOnly() { + assertEquals(0, MsTag.remainderFor(1L)) + val r = MsTag.remainderFor(TimeUtils.now()) + assertTrue(r in 0..999) + } + + // ---- I16 inline quote --------------------------------------------------------------------- + + @Test + fun inlineQuoteUsesTheFourElementQTag() { + val parentAuthor = KeyPair().pubKey.toHexKey() + val quote = ChannelChat.inlineReply(author, channelIdHex, 0, "Welcome!", "ab".repeat(32), parentAuthor, 2L) + val q = quote.tags.single { it[0] == "q" } + assertContentEquals(arrayOf("q", "ab".repeat(32), "", parentAuthor), q) + assertEquals(9, quote.kind) + } + + // ---- S3 delete ---------------------------------------------------------------------------- + + @Test + fun deleteIsAChannelBoundKind5WithETagsThenKTags() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "oops", createdAt = 1L) + val reply = ChannelChat.reply(alice.pubKey, channelIdHex, 0, "also oops", message, 2L) + val reaction = ChannelChat.reaction(alice.pubKey, channelIdHex, 0, message.id, message.pubKey, 9, "+", 3L) + + val delete = ChannelChat.delete(alice.pubKey, channelIdHex, 0, listOf(message, reply, reaction), 4L, ms = 533) + assertEquals(5, delete.kind) + assertEquals("", delete.content) + assertContentEquals(arrayOf("channel", channelIdHex), delete.tags[0]) + assertContentEquals(arrayOf("epoch", "0"), delete.tags[1]) + assertContentEquals(arrayOf("ms", "533"), delete.tags[2]) + assertEquals(listOf(message.id, reply.id, reaction.id), delete.tags.filter { it[0] == "e" }.map { it[1] }) + assertEquals(listOf("9", "1111", "7"), delete.tags.filter { it[0] == "k" }.map { it[1] }) + + // It rides the channel plane in an encrypted seal like any other Chat rumor. + val wrap = ConcordStreamEnvelope.wrap(delete, channel, alice, encrypted = true) + val opened = ConcordStreamEnvelope.open(wrap, channel) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind) + assertNotNull(ChannelChat.acceptOpened(opened, channelIdHex, 0)) + assertFailsWith { ChannelChat.delete(alice.pubKey, channelIdHex, 0, emptyList(), 4L) } + } + + // ---- S9 chat ingest gate ------------------------------------------------------------------ + + @Test + fun chatIngestAcceptsOnlyEncryptedSealsAndChatKinds() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + + suspend fun open( + rumor: Event, + encrypted: Boolean = true, + ) = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, channel, alice, encrypted = encrypted), channel) + + val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "hi", createdAt = 1L) + assertNotNull(ChannelChat.acceptOpened(open(message), channelIdHex, 0)) + + // A plaintext seal is Control-only (CORD-02 §5). + assertNull(ChannelChat.acceptOpened(open(message, encrypted = false), channelIdHex, 0)) + + // Another plane's kind, correctly bound, is still refused (Armada PLANE_KINDS). + for (kind in listOf(3308, 3306, 3309, 3312, 3303, 3313, 1)) { + val foreign = RumorAssembler.assembleRumor(alice.pubKey, 1L, kind, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0")), "{}") + assertNull(ChannelChat.acceptOpened(open(foreign), channelIdHex, 0), "kind $kind must not enter a Chat Plane") + } + for (kind in listOf(9, 1111, 7, 5, 3302, 23311, 1740)) { + assertTrue(ChannelChat.isChatKind(kind), "kind $kind is a Chat kind") + } + + // A malformed ms drops the rumor instead of being interpreted. + val badMs = RumorAssembler.assembleRumor(alice.pubKey, 1L, 9, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0"), arrayOf("ms", "1500")), "hi") + assertNull(ChannelChat.acceptOpened(open(badMs), channelIdHex, 0)) + + // And a binding mismatch is dropped as before. + assertNull(ChannelChat.acceptOpened(open(message), channelIdHex, 1)) + } + + // ---- S2 private channel keying ------------------------------------------------------------ + + @Test + fun aPrivateChannelLivesOnItsOwnKeyNotTheRootPlane() { + val channelKey = ByteArray(32) { 0x33 } + val public = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val private = ConcordChannelKeys.privateChannel(channelKey, channelId, 1) + assertNotEquals(public.publicKeyHex, private.publicKeyHex) + // The channel epoch is part of the derivation: a stale key generation is a different plane. + assertNotEquals(private.publicKeyHex, ConcordChannelKeys.privateChannel(channelKey, channelId, 2).publicKeyHex) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt new file mode 100644 index 0000000000..0893db6ab6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt @@ -0,0 +1,93 @@ +/* + * 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.concord.envelope + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** + * CORD-02 Appendix B: NIP-44 hard-caps plaintext at 65,535 bytes and every Concord layer must + * enforce it itself — quartz's NIP-44 would otherwise switch to its extended format, which strict + * readers (the reference client) cannot decrypt. + */ +class ConcordStreamEnvelopeCapTest { + private val authorSigner = NostrSignerInternal(KeyPair()) + private val stream = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 7 }, ByteArray(32) { 0x33 }, 0) + + private fun rumor(content: String): Event = + RumorAssembler.assembleRumor( + pubKey = authorSigner.pubKey, + createdAt = 1_700_000_000L, + kind = 9, + tags = arrayOf(arrayOf("channel", "abc"), arrayOf("epoch", "0")), + content = content, + ) + + @Test + fun anOversizeRumorIsRefusedAtTheSealLayer() = + runTest { + val big = rumor("x".repeat(ConcordStreamEnvelope.NIP44_MAX_PLAINTEXT)) + assertFailsWith { ConcordStreamEnvelope.seal(big, stream, authorSigner, encrypted = true) } + } + + @Test + fun anOversizeSealIsRefusedAtTheWrapLayer() = + runTest { + // A plaintext seal carries the rumor verbatim, so only the wrap layer sees its size. + val big = rumor("x".repeat(ConcordStreamEnvelope.NIP44_MAX_PLAINTEXT - 200)) + val seal = ConcordStreamEnvelope.seal(big, stream, authorSigner, encrypted = false) + assertFailsWith { ConcordStreamEnvelope.wrapSeal(seal, stream) } + } + + @Test + fun aRumorJustUnderTheCapStillRoundTrips() = + runTest { + // Leave room for the seal and rumor JSON around the content, well within the cap. + val text = "y".repeat(30_000) + val wrap = ConcordStreamEnvelope.wrap(rumor(text), stream, authorSigner, encrypted = true) + assertEquals(text, ConcordStreamEnvelope.open(wrap, stream).rumor.content) + } + + @Test + fun anExtendedFormatWrapIsRefusedOnOpen() = + runTest { + // A lenient publisher: a genuine seal, wrapped with NIP-44's extended format (> 65,535 + // bytes of plaintext), correctly signed by the stream key. It must not open. + val seal = ConcordStreamEnvelope.seal(rumor("z".repeat(70_000)), stream, authorSigner, encrypted = false) + val content = Nip44.v2.encrypt(seal.toJson(), stream.conversationKey).encodePayload() + val streamSigner = NostrSignerSync(KeyPair(privKey = stream.secretKey)) + val wrap = streamSigner.signNormal(1_700_000_000L, ConcordStreamEnvelope.KIND_WRAP, arrayOf(arrayOf("p", KeyPair().pubKey.toHexKey())), content) + + assertNull(ConcordStreamEnvelope.openOrNull(wrap, stream)) + } +}