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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-18 01:55:21 +00:00
parent ce717751b0
commit 3e9c80fed2
7 changed files with 1026 additions and 25 deletions
@@ -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<Tag> = tags.filter { it.isNotEmpty() && !CvmTags.isRouting(it[0]) }
fun discoveryTags(): List<Tag> = 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<Tag> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = eventTemplate<CvmMessageEvent>(KIND, JsonRpcCodec.encode(message), createdAt) {
) = eventTemplate<Event>(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<Tag> = emptyList(),
createdAt: Long = TimeUtils.now(),
): CvmMessageEvent =
): Event =
signer.sign(
createdAt,
KIND,
@@ -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<Array<String>>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION)
/** Reads whether a peer advertised ephemeral wrap support (CEP-19). */
fun peerSupportsEphemeral(tags: Array<Array<String>>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)
/** The capability flags this client advertises to peers. */
fun capabilityTags(): List<Array<String>> =
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
}
}
@@ -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<Tag> = 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<Tag>): 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<Tag>): 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<Tag>) {
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<Event>): Map<Kind, ServerAnnouncement> =
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<ServerRelay> {
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<ServerRelay>) = 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<Tag> {
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<Tag> =
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)
}
@@ -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<Array<String>>) =
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),
),
)
}
@@ -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<CvmEncryptionException> { 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))
}
}
@@ -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<Tag>,
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))
}
}
@@ -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<CvmEncryptionException> { 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<CvmEncryptionException> { crypto.unwrap(wrap, eavesdropper) }
}
@Test
fun `CVM-4-17 rejects a non-gift-wrap kind on both paths`() =
runTest {
val inner = innerMessage()
assertFailsWith<CvmEncryptionException> { crypto.wrap(inner, serverSigner.pubKey, kind = 1) }
assertFailsWith<CvmEncryptionException> { 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)
}
}