feat(contextvm): negotiate encryption and wrap kind from learned discovery

CvmTransport took peerSupportsEncryption as a hardcoded true and nothing
ever learned it — not from CEP-6, not from the CEP-35 baseline the
session already parsed off the peer's first message. EncryptionMode.
REQUIRED documents that it fails loudly rather than downgrading, but its
input was a constant, so the promise could never fire; CEP-19's fallback
to the persistent wrap had the same problem in reverse.

The rule is: a peer that declares a surface is believed, a peer that
declares nothing leaves the assumption in place. The second half is not
leniency for its own sake — a real coordinator's kind-25910 responses
carry only [["p",..],["e",..]], no discovery tags at all, so reading
silence as a full surface would say every deployed coordinator supports
nothing and REQUIRED would refuse to talk to all of them.

- DiscoverySurface.declaresNothing tells an all-default surface apart
  from a declaration.
- SessionDiscovery.declaredPeer is the baseline only when it declares
  something.
- The two constructor flags are renamed assumePeerSupportsEncryption /
  assumePeerSupportsEphemeralWrap: they are the prior, not the answer.

Both stay optimistic by default. Assuming a peer cannot encrypt would
send the first request of every session in the clear, which is the one
message whose exposure is still avoidable.

Five tests, each checked against a mutant: hardcoding either flag back
to a constant, and reading silence as a declaration, all now fail.

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-23 17:50:55 +00:00
parent 78e1f7e205
commit ddb34bbbd9
4 changed files with 197 additions and 5 deletions
@@ -48,6 +48,32 @@ data class DiscoverySurface(
*/
val unknownTags: List<Tag> = 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 }
@@ -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.
*
@@ -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
}
@@ -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<Array<String>>): List<Int> {
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 {