mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+34
-19
@@ -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
|
||||
}
|
||||
}
|
||||
+266
@@ -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)
|
||||
}
|
||||
+10
-6
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+108
@@ -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))
|
||||
}
|
||||
}
|
||||
+223
@@ -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))
|
||||
}
|
||||
}
|
||||
+162
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user