mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+26
@@ -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 }
|
||||
|
||||
|
||||
+11
@@ -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.
|
||||
*
|
||||
|
||||
+31
-5
@@ -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
|
||||
}
|
||||
|
||||
+129
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user