diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 385a85744e..9a876ca8f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent @@ -539,20 +540,20 @@ class MarmotInboundProcessor( // canonical epoch by construction. processCandidateBranchMessage(groupId, bytes) } else { - val innerJson = decrypted.content.decodeToString() + val payload = decrypted.content.decodeToString() + val author = payloadAuthor(payload) - // MIP-03: if the inner application payload is a Nostr event, - // its `pubkey` field MUST equal the MLS sender's credential - // identity. Reject any mismatch — otherwise a group member - // could mint events claiming a different author. Non-event - // payloads (raw bytes via buildGroupEventFromBytes) bypass - // this check since there is no author field to verify. + // `foundation/application-messages.md`, "Receiver + // authentication": the inner author MUST equal the account + // the MLS sender leaf authenticates. Without it any member + // could mint messages attributed to anyone else in the + // group. Payloads with no author field at all (raw bytes + // via buildGroupEventFromBytes) have nothing to compare. val senderIdentity = groupManager.memberIdentityHex(groupId, decrypted.senderLeafIndex) - val innerEvent = Event.fromJsonOrNull(innerJson) - if (innerEvent != null && (senderIdentity == null || innerEvent.pubKey != senderIdentity)) { + if (author != null && (senderIdentity == null || author != senderIdentity)) { return GroupEventResult.Error( groupId, - "MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)", + "inner event pubkey ($author) does not match MLS sender identity ($senderIdentity)", ) } @@ -561,13 +562,13 @@ class MarmotInboundProcessor( // incumbent is rebuilt and rescored at every resolution, so // counting only divergent branches would let any fork win // the witness steps unopposed. - if (innerEvent != null && senderIdentity != null) { + if (author != null && senderIdentity != null) { convergence.recordCanonicalWitness(groupId, decrypted.epoch, senderIdentity) } GroupEventResult.ApplicationMessage( groupId = groupId, - innerEventJson = innerJson, + innerEventJson = asEventShapedJson(payload), senderLeafIndex = decrypted.senderLeafIndex, epoch = decrypted.epoch, ) @@ -624,6 +625,36 @@ class MarmotInboundProcessor( } } + /** + * The account a payload claims as its author, or null when it has none. + * + * The canonical shape is tried first, because that is what a conformant + * peer sends and its checks are the strict ones. The legacy fall-back + * exists only for payloads this client itself wrote before the switch to + * the unsigned shape: those carry a `sig` member, which the strict decoder + * refuses by design. It is deliberately not a general "accept anything" + * path — a payload that is neither shape still has no author, and still + * fails the comparison rather than passing it. + */ + private fun payloadAuthor(payload: String): HexKey? = + MarmotAppEvent.decodeOrNull(payload)?.pubKey + ?: Event.fromJsonOrNull(payload)?.pubKey + + /** + * Re-shape a canonical payload into the Event-shaped JSON the app layer + * consumes. + * + * The application pipeline is built around `Event`, which requires a `sig` + * member; the wire form must not carry one. Rather than force every + * consumer to learn a second shape, the empty signature is re-added here at + * the boundary. The id is unaffected either way — NIP-01 never hashed the + * signature — so a message keeps one identity across the conversion. + */ + private fun asEventShapedJson(payload: String): String { + val appEvent = MarmotAppEvent.decodeOrNull(payload) ?: return payload + return appEvent.toJson().dropLast(1) + ",\"sig\":\"\"}" + } + /** * Try an app message against the retained candidate branches. * @@ -646,9 +677,9 @@ class MarmotInboundProcessor( "Application message decrypts on no canonical epoch or retained candidate branch", ) - val innerEvent = Event.fromJsonOrNull(candidate.content.decodeToString()) + val author = payloadAuthor(candidate.content.decodeToString()) val sender = candidate.senderAccount - val valid = innerEvent != null && sender != null && innerEvent.pubKey == sender + val valid = author != null && sender != null && author == sender if (valid && sender != null) { convergence.recordWitness(groupId, candidate.stateId, sender) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index 7d2da13955..a73888bef6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption @@ -84,7 +85,28 @@ class MarmotOutboundProcessor( suspend fun buildGroupEvent( nostrGroupId: HexKey, innerEvent: Event, - ): OutboundGroupEvent = buildGroupEventFromBytes(nostrGroupId, innerEvent.toJson().encodeToByteArray()) + ): OutboundGroupEvent = buildAppEvent(nostrGroupId, MarmotAppEvent.fromEvent(innerEvent)) + + /** + * Send a Marmot app event — the canonical, UNSIGNED payload shape + * (`foundation/application-messages.md`). + * + * The signature is dropped rather than merely left empty, and both halves + * of that matter. A conformant decoder REJECTS a payload carrying a `sig` + * member at all, so an event serialized with `"sig":""` is refused by every + * peer; and a payload with a real signature would be a valid standalone + * relay event, so one leaked plaintext could be republished publicly as a + * signed statement by its author. + * + * The event id is unchanged by the conversion: NIP-01 hashes + * `[0, pubkey, created_at, kind, tags, content]`, which never included the + * signature. Message identity therefore survives the switch, and history + * written under the old shape still lines up. + */ + suspend fun buildAppEvent( + nostrGroupId: HexKey, + appEvent: MarmotAppEvent, + ): OutboundGroupEvent = buildGroupEventFromBytes(nostrGroupId, appEvent.encodeToPayload()) /** * Encrypt raw bytes and build a GroupEvent for publishing. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt new file mode 100644 index 0000000000..5e63e03345 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEvent.kt @@ -0,0 +1,233 @@ +/* + * 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.marmot.foundation.appEvents + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher + +/** + * A Marmot app event — the plaintext inside an MLS application message + * (`foundation/application-messages.md`). + * + * It has the Nostr event shape **minus `sig`**, and the missing signature is + * the point rather than an omission. MLS already authenticates the sender as a + * group member and `pubkey` names the Marmot account that wrote it, so a + * signature would add nothing — while making every plaintext a valid standalone + * relay event, so one leaked message could be republished publicly as a signed + * statement by its author. A client MUST NOT sign one. + * + * The `id` is still the ordinary NIP-01 event id over + * `[0, pubkey, created_at, kind, tags, content]`, and decoders MUST reject a + * payload whose id does not match — which means every implementation has to + * produce byte-identical canonical JSON. That is why the id goes through + * [EventHasher] rather than being hashed over hand-built text here. + */ +class MarmotAppEvent( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Int, + val tags: TagArray, + val content: String, +) { + /** Whether [id] matches the canonical id of the other members. */ + fun hasValidId(): Boolean = EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) + + /** + * The canonical wire form: one UTF-8 JSON object with exactly the six + * members, in this order, and no others. + */ + fun toJson(): String { + val sb = StringBuilder(128 + content.length) + sb.append("{\"id\":\"").append(id) + sb.append("\",\"pubkey\":\"").append(pubKey) + sb.append("\",\"created_at\":").append(createdAt) + sb.append(",\"kind\":").append(kind) + sb.append(",\"tags\":") + appendTags(sb, tags) + sb.append(",\"content\":") + appendJsonString(sb, content) + sb.append('}') + return sb.toString() + } + + fun encodeToPayload(): ByteArray = toJson().encodeToByteArray() + + companion object { + /** Marmot's default ordinary chat kind. */ + const val KIND_CHAT = 9 + + /** In-place replacement of a prior message's text. */ + const val KIND_EDIT = 1009 + + /** A durable group system row, synthesized from canonical state. */ + const val KIND_SYSTEM = 1210 + + private val ALLOWED_MEMBERS = setOf("id", "pubkey", "created_at", "kind", "tags", "content") + + val EMPTY_TAGS: TagArray = emptyArray() + + /** + * Drop a signed Nostr event down to its Marmot app-event shape. + * + * The id survives unchanged: NIP-01 hashes + * `[0, pubkey, created_at, kind, tags, content]`, which never covered + * the signature. That is what lets a client switch to the canonical + * shape without renumbering its own history. + */ + fun fromEvent(event: Event) = + MarmotAppEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + kind = event.kind, + tags = event.tags, + content = event.content, + ) + + /** Build one, computing the canonical id from the other members. */ + fun build( + pubKey: HexKey, + kind: Int, + content: String, + createdAt: Long, + tags: TagArray = EMPTY_TAGS, + ) = MarmotAppEvent( + id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + ) + + /** + * Decode a Marmot app payload, strictly. + * + * Every rejection here is required by the spec, and each closes a + * different hole: + * + * - a `sig` member, because a signed inner event is republishable as a + * public statement by its author; + * - an unknown top-level member, because two implementations that + * disagree about what to ignore disagree about the id preimage; + * - a duplicate key, because "last one wins" and "first one wins" are + * both defensible and yield different events from identical bytes; + * - an id that does not match, because the id is what edits, history + * and deduplication all reference. + * + * @throws IllegalArgumentException naming the reason. + */ + fun decode(json: String): MarmotAppEvent { + val obj = MarmotJson.parseObject(json) + + require(!obj.containsKey("sig")) { + "Marmot app payload MUST NOT carry a Nostr signature" + } + val unknown = obj.keys - ALLOWED_MEMBERS + require(unknown.isEmpty()) { + "Marmot app payload has unknown member(s): ${unknown.sorted()}" + } + require(obj.duplicateKeys.isEmpty()) { + "Marmot app payload has duplicate key(s): ${obj.duplicateKeys.sorted()}" + } + val missing = ALLOWED_MEMBERS - obj.keys + require(missing.isEmpty()) { + "Marmot app payload is missing member(s): ${missing.sorted()}" + } + + val event = + MarmotAppEvent( + id = obj.string("id"), + pubKey = obj.string("pubkey"), + createdAt = obj.long("created_at"), + kind = obj.int("kind"), + tags = obj.tags("tags"), + content = obj.string("content"), + ) + require(event.hasValidId()) { + "Marmot app payload id does not match its canonical serialization" + } + return event + } + + /** [decode], returning null instead of throwing. */ + fun decodeOrNull(json: String): MarmotAppEvent? = + try { + decode(json) + } catch (_: Exception) { + null + } + + private fun appendTags( + sb: StringBuilder, + tags: TagArray, + ) { + sb.append('[') + for (i in tags.indices) { + if (i > 0) sb.append(',') + sb.append('[') + val tag = tags[i] + for (j in tag.indices) { + if (j > 0) sb.append(',') + appendJsonString(sb, tag[j]) + } + sb.append(']') + } + sb.append(']') + } + + /** + * NIP-01 string escaping. + * + * The escape set is exactly the one NIP-01 pins, because this text also + * feeds the id preimage: escaping one more character than a peer does + * changes the hash, and the payload is then rejected as having a bad id. + */ + private fun appendJsonString( + sb: StringBuilder, + value: String, + ) { + sb.append('"') + for (ch in value) { + when (ch) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + '\u0008' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") + else -> + if (ch < ' ') { + sb.append("\\u") + sb.append(ch.code.toString(16).padStart(4, '0')) + } else { + sb.append(ch) + } + } + } + sb.append('"') + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotJson.kt new file mode 100644 index 0000000000..022fc4d381 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotJson.kt @@ -0,0 +1,176 @@ +/* + * 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.marmot.foundation.appEvents + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +/** + * A parsed top-level JSON object, keeping the one thing every JSON library + * throws away: which keys appeared more than once. + * + * `foundation/application-messages.md` requires a decoder to REJECT a Marmot app + * payload with duplicate keys, and a `Map`-shaped parse cannot tell you that — + * it has already picked a winner. "Last one wins" and "first one wins" are both + * defensible, which is exactly the problem: two implementations would derive + * different events, and therefore different ids, from identical bytes. + */ +class MarmotJsonObject( + private val values: JsonObject, + /** Keys that appeared more than once at the top level. */ + val duplicateKeys: Set, +) { + val keys: Set get() = values.keys + + fun containsKey(name: String) = values.containsKey(name) + + fun string(name: String): String = requireNotNull(values[name]).jsonPrimitive.content + + fun long(name: String): Long = + requireNotNull(requireNotNull(values[name]).jsonPrimitive.content.toLongOrNull()) { + "$name is not an integer" + } + + fun int(name: String): Int = + requireNotNull(requireNotNull(values[name]).jsonPrimitive.content.toIntOrNull()) { + "$name is not an integer" + } + + fun tags(name: String): Array> { + val array = values[name] as? JsonArray ?: throw IllegalArgumentException("$name is not an array") + return Array(array.size) { i -> + val tag = array[i] as? JsonArray ?: throw IllegalArgumentException("$name[$i] is not an array") + Array(tag.size) { j -> + (tag[j] as? JsonPrimitive)?.content + ?: throw IllegalArgumentException("$name[$i][$j] is not a string") + } + } + } + + /** The raw element, for callers that need a nested object. */ + fun element(name: String) = values[name] +} + +/** Strict parsing helpers for Marmot app payloads. */ +object MarmotJson { + private val parser = Json { ignoreUnknownKeys = false } + + fun parseObject(json: String): MarmotJsonObject { + val element = parser.parseToJsonElement(json) + val obj = element as? JsonObject ?: throw IllegalArgumentException("payload is not a JSON object") + return MarmotJsonObject(obj, findDuplicateTopLevelKeys(json)) + } + + /** + * Scan the raw text for repeated top-level member names. + * + * A hand-rolled scan rather than a library call, because every JSON library + * this codebase has resolves duplicates before the caller sees them. It only + * has to be right about ONE thing — where a top-level key sits — so it + * tracks nesting depth and string boundaries and reads nothing else. + */ + private fun findDuplicateTopLevelKeys(json: String): Set { + val seen = mutableSetOf() + val duplicates = mutableSetOf() + var depth = 0 + var i = 0 + var expectingKey = false + + while (i < json.length) { + when (val ch = json[i]) { + '{' -> { + depth++ + if (depth == 1) expectingKey = true + } + + '}' -> depth-- + '[' -> depth++ + ']' -> depth-- + ',' -> if (depth == 1) expectingKey = true + '"' -> { + val end = endOfString(json, i) + if (depth == 1 && expectingKey) { + val key = unescape(json.substring(i + 1, end)) + if (!seen.add(key)) duplicates.add(key) + expectingKey = false + } + i = end + } + + else -> + if (ch != ' ' && ch != '\n' && ch != '\r' && ch != '\t' && ch != ':') { + expectingKey = false + } + } + i++ + } + return duplicates + } + + /** Index of the closing quote of the string starting at [start]. */ + private fun endOfString( + json: String, + start: Int, + ): Int { + var i = start + 1 + while (i < json.length) { + when (json[i]) { + '\\' -> i++ + '"' -> return i + } + i++ + } + throw IllegalArgumentException("unterminated string in payload") + } + + private fun unescape(raw: String): String { + if ('\\' !in raw) return raw + val sb = StringBuilder(raw.length) + var i = 0 + while (i < raw.length) { + val ch = raw[i] + if (ch != '\\' || i + 1 >= raw.length) { + sb.append(ch) + i++ + continue + } + when (val esc = raw[i + 1]) { + 'n' -> sb.append('\n') + 'r' -> sb.append('\r') + 't' -> sb.append('\t') + 'b' -> sb.append('\u0008') + 'f' -> sb.append('\u000C') + 'u' -> { + val hex = raw.substring(i + 2, minOf(i + 6, raw.length)) + sb.append(hex.toInt(16).toChar()) + i += 4 + } + + else -> sb.append(esc) + } + i += 2 + } + return sb.toString() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotMessageEdit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotMessageEdit.kt new file mode 100644 index 0000000000..ea63949556 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotMessageEdit.kt @@ -0,0 +1,98 @@ +/* + * 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.marmot.foundation.appEvents + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A kind `1009` message edit: an in-place replacement of a prior message's text + * (`foundation/application-messages.md`, "Message edits"). + * + * An edit is NOT chat. It must never render as its own row — the replacement is + * overlaid on the original body — and it must not advance an unread count: a + * reader who was caught up with the original is caught up with the edit. + */ +class MarmotMessageEdit( + /** Event id of the message being replaced. */ + val targetId: HexKey, + /** The replacement plaintext. Not JSON — a 1009's content is the body itself. */ + val replacement: String, + /** Orders competing edits. NOT a new activity timestamp for the target. */ + val createdAt: Long, + /** Account that authored the edit. */ + val author: HexKey, +) { + fun toAppEvent() = + MarmotAppEvent.build( + pubKey = author, + kind = MarmotAppEvent.KIND_EDIT, + content = replacement, + createdAt = createdAt, + tags = arrayOf(arrayOf("e", targetId)), + ) + + companion object { + /** + * Read an edit out of a decoded app event, or null when it is not one. + * + * Requires exactly one `e` tag: an edit that named several targets would + * leave every client to pick one, and they would not all pick the same. + */ + fun fromAppEvent(event: MarmotAppEvent): MarmotMessageEdit? { + if (event.kind != MarmotAppEvent.KIND_EDIT) return null + val targets = event.tags.filter { it.size >= 2 && it[0] == "e" } + if (targets.size != 1) return null + return MarmotMessageEdit( + targetId = targets[0][1], + replacement = event.content, + createdAt = event.createdAt, + author = event.pubKey, + ) + } + + /** + * Whether [edit] may replace a message authored by [originalAuthor]. + * + * Authorship is by Marmot ACCOUNT identity, not by leaf: a second device + * of the same account holds a different leaf and may still edit its own + * account's message. An edit from any other account is ignored outright + * — otherwise any member could rewrite anyone's words. + */ + fun isAuthorized( + edit: MarmotMessageEdit, + originalAuthor: HexKey, + ): Boolean = edit.author == originalAuthor + + /** + * The edit that wins for one target: the latest by `created_at`, with + * the event id breaking a tie. + * + * The tie-break matters more than it looks. Two devices of one account + * can stamp the same second, and without a deterministic rule two + * readers would render different text for the same message forever. + */ + fun selectOverlay(edits: List): MarmotMessageEdit? = + edits.maxWithOrNull( + compareBy { it.createdAt } + .thenBy { it.toAppEvent().id }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotSystemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotSystemEvent.kt new file mode 100644 index 0000000000..24a70aad34 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotSystemEvent.kt @@ -0,0 +1,177 @@ +/* + * 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.marmot.foundation.appEvents + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** The group-state changes a kind `1210` row can record. */ +enum class MarmotSystemType( + val wireName: String, + val defaultText: String, +) { + MEMBER_ADDED("member_added", "Member added"), + MEMBER_REMOVED("member_removed", "Member removed"), + MEMBER_LEFT("member_left", "Member left"), + ADMIN_ADDED("admin_added", "Admin added"), + ADMIN_REMOVED("admin_removed", "Admin removed"), + GROUP_RENAMED("group_renamed", "Group renamed"), + GROUP_AVATAR_CHANGED("group_avatar_changed", "Group avatar changed"), + GROUP_DISBANDED("group_disbanded", "Group disbanded"), + ; + + companion object { + fun fromWire(name: String): MarmotSystemType? = entries.firstOrNull { it.wireName == name } + } +} + +/** + * A kind `1210` group system row + * (`foundation/application-messages.md`, "Group system events"). + * + * These rows are **synthesized locally from canonical group state**, not + * received as messages. That is what makes them trustworthy: a row derived from + * an MLS-authenticated commit cannot be forged by one member, and every client + * that applies the same commit derives the same row. A client MUST NOT wait for + * a 1210 *message* to learn that group state changed — the state notification + * is authoritative, and a 1210 that does arrive over the wire is an assertion by + * its sender, not a derived fact. + * + * They are also not chat: render them separately, and never treat [text] as a + * chat body. + */ +class MarmotSystemEvent( + val systemType: MarmotSystemType, + /** Committing member, when the change is attributable. */ + val actor: HexKey?, + /** The member the change concerns, for the member/admin types. */ + val subject: HexKey? = null, + /** New group name, for [MarmotSystemType.GROUP_RENAMED]. */ + val name: String? = null, + /** Human-readable fallback. Clients SHOULD render from the structured fields instead. */ + val text: String = systemType.defaultText, +) { + /** + * The `content` JSON. + * + * Built by hand rather than via a serializer because this string is inside + * the app event's id preimage: a library that reorders members or spaces + * them differently would produce a different id for the same row, and a + * peer would reject it. + */ + fun toContentJson(): String { + val sb = StringBuilder(128) + sb.append("{\"v\":").append(SCHEMA_VERSION) + sb.append(",\"system_type\":\"").append(systemType.wireName).append("\"") + sb.append(",\"text\":") + appendJsonString(sb, text) + sb.append(",\"data\":{") + var first = true + actor?.let { + sb.append("\"actor\":\"").append(it).append("\"") + first = false + } + subject?.let { + if (!first) sb.append(',') + sb.append("\"subject\":\"").append(it).append("\"") + first = false + } + name?.let { + if (!first) sb.append(',') + sb.append("\"name\":") + appendJsonString(sb, it) + } + sb.append("}}") + return sb.toString() + } + + /** + * The complete app event for this row. + * + * [author] is the account the row is attributed to — the committer for an + * attributable change. The row is anchored to the epoch the change reached, + * so [createdAt] should be that moment, not the moment it was rendered. + */ + fun toAppEvent( + author: HexKey, + createdAt: Long, + ) = MarmotAppEvent.build( + pubKey = author, + kind = MarmotAppEvent.KIND_SYSTEM, + content = toContentJson(), + createdAt = createdAt, + tags = arrayOf(arrayOf("system", systemType.wireName)), + ) + + companion object { + const val SCHEMA_VERSION = 1 + + /** + * Parse a 1210 row's content, or null when it is not one we understand. + * + * An unknown `system_type` returns null rather than throwing: the + * registry grows, and protocol processing MUST NOT reject an otherwise + * valid app payload just because its semantics are unfamiliar. The + * caller delivers it and declines to render it. + */ + fun fromAppEvent(event: MarmotAppEvent): MarmotSystemEvent? { + if (event.kind != MarmotAppEvent.KIND_SYSTEM) return null + return try { + val obj = MarmotJson.parseObject(event.content) + if (obj.int("v") != SCHEMA_VERSION) return null + val type = MarmotSystemType.fromWire(obj.string("system_type")) ?: return null + val data = obj.element("data")?.let { MarmotJson.parseObject(it.toString()) } + MarmotSystemEvent( + systemType = type, + actor = data?.takeIf { it.containsKey("actor") }?.string("actor"), + subject = data?.takeIf { it.containsKey("subject") }?.string("subject"), + name = data?.takeIf { it.containsKey("name") }?.string("name"), + text = if (obj.containsKey("text")) obj.string("text") else type.defaultText, + ) + } catch (_: Exception) { + null + } + } + + private fun appendJsonString( + sb: StringBuilder, + value: String, + ) { + sb.append('"') + for (ch in value) { + when (ch) { + '"' -> sb.append("ESC\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> + if (ch < ' ') { + sb.append("\\u") + sb.append(ch.code.toString(16).padStart(4, '0')) + } else { + sb.append(ch) + } + } + } + sb.append('"') + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEventTest.kt new file mode 100644 index 0000000000..4937053aef --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/foundation/appEvents/MarmotAppEventTest.kt @@ -0,0 +1,229 @@ +/* + * 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.marmot.foundation.appEvents + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `foundation/application-messages.md` conformance. + * + * The load-bearing test is [matchesTheSpecPublishedSystemEventFixture]: the + * spec publishes one complete kind `1210` event together with the exact id it + * hashes to. Because decoders MUST reject a payload whose id does not match, + * canonical encoding is not a style question — one extra space or a reordered + * member and every peer rejects everything we send. Matching the published id + * is the only way to know we are right rather than merely self-consistent. + */ +class MarmotAppEventTest { + private val alice = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + @Test + fun matchesTheSpecPublishedSystemEventFixture() { + val row = MarmotSystemEvent(systemType = MarmotSystemType.GROUP_DISBANDED, actor = alice) + val event = row.toAppEvent(author = alice, createdAt = 1700000000L) + + assertEquals( + "{\"v\":1,\"system_type\":\"group_disbanded\"," + + "\"text\":\"Group disbanded\"," + + "\"data\":{\"actor\":\"" + alice + "\"}}", + row.toContentJson(), + ) + assertEquals("126e47076e4d0a75ed260b279c33ed433acd764fc80e2de2e0315a64116d1f52", event.id) + assertTrue(event.hasValidId()) + } + + @Test + fun roundTripsThroughItsCanonicalJson() { + val event = + MarmotAppEvent.build( + pubKey = alice, + kind = MarmotAppEvent.KIND_CHAT, + content = "hello", + createdAt = 1700000000L, + ) + val decoded = MarmotAppEvent.decode(event.toJson()) + assertEquals(event.id, decoded.id) + assertEquals(event.content, decoded.content) + assertEquals(event.toJson(), decoded.toJson()) + } + + /** + * A signed inner event would be republishable as a public statement by its + * author, which is exactly why the signature is left out — so a payload + * carrying one is refused, not merely ignored. + */ + @Test + fun rejectsAPayloadCarryingASignature() { + val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L) + val withSig = event.toJson().dropLast(1) + ",\"sig\":\"" + "0".repeat(128) + "\"}" + val failure = assertFailsWith { MarmotAppEvent.decode(withSig) } + assertTrue(failure.message!!.contains("signature")) + } + + /** Two implementations that disagree about what to ignore disagree about the id. */ + @Test + fun rejectsAnUnknownTopLevelMember() { + val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L) + val extra = event.toJson().dropLast(1) + ",\"nonce\":\"x\"}" + assertFailsWith { MarmotAppEvent.decode(extra) } + } + + /** + * "Last one wins" and "first one wins" are both defensible, which is the + * problem: identical bytes would yield different events, and so different + * ids, on two clients. + */ + @Test + fun rejectsDuplicateKeys() { + val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L) + val dup = event.toJson().dropLast(1) + ",\"content\":\"something else\"}" + val failure = assertFailsWith { MarmotAppEvent.decode(dup) } + assertTrue(failure.message!!.contains("duplicate")) + } + + /** A repeated key inside a nested VALUE is not a top-level duplicate. */ + @Test + fun doesNotConfuseANestedKeyForATopLevelOne() { + val nested = "{\"a\":1,\"a\":2}" + val event = MarmotAppEvent.build(alice, 1210, nested, 1700000000L) + assertEquals(event.id, MarmotAppEvent.decode(event.toJson()).id) + } + + @Test + fun rejectsAMismatchedId() { + val event = MarmotAppEvent.build(alice, 9, "hi", 1700000000L) + val tampered = event.toJson().replace("\"content\":\"hi\"", "\"content\":\"bye\"") + val failure = assertFailsWith { MarmotAppEvent.decode(tampered) } + assertTrue(failure.message!!.contains("id does not match")) + } + + @Test + fun rejectsAMissingMember() { + val incomplete = + "{\"id\":\"x\",\"pubkey\":\"" + alice + "\",\"created_at\":1,\"kind\":9,\"tags\":[]}" + assertFailsWith { MarmotAppEvent.decode(incomplete) } + } + + /** Escaping one more character than a peer does changes the hash. */ + @Test + fun escapesExactlyTheNip01Set() { + val awkward = "quote \"q\" backslash \\\\ newline \\n tab \\t" + val event = MarmotAppEvent.build(alice, 9, awkward, 1700000000L) + assertTrue(event.hasValidId(), "our serialization must agree with EventHasher's") + assertEquals(awkward, MarmotAppEvent.decode(event.toJson()).content) + } + + // --- kind 1009 ------------------------------------------------------- + + @Test + fun anEditNamesExactlyOneTarget() { + val edit = MarmotMessageEdit("a".repeat(64), "fixed", 1700000000L, alice) + val parsed = MarmotMessageEdit.fromAppEvent(edit.toAppEvent())!! + assertEquals("a".repeat(64), parsed.targetId) + assertEquals("fixed", parsed.replacement) + + // An edit naming two targets leaves each client to pick one, and they + // would not all pick the same. + val twoTargets = + MarmotAppEvent.build( + alice, + MarmotAppEvent.KIND_EDIT, + "fixed", + 1700000000L, + arrayOf(arrayOf("e", "a".repeat(64)), arrayOf("e", "b".repeat(64))), + ) + assertNull(MarmotMessageEdit.fromAppEvent(twoTargets)) + } + + /** Authorship is by ACCOUNT, so another device of the same account may edit. */ + @Test + fun onlyTheOriginalAuthorsAccountMayEdit() { + val bob = "b".repeat(64) + val mine = MarmotMessageEdit("a".repeat(64), "fixed", 1700000000L, alice) + assertTrue(MarmotMessageEdit.isAuthorized(mine, alice)) + + val theirs = MarmotMessageEdit("a".repeat(64), "vandalised", 1700000001L, bob) + assertFalse(MarmotMessageEdit.isAuthorized(theirs, alice)) + } + + /** + * Two devices of one account can stamp the same second; without a + * deterministic tie-break two readers would render different text for the + * same message forever. + */ + @Test + fun theLatestEditWinsAndTiesBreakDeterministically() { + val target = "a".repeat(64) + val older = MarmotMessageEdit(target, "first", 1700000000L, alice) + val newer = MarmotMessageEdit(target, "second", 1700000001L, alice) + assertEquals("second", MarmotMessageEdit.selectOverlay(listOf(newer, older))!!.replacement) + assertEquals("second", MarmotMessageEdit.selectOverlay(listOf(older, newer))!!.replacement) + + val tieA = MarmotMessageEdit(target, "aaa", 1700000005L, alice) + val tieB = MarmotMessageEdit(target, "bbb", 1700000005L, alice) + assertEquals( + MarmotMessageEdit.selectOverlay(listOf(tieA, tieB))!!.replacement, + MarmotMessageEdit.selectOverlay(listOf(tieB, tieA))!!.replacement, + ) + } + + // --- kind 1210 ------------------------------------------------------- + + @Test + fun aSystemRowRoundTripsItsStructuredFields() { + val row = + MarmotSystemEvent( + systemType = MarmotSystemType.MEMBER_ADDED, + actor = alice, + subject = "b".repeat(64), + ) + val parsed = MarmotSystemEvent.fromAppEvent(row.toAppEvent(alice, 1700000000L))!! + assertEquals(MarmotSystemType.MEMBER_ADDED, parsed.systemType) + assertEquals(alice, parsed.actor) + assertEquals("b".repeat(64), parsed.subject) + assertNull(parsed.name) + } + + @Test + fun aRenameCarriesTheNewName() { + val row = MarmotSystemEvent(MarmotSystemType.GROUP_RENAMED, actor = alice, name = "Book Club") + val parsed = MarmotSystemEvent.fromAppEvent(row.toAppEvent(alice, 1700000000L))!! + assertEquals("Book Club", parsed.name) + } + + /** + * The registry grows. Protocol processing MUST NOT reject an otherwise-valid + * app payload just because its semantics are unfamiliar — the payload still + * decodes; only the row interpretation is declined. + */ + @Test + fun anUnknownSystemTypeIsDeclinedNotRejected() { + val content = "{\"v\":1,\"system_type\":\"something_new\",\"text\":\"?\",\"data\":{}}" + val event = MarmotAppEvent.build(alice, MarmotAppEvent.KIND_SYSTEM, content, 1700000000L) + assertEquals(event.id, MarmotAppEvent.decode(event.toJson()).id) + assertNull(MarmotSystemEvent.fromAppEvent(event)) + } +}