mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+133
-19
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-10
@@ -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
|
||||
|
||||
+15
@@ -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)
|
||||
}
|
||||
|
||||
+29
-7
@@ -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()
|
||||
|
||||
+9
-1
@@ -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())
|
||||
|
||||
+86
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+243
@@ -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)
|
||||
}
|
||||
}
|
||||
+93
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user