fix(concord): strict chat binding, ms tag, chat kind gate, NIP-44 cap

Brings the CORD-01/03 Chat Plane primitives in quartz up to spec b84554e:

- I13: the binding is strict — exactly one `channel` and one `epoch` tag,
  the epoch compared as its canonical decimal string ("04", "+4" and
  duplicates no longer bind), matching Armada's uniqueTag /
  checkChannelBinding. Builders drop binding tags smuggled in extraTags
  (and no longer collapse repeated extra tags such as several emoji).
- I15: every ChannelChat rumor carries ["ms", 0..999] after the binding
  (CORD-02 §4, examples §2); MsTag parses strictly and a malformed or
  duplicated ms drops the rumor; edit recency uses the same basis.
- I16: the inline quote is the four-element ["q", id, "", author].
- S3 (quartz half): ChannelChat.delete builds the in-stream kind 5 of
  examples §2.4 (binding, e per target, k per target kind).
- S9 (chat half): ChannelChat.acceptOpened is the Chat ingest gate — a
  20013 seal, a CHAT_KINDS rumor (9, 1111, 7, 5, 3302, 23311, 1740), the
  strict binding and a well-formed ms; other planes' kinds are refused.
- S10: ConcordStreamEnvelope refuses to seal or wrap a plaintext over
  65,535 bytes instead of letting NIP-44 switch to its extended format,
  and refuses an extended-format payload on open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 15:20:55 +00:00
parent 1350533850
commit f231b95384
9 changed files with 656 additions and 41 deletions
@@ -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<Array<String>> = 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", <rumor id>, "", <author>]` 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<Array<String>> = 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<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor<ConcordChatEditEvent>(
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", <rumor id>]` per target, then one `["k", <kind>]` 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<Event>,
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<DeletionRequestEvent>(
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<Array<String>> = 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<Array<String>> = 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<Array<String>> = emptyArray(),
ms: Int = MsTag.remainderFor(createdAt),
): Event =
RumorAssembler.assembleRumor<ReactionEvent>(
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<IMetaTag>,
createdAt: Long,
extraTags: Array<Array<String>> = 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<Event>(
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<Int> =
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<Array<String>> = 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<String>>): Array<Array<String>> = extraTags.filterNot { it.isNotEmpty() && it[0] in BINDING_TAG_NAMES }.toTypedArray()
}
/**
@@ -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
@@ -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 <T : Event> TagArrayBuilder<T>.channel(channelId: HexKey) = addUnique(Channe
fun <T : Event> TagArrayBuilder<T>.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch))
/** The CORD-02 §4 sub-second remainder (0..999) of the rumor's send time. */
fun <T : Event> TagArrayBuilder<T>.ms(ms: Int) = addUnique(MsTag.assemble(ms))
/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */
fun <T : Event> TagArrayBuilder<T>.channelBinding(
channelId: HexKey,
@@ -44,3 +48,14 @@ fun <T : Event> TagArrayBuilder<T>.channelBinding(
channel(channelId)
epoch(epoch)
}
/** [channelBinding] plus the `["ms", …]` remainder every Chat rumor carries (CORD-02 §4). */
fun <T : Event> TagArrayBuilder<T>.channelBinding(
channelId: HexKey,
epoch: Long,
ms: Int,
) = apply {
channel(channelId)
epoch(epoch)
ms(ms)
}
@@ -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()
@@ -35,11 +35,19 @@ class EpochTag {
fun isTag(tag: Array<String>) = 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<String>): 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())
@@ -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<String>): 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<String> {
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)
}
}
}
@@ -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<Array<String>>()
/**
* 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)
}
}
/**
@@ -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<String>): Event = RumorAssembler.assembleRumor<Event>(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<IllegalArgumentException> { 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<IllegalArgumentException> { 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<Event>(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<Event>(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)
}
}
@@ -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<Event>(
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<IllegalArgumentException> { 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<IllegalArgumentException> { 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<Event>(1_700_000_000L, ConcordStreamEnvelope.KIND_WRAP, arrayOf(arrayOf("p", KeyPair().pubKey.toHexKey())), content)
assertNull(ConcordStreamEnvelope.openOrNull(wrap, stream))
}
}