diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt index 61b0403c8c..0d158f6a2a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt @@ -48,6 +48,32 @@ data class DiscoverySurface( */ val unknownTags: List = emptyList(), ) { + /** + * Whether the peer said anything about itself at all. + * + * The distinction this exists for: CEP-35 reads an absent flag as "not + * supported", which is right for a peer that sent a discovery surface and + * left a flag out of it. It is wrong for a peer that sent no discovery tags + * whatsoever — that peer has made no claim, and treating its silence as a + * denial would have us conclude it cannot do anything. + * + * This is not hypothetical. A live cordn coordinator's kind-25910 responses + * carry only the routing tags `p` and `e` (observed on the public relays, + * 2026-09-23), so reading them as a full surface would say "supports + * nothing" about a server that in fact accepts every wrap we send. + */ + val declaresNothing: Boolean + get() = + name == null && + about == null && + picture == null && + website == null && + !supportsEncryption && + !supportsEphemeralEncryption && + !supportsOversizedTransfer && + !supportsOpenStream && + unknownTags.isEmpty() + /** 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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt index 97d8009204..65157e496c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt @@ -41,6 +41,17 @@ class SessionDiscovery { val hasLearned get() = baseline != null + /** + * The baseline, but only when the peer actually declared something. + * + * Null both before the peer's first message and when that message carried + * no discovery tags — two different facts a caller cannot act on + * differently, because both mean "this peer has told us nothing". Negotiate + * off this, not off [peer]: see [DiscoverySurface.declaresNothing] for the + * live case that makes the difference. + */ + val declaredPeer get() = baseline?.takeIf { !it.declaresNothing } + /** * Applies a peer message's tags. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt index 9523e2e1aa..a344e4fd26 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt @@ -88,13 +88,39 @@ class CvmTransport( private val serverPubKey: HexKey, private val crypto: CvmGiftWrap = CvmGiftWrap(), private val discovery: SessionDiscovery = SessionDiscovery(), - /** Whether the peer is known to accept encrypted messages. */ - private val peerSupportsEncryption: Boolean = true, - private val peerSupportsEphemeralWrap: Boolean = true, + /** + * What to assume about the peer **until it declares otherwise**. + * + * Not a fixed answer: once the peer sends a CEP-35 discovery surface, that + * surface wins (see [peerEncrypts]). These are only what to believe before + * the first message arrives, and for a peer that never declares anything. + * + * The default is optimistic on purpose. Assuming a peer cannot encrypt + * would have us send the first request of every session in the clear, which + * is the one message whose exposure we can still avoid. + */ + private val assumePeerSupportsEncryption: Boolean = true, + private val assumePeerSupportsEphemeralWrap: Boolean = true, ) { /** The peer's learned discovery baseline, once its first message has arrived. */ val peer get() = discovery.peer + /** + * Whether to encrypt to this peer, by what it has actually told us. + * + * A declared surface wins; silence leaves the assumption in place. That + * split is what makes [com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode.REQUIRED] + * mean something: before this, its input was a constant, so its promise to + * fail loudly rather than downgrade could never fire. Now a peer that + * declares a surface without `support_encryption` gets a stated refusal + * instead of a wrap it cannot open and a request that times out with no + * reason. + */ + private fun peerEncrypts() = discovery.declaredPeer?.supportsEncryption ?: assumePeerSupportsEncryption + + /** As [peerEncrypts], for CEP-19's ephemeral wrap kind. */ + private fun peerTakesEphemeralWrap() = discovery.declaredPeer?.supportsEphemeralEncryption ?: assumePeerSupportsEphemeralWrap + private var sentFirstMessage = false /** @@ -241,8 +267,8 @@ class CvmTransport( } private suspend fun outbound(inner: Event): Event = - if (crypto.shouldEncrypt(peerSupportsEncryption)) { - crypto.wrap(inner, serverPubKey, crypto.negotiatedWrapKind(peerSupportsEphemeralWrap)) + if (crypto.shouldEncrypt(peerEncrypts())) { + crypto.wrap(inner, serverPubKey, crypto.negotiatedWrapKind(peerTakesEphemeralWrap())) } else { inner } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt index 31ab90de42..bc0238587e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.contextvm.transport +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmEncryptionException import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.cep04Encryption.GiftWrapMode import com.vitorpamplona.quartz.contextvm.core.CvmKinds import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent import com.vitorpamplona.quartz.contextvm.core.CvmTags @@ -47,6 +49,7 @@ import kotlinx.serialization.json.buildJsonObject import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertTrue @@ -347,6 +350,132 @@ class CvmTransportTest { ) } + @Test + fun `CVM-4-22 a peer that declares no encryption gets a refusal, not an unreadable wrap`() = + runTest { + // The point of EncryptionMode.REQUIRED. Before negotiation was + // wired up its input was a constant true, so this could never fire: + // we would have sent a wrap the peer cannot open and the caller + // would have seen a timeout with no reason. + val fixture = server(discoveryTags = listOf(arrayOf("name", "Plaintext only"))) + fixture.start() + val transport = transport() + + // First request establishes the baseline and still goes out under + // the optimistic assumption — the peer has not spoken yet. + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + assertFalse(transport.peer!!.supportsEncryption) + + val thrown = + runCatching { + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(1), "ping")) + }.exceptionOrNull() + + assertTrue(thrown is CvmEncryptionException, "expected a stated refusal, got $thrown") + } + + @Test + fun `CVM-4-23 a peer that declares encryption keeps getting wraps`() = + runTest { + val fixture = + server( + discoveryTags = listOf(arrayOf("name", "Encrypts"), CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)), + ) + fixture.start() + val transport = transport() + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + val second = + exchange( + fixture, + transport, // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(second is JsonRpcSuccess) + assertTrue(relays.published.all { CvmKinds.isGiftWrap(it.kind) }) + } + + @Test + fun `CVM-4-24 a peer that declares nothing at all is not read as declaring no`() = + runTest { + // The case that decides the whole design. A live cordn coordinator's + // kind-25910 responses carry only the routing tags `p` and `e` + // (observed on the public relays, 2026-09-23). Reading that silence + // as a full CEP-35 surface would conclude it supports nothing, and + // REQUIRED would refuse to talk to every deployed coordinator. + val fixture = server(discoveryTags = emptyList()) + fixture.start() + val transport = transport() + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + val second = + exchange( + fixture, + transport, // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(second is JsonRpcSuccess, "a silent peer must stay reachable") + assertTrue(relays.published.all { CvmKinds.isGiftWrap(it.kind) }) + } + + @Test + fun `CVM-19-05 the wrap kind follows what the peer declared`() = + runTest { + // CEP-19: preferring 21059 must never mean refusing a 1059-only + // server. A declared surface without the ephemeral flag downgrades. + // The surface must declare encryption, or OPTIONAL would send the + // second request unwrapped and the wrap kind would never be picked + // at all - which made an earlier version of this test vacuous. + assertEquals( + listOf(CvmKinds.GIFT_WRAP), + wrapKindsOfSecondRequest( + discoveryTags = listOf(arrayOf(CvmTags.SUPPORT_ENCRYPTION, "true"), arrayOf("name", "Persistent only")), + ), + ) + } + + @Test + fun `CVM-19-06 a peer that declares ephemeral support gets the ephemeral wrap`() = + runTest { + assertEquals( + listOf(CvmKinds.EPHEMERAL_GIFT_WRAP), + wrapKindsOfSecondRequest( + discoveryTags = + listOf( + arrayOf(CvmTags.SUPPORT_ENCRYPTION, "true"), + arrayOf(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL, "true"), + ), + ), + ) + } + + /** + * Runs two requests against a server declaring [discoveryTags] and returns + * the kinds we published on the second one - by then the peer's surface has + * been learned from its first reply. + */ + private suspend fun wrapKindsOfSecondRequest(discoveryTags: List>): List { + val fixture = server(discoveryTags = discoveryTags) + fixture.start() + val transport = transport(CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL, encryptionMode = EncryptionMode.OPTIONAL)) + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + relays.published.clear() + // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + // Only what WE sent: a wrap addressed to the server. The fixture's own + // replies are wrapped too and would otherwise be counted. + val ours = relays.published.filter { e -> e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == serverSigner.pubKey } } + assertTrue(ours.isNotEmpty(), "the second request should have been published") + return ours.map { it.kind }.distinct() + } + @Test fun `CVM-35-11 the stable identity is used only when asked for`() = runTest {