From 3e9c80fed284947d6ecb78080e5fb8162347a07e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 01:55:21 +0000 Subject: [PATCH] feat(contextvm): CEP-4/19 encryption, CEP-6/17/23/24 discovery, CEP-35 learning Build items 3, 5, 6 and 15. CEP-4/19 gift wrap. The scheme is a simplified NIP-59: the inner kind-25910 event is signed first and the whole signed event is NIP-44 encrypted into the wrap, with no separate seal and no unsigned rumor. unwrap() verifies the inner signature, because the wrap's own signature only proves a throwaway key signed it -- without that check anyone able to encrypt to us could claim any pubkey. Each wrap uses a fresh key so two messages from one sender are unlinkable, and the NIP-59 timestamp is shifted and documented as unusable for ordering. EncryptionMode defaults to REQUIRED rather than mirroring the reference SDK's permissive default, and fails closed: under OPTIONAL a coordinator that simply never advertises encryption receives plaintext JSON-RPC on public relays. GiftWrapMode prefers the ephemeral 21059 but falls back to 1059 for a peer without it, since CEP-19 is additive and preferring it must never mean refusing to talk. Discovery covers CEP-6 announcements (11316-11320, newest kept per kind since they are replaceable), CEP-17 relay lists where the ContextVM profile is an unmarked r tag meaning both directions, CEP-24 review tagging including the asymmetry where a reply keeps the uppercase root on the announcement while the lowercase parent moves to the comment, and CEP-35 session learning where the first message each way sets the baseline and unknown tags are preserved with raw access. Also corrects a design error the crypto tests caught: CvmMessageEvent was an Event subclass whose create() claimed to return that subclass, but quartz mints event subclasses through its own kind-to-class factory, which knows nothing about 25910. Every event off the wire or out of a signer is a plain Event, so the subclass could only ever exist where we built one by hand and the cast failed at runtime. It is now a wrapper over Event, which is both honest and what the parsing side actually wanted. 40 new tests across CVM-4, CVM-19, CVM-6, CVM-17, CVM-24 and CVM-35, with the gift wrap round trip in jvmTest against real NIP-44 and secp256k1. Module suite at 152. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n --- .../contextvm/core/CvmMessageEvent.kt | 53 ++-- .../contextvm/crypto/CvmGiftWrap.kt | 223 +++++++++++++++ .../contextvm/discovery/ServerDiscovery.kt | 266 ++++++++++++++++++ .../contextvm/core/CvmMessageEventTest.kt | 16 +- .../contextvm/crypto/CvmGiftWrapPolicyTest.kt | 108 +++++++ .../contextvm/discovery/DiscoveryTest.kt | 223 +++++++++++++++ .../contextvm/crypto/CvmGiftWrapCryptoTest.kt | 162 +++++++++++ 7 files changed, 1026 insertions(+), 25 deletions(-) create mode 100644 contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrap.kt create mode 100644 contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/discovery/ServerDiscovery.kt create mode 100644 contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapPolicyTest.kt create mode 100644 contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/discovery/DiscoveryTest.kt create mode 100644 contextvm/src/jvmTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapCryptoTest.kt diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt index 41748946f3..e836fc9066 100644 --- a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEvent.kt @@ -33,13 +33,18 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.TimeUtils /** - * A ContextVM message: kind 25910, carrying a stringified MCP JSON-RPC message - * in `content`. + * A typed view over a ContextVM message event: kind 25910, carrying a + * stringified MCP JSON-RPC message in `content`. * - * The ContextVM layering is deliberately thin — the MCP message is preserved - * byte-for-byte and only addressing and correlation move into tags: - * - `p` names the peer this message is for - * - `e` references the request event a response answers + * This wraps an [Event] rather than extending it. Quartz mints event subclasses + * through its own kind-to-class factory, which knows nothing about 25910, so an + * event signed or parsed anywhere is always a plain [Event] — a subclass would + * only ever exist where we constructed one by hand, and claiming a signer could + * return one is simply false. + * + * The ContextVM layering is deliberately thin: the MCP message is preserved + * byte-for-byte and only addressing and correlation move into tags, `p` for the + * peer and `e` for the request a response answers. * * `content` is a **JSON string**, not an embedded JSON object. The spec's * examples show it unstringified for readability, which is an easy trap; rule @@ -49,18 +54,19 @@ import com.vitorpamplona.quartz.utils.TimeUtils * that was already live when the peer published it (`CVM-CORE-06`). */ class CvmMessageEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: TagArray, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + val event: Event, +) { + val id: HexKey get() = event.id + val pubKey: HexKey get() = event.pubKey + val createdAt: Long get() = event.createdAt + val tags: TagArray get() = event.tags + val content: String get() = event.content + /** The peer this message is addressed to, or null when untagged. */ - fun recipient(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey) + fun recipient(): HexKey? = event.tags.firstNotNullOfOrNull(PTag::parseKey) /** The request event id this message answers, or null when it is not a response. */ - fun inReplyTo(): HexKey? = tags.firstNotNullOfOrNull(ETag::parseId) + fun inReplyTo(): HexKey? = event.tags.firstNotNullOfOrNull(ETag::parseId) /** * The JSON-RPC message in `content`. @@ -68,7 +74,7 @@ class CvmMessageEvent( * @throws com.vitorpamplona.contextvm.jsonrpc.JsonRpcFormatException when * `content` is not a well-formed JSON-RPC 2.0 message. */ - fun message(): JsonRpcMessage = JsonRpcCodec.decode(content) + fun message(): JsonRpcMessage = JsonRpcCodec.decode(event.content) /** * The discovery tags this message carries, per CEP-35. @@ -76,11 +82,14 @@ class CvmMessageEvent( * Routing tags are excluded; everything else is preserved, including tags we * do not understand, because CEP-35 makes forward compatibility the default. */ - fun discoveryTags(): List = tags.filter { it.isNotEmpty() && !CvmTags.isRouting(it[0]) } + fun discoveryTags(): List = event.tags.filter { it.isNotEmpty() && !CvmTags.isRouting(it[0]) } companion object { const val KIND = CvmKinds.MESSAGE + /** Wraps [event] when it is a ContextVM message, or returns null. */ + fun fromOrNull(event: Event) = if (event.kind == KIND) CvmMessageEvent(event) else null + /** * Template for a message addressed to [recipient], optionally answering * [inReplyTo]. @@ -94,10 +103,16 @@ class CvmMessageEvent( inReplyTo: HexKey? = null, extraTags: List = emptyList(), createdAt: Long = TimeUtils.now(), - ) = eventTemplate(KIND, JsonRpcCodec.encode(message), createdAt) { + ) = eventTemplate(KIND, JsonRpcCodec.encode(message), createdAt) { addAll(assembleTags(recipient, inReplyTo, extraTags)) } + /** + * Signs a message for [recipient]. + * + * Returns a plain [Event] because that is what the signer produces; wrap + * it with [fromOrNull] when a typed view is wanted. + */ suspend fun create( message: JsonRpcMessage, recipient: HexKey, @@ -105,7 +120,7 @@ class CvmMessageEvent( inReplyTo: HexKey? = null, extraTags: List = emptyList(), createdAt: Long = TimeUtils.now(), - ): CvmMessageEvent = + ): Event = signer.sign( createdAt, KIND, diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrap.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrap.kt new file mode 100644 index 0000000000..4cf2402b23 --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrap.kt @@ -0,0 +1,223 @@ +/* + * 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.contextvm.crypto + +import com.vitorpamplona.contextvm.core.CvmKinds +import com.vitorpamplona.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Whether ContextVM messages must be encrypted. + * + * Defaults to [REQUIRED] rather than mirroring the reference SDK's permissive + * default. Under `OPTIONAL` a coordinator that simply does not advertise + * encryption receives plaintext JSON-RPC on public relays, exposing every tool + * argument to any relay operator. Failing closed is the only safe default for a + * messaging client. + */ +enum class EncryptionMode { + /** Encrypt always; refuse to talk to a peer that cannot. */ + REQUIRED, + + /** Encrypt when the peer advertises support. Opt in deliberately. */ + OPTIONAL, + + /** Never encrypt. For test fixtures and diagnostics only. */ + DISABLED, +} + +/** Which gift wrap kind to use (CEP-19). */ +enum class GiftWrapMode { + /** Kind 21059 — ephemeral, so relays do not retain the envelope. */ + EPHEMERAL, + + /** Kind 1059 — persistent, for deliberately retained delivery. */ + PERSISTENT, +} + +/** Thrown when a gift wrap cannot be produced or trusted. */ +class CvmEncryptionException( + message: String, +) : IllegalStateException(message) + +/** + * CEP-4 and CEP-19 message encryption. + * + * The scheme is a simplified NIP-59: the inner kind-25910 event is **signed + * first**, then the whole signed event JSON is NIP-44 encrypted to the + * recipient and placed in a gift wrap. There is no separate seal and no unsigned + * rumor, so a receiver verifies the inner signature and correlates on the + * **inner** event id. + * + * What this hides and what it does not: the sender, the inner kind and the real + * timestamp are concealed, but the recipient's pubkey is a `p` tag on the wrap + * and is therefore visible to relays. CEP-4 states that limitation outright; it + * is inherent to the addressing model, not a defect here. + */ +class CvmGiftWrap( + private val encryptionMode: EncryptionMode = EncryptionMode.REQUIRED, + private val giftWrapMode: GiftWrapMode = GiftWrapMode.EPHEMERAL, +) { + /** The wrap kind this client emits. */ + fun wrapKind(): Kind = + when (giftWrapMode) { + GiftWrapMode.EPHEMERAL -> CvmKinds.EPHEMERAL_GIFT_WRAP + GiftWrapMode.PERSISTENT -> CvmKinds.GIFT_WRAP + } + + /** + * The kind to actually use against a peer. + * + * CEP-19 requires falling back to the persistent wrap for a peer that does + * not advertise ephemeral support, so preferring 21059 must never mean + * refusing to talk to a 1059-only server. + */ + fun negotiatedWrapKind(peerSupportsEphemeral: Boolean): Kind = + if (giftWrapMode == GiftWrapMode.EPHEMERAL && peerSupportsEphemeral) { + CvmKinds.EPHEMERAL_GIFT_WRAP + } else { + CvmKinds.GIFT_WRAP + } + + /** + * Whether a message to this peer must be encrypted. + * + * @throws CvmEncryptionException under [EncryptionMode.REQUIRED] when the + * peer cannot encrypt — failing loudly rather than downgrading. + */ + fun shouldEncrypt(peerSupportsEncryption: Boolean): Boolean = + when (encryptionMode) { + EncryptionMode.DISABLED -> false + EncryptionMode.OPTIONAL -> peerSupportsEncryption + EncryptionMode.REQUIRED -> + if (peerSupportsEncryption) { + true + } else { + throw CvmEncryptionException( + "peer does not advertise encryption and this client requires it", + ) + } + } + + /** + * Wraps an already-signed inner event for [recipient]. + * + * The wrap is signed by a fresh throwaway key, so nothing links two wraps + * from the same sender. Its `created_at` is randomized per NIP-59 and must + * never be used for ordering. + */ + suspend fun wrap( + inner: Event, + recipient: HexKey, + kind: Kind = wrapKind(), + ): Event { + if (!CvmKinds.isGiftWrap(kind)) { + throw CvmEncryptionException("$kind is not a gift wrap kind") + } + + val wrapSigner = NostrSignerInternal(KeyPair()) + val ciphertext = wrapSigner.nip44Encrypt(OptimizedJsonMapper.toJson(inner), recipient) + + return wrapSigner.sign( + createdAt = randomizedTimestamp(), + kind = kind, + tags = arrayOf(PTag.assemble(recipient, relayHint = null)), + content = ciphertext, + ) + } + + /** + * Unwraps [giftWrap] and returns the signed inner event. + * + * The inner signature is verified here: without that check anyone able to + * encrypt to us could impersonate any pubkey, since the wrap's own signature + * only proves the throwaway key signed it. + */ + suspend fun unwrap( + giftWrap: Event, + signer: NostrSigner, + ): Event { + if (!CvmKinds.isGiftWrap(giftWrap.kind)) { + throw CvmEncryptionException("${giftWrap.kind} is not a gift wrap kind") + } + + val json = + try { + signer.nip44Decrypt(giftWrap.content, giftWrap.pubKey) + } catch (e: Exception) { + throw CvmEncryptionException("could not decrypt gift wrap: ${e.message}") + } + + val inner = + try { + OptimizedJsonMapper.fromJson(json) + } catch (e: Exception) { + throw CvmEncryptionException("gift wrap did not contain an event: ${e.message}") + } + + if (!inner.verify()) { + throw CvmEncryptionException("inner event signature is invalid") + } + + return inner + } + + /** Reads whether a peer advertised encryption support (CEP-4). */ + fun peerSupportsEncryption(tags: Array>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION) + + /** Reads whether a peer advertised ephemeral wrap support (CEP-19). */ + fun peerSupportsEphemeral(tags: Array>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL) + + /** The capability flags this client advertises to peers. */ + fun capabilityTags(): List> = + buildList { + if (encryptionMode != EncryptionMode.DISABLED) { + add(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)) + if (giftWrapMode == GiftWrapMode.EPHEMERAL) { + add(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + } + } + } + + /** + * A timestamp shifted randomly into the recent past, per NIP-59. + * + * Consumers must not order on it — it is deliberately not the real send + * time. + */ + private fun randomizedTimestamp(): Long { + val now = TimeUtils.now() + return now - (0..TWO_DAYS_SECONDS).random() + } + + companion object { + private const val TWO_DAYS_SECONDS = 2 * 24 * 60 * 60L + } +} diff --git a/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/discovery/ServerDiscovery.kt b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/discovery/ServerDiscovery.kt new file mode 100644 index 0000000000..7393ba12ed --- /dev/null +++ b/contextvm/src/commonMain/kotlin/com/vitorpamplona/contextvm/discovery/ServerDiscovery.kt @@ -0,0 +1,266 @@ +/* + * 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.contextvm.discovery + +import com.vitorpamplona.contextvm.core.CvmKinds +import com.vitorpamplona.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * What a peer told us about itself. + * + * CEP-35 makes this a *session* concept rather than an announcement one: the + * same tag vocabulary arrives either on a public announcement (CEP-6) or on the + * first direct message of a session, and both are treated as equivalent. + */ +data class DiscoverySurface( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val website: String? = null, + val supportsEncryption: Boolean = false, + val supportsEphemeralEncryption: Boolean = false, + val supportsOversizedTransfer: Boolean = false, + val supportsOpenStream: Boolean = false, + /** + * Everything else, routing excluded. + * + * CEP-35 requires unknown discovery tags to be preserved rather than + * discarded, so custom protocols can build on the same exchange. Keeping + * them reachable is what makes that work. + */ + val unknownTags: List = emptyList(), +) { + /** Raw access for a caller that understands a tag this version does not. */ + fun rawTag(name: String): Tag? = unknownTags.firstOrNull { it.isNotEmpty() && it[0] == name } + + companion object { + private val KNOWN = + setOf( + CvmTags.NAME, + CvmTags.ABOUT, + CvmTags.PICTURE, + CvmTags.WEBSITE, + CvmTags.SUPPORT_ENCRYPTION, + CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL, + CvmTags.SUPPORT_OVERSIZED_TRANSFER, + CvmTags.SUPPORT_OPEN_STREAM, + ) + + fun parse(tags: Array): DiscoverySurface { + fun value(name: String) = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1) + + return DiscoverySurface( + name = value(CvmTags.NAME), + about = value(CvmTags.ABOUT), + picture = value(CvmTags.PICTURE), + website = value(CvmTags.WEBSITE), + supportsEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION), + supportsEphemeralEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL), + supportsOversizedTransfer = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OVERSIZED_TRANSFER), + supportsOpenStream = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OPEN_STREAM), + unknownTags = + tags.filter { + it.isNotEmpty() && it[0] !in KNOWN && !CvmTags.isRouting(it[0]) + }, + ) + } + } +} + +/** + * Per-session capability learning (CEP-35). + * + * Discovery is a first-message exchange in each direction, not an + * initialize-only step: a server-to-client message that is not an initialize + * response may still carry the server's baseline. After that exchange, later + * feature tags are message-local and do not mutate the baseline unless a + * feature-specific CEP says they do — CEP-8's `payment_interaction` upsert being + * the one that does. + */ +class SessionDiscovery { + private var baseline: DiscoverySurface? = null + + /** The peer's learned baseline, or null before their first message. */ + val peer get() = baseline + + val hasLearned get() = baseline != null + + /** + * Applies a peer message's tags. + * + * The first one establishes the baseline; later ones are returned for + * message-local interpretation but leave the baseline alone. + */ + fun observe(tags: Array): DiscoverySurface { + val surface = DiscoverySurface.parse(tags) + if (baseline == null) baseline = surface + return surface + } + + /** Replaces the baseline outright. For a feature CEP that defines an update. */ + fun replaceBaseline(tags: Array) { + baseline = DiscoverySurface.parse(tags) + } +} + +/** + * A CEP-6 announcement. + * + * `content` is the stringified result of the matching MCP call — the initialize + * result for a server announcement, a list result for the others — so it is left + * as text for the caller to decode with the same codec it uses on the wire. + */ +data class ServerAnnouncement( + val kind: Kind, + val pubKey: HexKey, + val createdAt: Long, + val content: String, + val discovery: DiscoverySurface, +) { + companion object { + fun parseOrNull(event: Event): ServerAnnouncement? { + if (event.kind !in CvmKinds.ANNOUNCEMENTS) return null + return ServerAnnouncement( + kind = event.kind, + pubKey = event.pubKey, + createdAt = event.createdAt, + content = event.content, + discovery = DiscoverySurface.parse(event.tags), + ) + } + + /** + * Keeps the newest announcement per `(kind, pubkey)`. + * + * These kinds are replaceable, so an older event arriving late from a + * lagging relay must not overwrite a newer one already held. + */ + fun latestPerKind(events: List): Map = + events + .mapNotNull { parseOrNull(it) } + .groupBy { it.kind } + .mapValues { (_, list) -> list.maxBy { it.createdAt } } + } +} + +/** + * A relay a server is reachable on (CEP-17, NIP-65 kind 10002). + * + * The ContextVM profile publishes unmarked tags, meaning the relay serves both + * directions. Markers are honoured when present but are not the norm here. + */ +data class ServerRelay( + val url: String, + val read: Boolean = true, + val write: Boolean = true, +) { + companion object { + const val READ = "read" + const val WRITE = "write" + + fun parseAll(event: Event): List { + if (event.kind != CvmKinds.RELAY_LIST) return emptyList() + return event.tags + .filter { it.size >= 2 && it[0] == CvmTags.RELAY && it[1].isNotBlank() } + .map { tag -> + when (tag.getOrNull(2)) { + READ -> ServerRelay(tag[1], read = true, write = false) + WRITE -> ServerRelay(tag[1], read = false, write = true) + // Unmarked is the recommended ContextVM profile: the + // relay is usable for both publishing and subscribing. + else -> ServerRelay(tag[1]) + } + } + } + + /** + * Relays usable for a full request/response exchange. + * + * A read-only or write-only relay cannot carry both halves, and since + * kind 25910 is ephemeral there is no fetching a response later from + * somewhere else. + */ + fun operational(relays: List) = relays.filter { it.read && it.write } + } +} + +/** CEP-24 server reviews: NIP-22 comments anchored to a kind-11316 announcement. */ +object ServerReview { + /** The addressable coordinate a review targets. */ + fun coordinate(serverPubKey: HexKey) = "${CvmKinds.SERVER_ANNOUNCEMENT}:$serverPubKey:" + + /** + * Tags for a top-level review. + * + * NIP-22 uses uppercase tags for the root and lowercase for the immediate + * parent; for a top-level comment both are the announcement, hence the + * apparent duplication. + */ + fun topLevelTags( + serverPubKey: HexKey, + relayHint: String? = null, + announcementEventId: HexKey? = null, + ): List { + val coordinate = coordinate(serverPubKey) + val kind = CvmKinds.SERVER_ANNOUNCEMENT.toString() + return buildList { + add(tagOf("A", coordinate, relayHint)) + add(arrayOf("K", kind)) + add(tagOf("P", serverPubKey, relayHint)) + add(tagOf("a", coordinate, relayHint)) + announcementEventId?.let { add(arrayOf("e", it, relayHint ?: "", serverPubKey)) } + add(arrayOf("k", kind)) + add(tagOf("p", serverPubKey, relayHint)) + } + } + + /** + * Tags for a reply to an existing review. + * + * The uppercase root stays on the announcement while the lowercase parent + * moves to the comment being answered — that asymmetry is the whole point of + * NIP-22's dual tagging and is easy to get wrong. + */ + fun replyTags( + serverPubKey: HexKey, + parentCommentId: HexKey, + parentAuthor: HexKey, + relayHint: String? = null, + ): List = + buildList { + add(tagOf("A", coordinate(serverPubKey), relayHint)) + add(arrayOf("K", CvmKinds.SERVER_ANNOUNCEMENT.toString())) + add(tagOf("P", serverPubKey, relayHint)) + add(arrayOf("e", parentCommentId, relayHint ?: "", parentAuthor)) + add(arrayOf("k", CvmKinds.REVIEW.toString())) + add(tagOf("p", parentAuthor, relayHint)) + } + + private fun tagOf( + name: String, + value: String, + relayHint: String?, + ): Tag = if (relayHint != null) arrayOf(name, value, relayHint) else arrayOf(name, value) +} diff --git a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt index 42ed85a9ea..91d588dd3c 100644 --- a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt +++ b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/core/CvmMessageEventTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.contextvm.core import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import kotlin.test.Test import kotlin.test.assertContentEquals @@ -130,11 +131,14 @@ class CvmMessageEventTest { private fun event(tags: Array>) = CvmMessageEvent( - id = "c".repeat(64), - pubKey = "d".repeat(64), - createdAt = 1_700_000_000L, - tags = tags, - content = JsonRpcCodec.encode(ping), - sig = "e".repeat(128), + Event( + id = "c".repeat(64), + pubKey = "d".repeat(64), + createdAt = 1_700_000_000L, + kind = CvmMessageEvent.KIND, + tags = tags, + content = JsonRpcCodec.encode(ping), + sig = "e".repeat(128), + ), ) } diff --git a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapPolicyTest.kt b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapPolicyTest.kt new file mode 100644 index 0000000000..587fbc36c8 --- /dev/null +++ b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapPolicyTest.kt @@ -0,0 +1,108 @@ +/* + * 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.contextvm.crypto + +import com.vitorpamplona.contextvm.core.CvmKinds +import com.vitorpamplona.contextvm.core.CvmTags +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** `CVM-4-*` and `CVM-19-*`: encryption policy and wrap-kind negotiation. */ +class CvmGiftWrapPolicyTest { + @Test + fun `CVM-4-01 REQUIRED refuses to downgrade for a peer that cannot encrypt`() { + // The whole reason this client does not use the permissive default: + // a coordinator that never advertises encryption would otherwise get + // plaintext JSON-RPC on public relays. + val crypto = CvmGiftWrap(encryptionMode = EncryptionMode.REQUIRED) + assertTrue(crypto.shouldEncrypt(peerSupportsEncryption = true)) + assertFailsWith { crypto.shouldEncrypt(peerSupportsEncryption = false) } + } + + @Test + fun `CVM-4-02 OPTIONAL follows the peer and DISABLED never encrypts`() { + val optional = CvmGiftWrap(encryptionMode = EncryptionMode.OPTIONAL) + assertTrue(optional.shouldEncrypt(peerSupportsEncryption = true)) + assertFalse(optional.shouldEncrypt(peerSupportsEncryption = false)) + + val disabled = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + assertFalse(disabled.shouldEncrypt(peerSupportsEncryption = true)) + } + + @Test + fun `CVM-19-01 prefers the ephemeral wrap when the peer supports it`() { + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL) + assertEquals(CvmKinds.EPHEMERAL_GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = true)) + } + + @Test + fun `CVM-19-02 falls back to the persistent wrap rather than refusing`() { + // Preferring 21059 must never mean refusing to talk to a 1059-only + // server: CEP-19 is additive, not a requirement. + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL) + assertEquals(CvmKinds.GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = false)) + } + + @Test + fun `CVM-19-03 a persistent-mode client stays on 1059 regardless`() { + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT) + assertEquals(CvmKinds.GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = true)) + } + + @Test + fun `CVM-4-03 advertises the capability flags matching its own configuration`() { + val ephemeral = CvmGiftWrap().capabilityTags().map { it[0] } + assertTrue(ephemeral.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue(ephemeral.contains(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + + val persistent = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT).capabilityTags().map { it[0] } + assertTrue(persistent.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertFalse( + persistent.contains(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL), + "a persistent-mode client must not claim ephemeral support", + ) + + assertTrue(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED).capabilityTags().isEmpty()) + } + + @Test + fun `CVM-4-04 reads the peer's advertised encryption flags`() { + val crypto = CvmGiftWrap() + val tags = arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue(crypto.peerSupportsEncryption(tags)) + assertFalse(crypto.peerSupportsEphemeral(tags)) + + val both = tags + arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + assertTrue(crypto.peerSupportsEphemeral(both)) + } + + @Test + fun `CVM-19-04 both wrap kinds are recognised on the way in`() { + // A client subscribes to both because CEP-19's fallback means either may + // arrive regardless of which it sends. + assertTrue(CvmKinds.isGiftWrap(CvmKinds.GIFT_WRAP)) + assertTrue(CvmKinds.isGiftWrap(CvmKinds.EPHEMERAL_GIFT_WRAP)) + assertFalse(CvmKinds.isGiftWrap(CvmKinds.MESSAGE)) + } +} diff --git a/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/discovery/DiscoveryTest.kt b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/discovery/DiscoveryTest.kt new file mode 100644 index 0000000000..334461c8f5 --- /dev/null +++ b/contextvm/src/commonTest/kotlin/com/vitorpamplona/contextvm/discovery/DiscoveryTest.kt @@ -0,0 +1,223 @@ +/* + * 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.contextvm.discovery + +import com.vitorpamplona.contextvm.core.CvmKinds +import com.vitorpamplona.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `CVM-6-*`, `CVM-17-*`, `CVM-24-*` and `CVM-35-*`. */ +class DiscoveryTest { + private val serverPubKey = "a".repeat(64) + + private fun event( + kind: Kind, + tags: Array, + content: String = "", + createdAt: Long = 1_700_000_000L, + pubKey: String = serverPubKey, + ) = Event( + id = "c".repeat(64), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "e".repeat(128), + ) + + // --- CEP-6 / CEP-35 discovery surface --- + + @Test + fun `CVM-6-01 parses the announcement discovery tags`() { + val surface = + DiscoverySurface.parse( + arrayOf( + arrayOf("name", "Example Server"), + arrayOf("about", "Public MCP provider"), + arrayOf("picture", "https://example.com/a.png"), + arrayOf("website", "https://example.com"), + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + CvmTags.flag(CvmTags.SUPPORT_OPEN_STREAM), + ), + ) + assertEquals("Example Server", surface.name) + assertEquals("https://example.com", surface.website) + assertTrue(surface.supportsEncryption) + assertTrue(surface.supportsOpenStream) + assertFalse(surface.supportsOversizedTransfer) + } + + @Test + fun `CVM-35-01 preserves unknown discovery tags and excludes routing`() { + val surface = + DiscoverySurface.parse( + arrayOf( + arrayOf("p", serverPubKey), + arrayOf("e", "b".repeat(64)), + arrayOf("some_future_capability", "v2"), + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + ), + ) + assertEquals(1, surface.unknownTags.size, "only the unrecognised tag is retained") + assertContentEquals(arrayOf("some_future_capability", "v2"), surface.rawTag("some_future_capability")) + assertNull(surface.rawTag("p"), "routing tags are not discovery") + } + + @Test + fun `CVM-35-02 the first peer message establishes the baseline`() { + val session = SessionDiscovery() + assertFalse(session.hasLearned) + + session.observe(arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), arrayOf("name", "First"))) + assertTrue(session.hasLearned) + assertEquals("First", session.peer?.name) + assertTrue(session.peer!!.supportsEncryption) + } + + @Test + fun `CVM-35-03 a later message is interpreted locally without mutating the baseline`() { + val session = SessionDiscovery() + session.observe(arrayOf(arrayOf("name", "First"), CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION))) + + val local = session.observe(arrayOf(arrayOf("cap", "tool:x", "100", "sats"))) + assertEquals(1, local.unknownTags.size, "the later tags are returned for local use") + assertEquals("First", session.peer?.name, "the baseline is unchanged") + assertTrue(session.peer!!.supportsEncryption) + } + + @Test + fun `CVM-35-04 a feature CEP can replace the baseline explicitly`() { + val session = SessionDiscovery() + session.observe(arrayOf(arrayOf("name", "First"))) + session.replaceBaseline(arrayOf(arrayOf("name", "Renamed"))) + assertEquals("Renamed", session.peer?.name) + } + + // --- CEP-6 announcements --- + + @Test + fun `CVM-6-02 parses each announcement kind and rejects others`() { + CvmKinds.ANNOUNCEMENTS.forEach { kind -> + assertNotNull(ServerAnnouncement.parseOrNull(event(kind, emptyArray(), """{"tools":[]}"""))) + } + assertNull(ServerAnnouncement.parseOrNull(event(1, emptyArray()))) + } + + @Test + fun `CVM-6-03 keeps the newest announcement per kind`() { + // Replaceable kinds: a stale event from a lagging relay must not + // overwrite a newer one already held. + val older = event(CvmKinds.SERVER_ANNOUNCEMENT, arrayOf(arrayOf("name", "old")), createdAt = 100) + val newer = event(CvmKinds.SERVER_ANNOUNCEMENT, arrayOf(arrayOf("name", "new")), createdAt = 200) + + val latest = ServerAnnouncement.latestPerKind(listOf(newer, older)) + assertEquals("new", latest[CvmKinds.SERVER_ANNOUNCEMENT]?.discovery?.name) + } + + @Test + fun `CVM-6-04 leaves the announcement content as text for the caller to decode`() { + val announcement = + ServerAnnouncement.parseOrNull( + event(CvmKinds.TOOLS_LIST, emptyArray(), """{"tools":[{"name":"x"}]}"""), + )!! + assertEquals("""{"tools":[{"name":"x"}]}""", announcement.content) + } + + // --- CEP-17 relay list --- + + @Test + fun `CVM-17-01 treats an unmarked relay as both read and write`() { + val relays = ServerRelay.parseAll(event(CvmKinds.RELAY_LIST, arrayOf(arrayOf("r", "wss://a")))) + assertEquals(listOf(ServerRelay("wss://a", read = true, write = true)), relays) + } + + @Test + fun `CVM-17-02 honours read and write markers when present`() { + val relays = + ServerRelay.parseAll( + event( + CvmKinds.RELAY_LIST, + arrayOf(arrayOf("r", "wss://r", "read"), arrayOf("r", "wss://w", "write")), + ), + ) + assertEquals(ServerRelay("wss://r", read = true, write = false), relays[0]) + assertEquals(ServerRelay("wss://w", read = false, write = true), relays[1]) + } + + @Test + fun `CVM-17-03 only a bidirectional relay can carry a full exchange`() { + // Kind 25910 is ephemeral, so a response missed on a write-only relay is + // simply gone -- both halves must share a relay. + val relays = + listOf( + ServerRelay("wss://both"), + ServerRelay("wss://read", read = true, write = false), + ) + assertEquals(listOf(ServerRelay("wss://both")), ServerRelay.operational(relays)) + } + + @Test + fun `CVM-17-04 ignores a blank relay url and a non-relay-list event`() { + assertTrue(ServerRelay.parseAll(event(CvmKinds.RELAY_LIST, arrayOf(arrayOf("r", "")))).isEmpty()) + assertTrue(ServerRelay.parseAll(event(1, arrayOf(arrayOf("r", "wss://a")))).isEmpty()) + } + + // --- CEP-24 reviews --- + + @Test + fun `CVM-24-01 a top-level review tags the announcement as both root and parent`() { + val tags = ServerReview.topLevelTags(serverPubKey, relayHint = "wss://r") + val names = tags.map { it[0] } + assertTrue(names.containsAll(listOf("A", "K", "P", "a", "k", "p"))) + assertEquals("11316:$serverPubKey:", tags.first { it[0] == "A" }[1]) + assertEquals("11316", tags.first { it[0] == "k" }[1]) + } + + @Test + fun `CVM-24-02 a reply keeps the root uppercase but moves the parent to the comment`() { + val parent = "b".repeat(64) + val author = "d".repeat(64) + val tags = ServerReview.replyTags(serverPubKey, parent, author) + + // Root stays on the announcement... + assertEquals("11316:$serverPubKey:", tags.first { it[0] == "A" }[1]) + assertEquals("11316", tags.first { it[0] == "K" }[1]) + // ...while the lowercase parent is the comment being answered. + assertEquals(parent, tags.first { it[0] == "e" }[1]) + assertEquals("1111", tags.first { it[0] == "k" }[1]) + assertEquals(author, tags.first { it[0] == "p" }[1]) + } + + @Test + fun `CVM-24-03 builds the addressable coordinate`() { + assertEquals("11316:$serverPubKey:", ServerReview.coordinate(serverPubKey)) + } +} diff --git a/contextvm/src/jvmTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapCryptoTest.kt b/contextvm/src/jvmTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapCryptoTest.kt new file mode 100644 index 0000000000..7045cc8be5 --- /dev/null +++ b/contextvm/src/jvmTest/kotlin/com/vitorpamplona/contextvm/crypto/CvmGiftWrapCryptoTest.kt @@ -0,0 +1,162 @@ +/* + * 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.contextvm.crypto + +import com.vitorpamplona.contextvm.core.CvmKinds +import com.vitorpamplona.contextvm.core.CvmMessageEvent +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * `CVM-4-*`: the gift wrap round trip against real NIP-44 and secp256k1. + * + * Lives in jvmTest rather than commonTest because it needs the secp256k1 JNI. + */ +class CvmGiftWrapCryptoTest { + private val clientSigner = NostrSignerInternal(KeyPair()) + private val serverSigner = NostrSignerInternal(KeyPair()) + private val crypto = CvmGiftWrap() + + private suspend fun innerMessage(): Event = + CvmMessageEvent.create( + message = JsonRpcRequest(JsonRpcId.Num(1), "tools/list"), + recipient = serverSigner.pubKey, + signer = clientSigner, + ) + + @Test + fun `CVM-4-10 round-trips a wrapped message and recovers the signed inner event`() = + runTest { + val inner = innerMessage() + val wrap = crypto.wrap(inner, serverSigner.pubKey) + + assertEquals(CvmKinds.EPHEMERAL_GIFT_WRAP, wrap.kind) + + val unwrapped = crypto.unwrap(wrap, serverSigner) + assertEquals(inner.id, unwrapped.id) + assertEquals(inner.pubKey, unwrapped.pubKey) + assertEquals(inner.content, unwrapped.content) + assertEquals(CvmKinds.MESSAGE, unwrapped.kind) + } + + @Test + fun `CVM-4-11 the inner event is signed by the real client, not the wrap key`() = + runTest { + // CEP-4 signs the inner event first and encrypts the whole signed + // event. The wrap's own signature only proves the throwaway key + // signed it, so authorship comes from the inner one. + val inner = innerMessage() + val wrap = crypto.wrap(inner, serverSigner.pubKey) + + assertEquals(clientSigner.pubKey, crypto.unwrap(wrap, serverSigner).pubKey) + assertNotEquals(clientSigner.pubKey, wrap.pubKey, "the wrap must not be signed by the client key") + } + + @Test + fun `CVM-4-12 each wrap uses a fresh throwaway key`() = + runTest { + val inner = innerMessage() + val first = crypto.wrap(inner, serverSigner.pubKey) + val second = crypto.wrap(inner, serverSigner.pubKey) + + assertNotEquals( + first.pubKey, + second.pubKey, + "reusing a wrap key would link two messages from the same sender", + ) + } + + @Test + fun `CVM-4-13 the wrap addresses the recipient with a p tag`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val pTag = wrap.tags.first { it[0] == "p" } + assertEquals(serverSigner.pubKey, pTag[1]) + } + + @Test + fun `CVM-4-14 the wrap timestamp is shifted and must not be used for ordering`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val now = + com.vitorpamplona.quartz.utils.TimeUtils + .now() + assertTrue(wrap.createdAt <= now, "NIP-59 shifts the timestamp into the past") + assertTrue(wrap.createdAt > now - (3 * 24 * 60 * 60), "but only within a bounded window") + } + + @Test + fun `CVM-4-15 rejects an inner event whose signature does not verify`() = + runTest { + // Without this check, anyone able to encrypt to us could claim any + // pubkey: the wrap signature proves nothing about the inner author. + val inner = innerMessage() + val forged = + Event( + id = inner.id, + pubKey = inner.pubKey, + createdAt = inner.createdAt, + kind = inner.kind, + tags = inner.tags, + content = inner.content, + sig = "00".repeat(64), + ) + val wrap = crypto.wrap(forged, serverSigner.pubKey) + + assertFailsWith { crypto.unwrap(wrap, serverSigner) } + } + + @Test + fun `CVM-4-16 rejects a wrap addressed to someone else`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val eavesdropper = NostrSignerInternal(KeyPair()) + assertFailsWith { crypto.unwrap(wrap, eavesdropper) } + } + + @Test + fun `CVM-4-17 rejects a non-gift-wrap kind on both paths`() = + runTest { + val inner = innerMessage() + assertFailsWith { crypto.wrap(inner, serverSigner.pubKey, kind = 1) } + assertFailsWith { crypto.unwrap(inner, serverSigner) } + } + + @Test + fun `CVM-19-10 a persistent-mode client produces kind 1059`() = + runTest { + val persistent = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT) + val wrap = persistent.wrap(innerMessage(), serverSigner.pubKey) + assertEquals(CvmKinds.GIFT_WRAP, wrap.kind) + + // Either kind must unwrap regardless of which mode we prefer. + assertEquals(CvmKinds.MESSAGE, crypto.unwrap(wrap, serverSigner).kind) + } +}