Merge 56327af8986f1c4a8c3d5ab0942e519434c3766a into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
heyaim 2026-09-14 12:20:36 -05:00 committed by GitHub
commit b538a1c610
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 432 additions and 9 deletions

View File

@ -150,7 +150,11 @@ class FragmentManager {
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
signature = null, // iOS: signature: nil
// Fragments carry the original packet's timestamp, so a receiver applying a
// freshness window judges each fragment on it. A replayed archive packet
// large enough to fragment would be dropped without this.
isRSR = packet.isRSR
)
fragments.add(fragmentPacket)

View File

@ -64,7 +64,8 @@ class WirePayload(
* - Type: 1 byte
* - TTL: 1 byte
* - Timestamp: 8 bytes (UInt64, big-endian)
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed)
* - Flags: 1 byte (bit 0: hasRecipient, bit 1: hasSignature, bit 2: isCompressed,
* bit 3: hasRoute (v2+), bit 4: isRSR)
* - PayloadLength: 2 bytes (v1) / 4 bytes (v2) (big-endian)
*
* Variable sections:
@ -86,7 +87,15 @@ data class BitchatPacket(
var route: List<ByteArray>? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
// Set by BinaryProtocol.decode. Not part of packet identity, so it stays out of
// the parcel, equals and hashCode. Losing it only costs a re-compression.
@IgnoredOnParcel val wirePayload: WirePayload? = null
@IgnoredOnParcel val wirePayload: WirePayload? = null,
// Set when this packet is being sent as a solicited REQUEST_SYNC response. Mutable in
// transit like ttl, and deliberately not part of the signing preimage, so an archived
// packet can be marked without invalidating the signature it was stored with.
//
// Also deliberately outside equals/hashCode below, unlike ttl: a packet is the same packet
// whether or not it was served as a sync response, which is the view PacketIdUtil takes
// when it computes identity from type, sender, timestamp and payload alone.
var isRSR: Boolean = false
) : Parcelable {
constructor(
@ -112,6 +121,11 @@ data class BitchatPacket(
/**
* Create binary representation for signing (without signature and TTL fields)
* TTL is excluded because it changes during packet relay operations
*
* isRSR is excluded for the same reason: a packet is marked as a sync response when it is
* replayed from the archive, long after it was signed. It is omitted from the constructor
* call below and so defaults to false on both the signing and verifying side. Do not pass
* it through here; threading it in would invalidate the signature on every replayed packet.
*/
fun toBinaryDataForSigning(): ByteArray? {
// Create a copy without signature and with fixed TTL for signing
@ -215,6 +229,12 @@ object BinaryProtocol {
const val HAS_SIGNATURE: UByte = 0x02u
const val IS_COMPRESSED: UByte = 0x04u
const val HAS_ROUTE: UByte = 0x08u
// Marks a packet as a solicited REQUEST_SYNC response. A peer replaying its archive
// sends the original timestamps, so a receiver that applies a freshness window needs
// to know the packet was asked for. Like TTL, this changes in transit and is excluded
// from the signing preimage (see BitchatPacket.toBinaryDataForSigning).
const val IS_RSR: UByte = 0x10u
}
private fun getHeaderSize(version: UByte): Int {
@ -293,6 +313,9 @@ object BinaryProtocol {
if (!packet.route.isNullOrEmpty() && packet.version >= 2u.toUByte()) {
flags = flags or Flags.HAS_ROUTE
}
if (packet.isRSR) {
flags = flags or Flags.IS_RSR
}
buffer.put(flags.toByte())
// Payload length (2 or 4 bytes, big-endian) - includes original size if compressed
@ -423,6 +446,7 @@ object BinaryProtocol {
val isCompressed = (flags and Flags.IS_COMPRESSED) != 0u.toUByte()
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
val hasRoute = (version >= 2u.toUByte()) && (flags and Flags.HAS_ROUTE) != 0u.toUByte()
val isRSR = (flags and Flags.IS_RSR) != 0u.toUByte()
// Payload length - version-dependent (2 or 4 bytes)
val payloadLength = if (version >= 2u.toUByte()) {
@ -561,13 +585,14 @@ object BinaryProtocol {
signature = signature,
ttl = ttl,
route = route,
isRSR = isRSR,
wirePayload = WirePayload(
bytes = receivedCompressed ?: payload,
compressed = receivedCompressed != null,
forPayload = payload
)
)
} catch (e: Exception) {
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
return null

View File

@ -188,8 +188,13 @@ class GossipSyncManager(
val (id, pkt) = pair
val idBytes = hexToBytes(id)
if (!mightContain(idBytes)) {
// Send original packet unchanged to requester only (keep local TTL)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
// Send original packet unchanged to requester only (keep local TTL).
// Mark it as a solicited response: it carries its original timestamp, which a
// receiver applying a freshness window would otherwise reject as stale.
val toSend = pkt.copy(
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS,
isRSR = true
)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
}
@ -200,7 +205,10 @@ class GossipSyncManager(
for (pkt in toSendMsgs) {
val idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
val toSend = pkt.copy(
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS,
isRSR = true
)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
}

View File

@ -244,6 +244,58 @@ class FragmentManagerTest {
assertEquals(257, plan(low).size)
}
/**
* Fragments of a solicited sync response stay marked: each carries the original timestamp a
* freshness window would judge it on.
*/
@Test
fun `fragments of a sync response stay marked as sync responses`() {
val payload = ByteArray(4000) { (it % 251).toByte() }
val replayed = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(senderID),
recipientID = null,
timestamp = 1_700_000_000_000uL,
payload = payload,
signature = ByteArray(64) { 9 },
ttl = 0u
).apply { isRSR = true }
val fragments = fragmentManager.createFragments(replayed)
assertTrue("payload should have been split", fragments.size > 1)
assertTrue(
"every fragment of a sync response must carry the mark",
fragments.all { it.isRSR }
)
assertTrue(
"fragments keep the original timestamp, which is why they need the mark",
fragments.all { it.timestamp == replayed.timestamp }
)
}
/** A normal, unsolicited packet must not gain the mark by being fragmented. */
@Test
fun `fragments of an ordinary packet are not marked`() {
val payload = ByteArray(4000) { (it % 251).toByte() }
val ordinary = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = hexStringToByteArray(senderID),
recipientID = null,
timestamp = 1_700_000_000_000uL,
payload = payload,
signature = ByteArray(64) { 9 },
ttl = 7u
)
val fragments = fragmentManager.createFragments(ordinary)
assertTrue("payload should have been split", fragments.size > 1)
assertTrue("no fragment may be marked", fragments.none { it.isRSR })
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8)
for (i in 0 until 8) {

View File

@ -1,6 +1,12 @@
package com.bitchat.android.protocol
import com.bitchat.android.model.BitchatFilePacket
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.SecureRandom
import org.junit.Assert.assertEquals
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
@ -1619,6 +1625,193 @@ class BinaryProtocolTest {
return data.copyOfRange(start, data.size).all { (it.toInt() and 0xFF) == paddingLength }
}
// MARK: - RSR flag (0x10)
/**
* A packet marked as a solicited sync response survives the round-trip with the flag set,
* and an unmarked packet decodes with it clear.
*/
@Test
fun rsrFlagSurvivesEncodeDecodeRoundTrip() {
val payload = "sync replay".toByteArray()
val flagged = makePacket(payload = payload).apply { isRSR = true }
val decodedFlagged = BinaryProtocol.decode(BinaryProtocol.encode(flagged, padding = false)!!)
assertNotNull("flagged packet should decode", decodedFlagged)
assertTrue("isRSR must survive the round-trip", decodedFlagged!!.isRSR)
val plain = makePacket(payload = payload)
val decodedPlain = BinaryProtocol.decode(BinaryProtocol.encode(plain, padding = false)!!)
assertFalse("an unmarked packet must decode with isRSR clear", decodedPlain!!.isRSR)
}
/**
* The flag occupies bit 0x10 of the flags byte and nothing else moves.
*
* Pins the wire position against iOS, which defines the same bit. If this drifts, the two
* platforms stop agreeing on what a sync response looks like.
*/
@Test
fun rsrFlagOccupiesBit0x10OnTheWire() {
val payload = "wire position".toByteArray()
val plain = BinaryProtocol.encode(makePacket(payload = payload), padding = false)!!
val flagged = BinaryProtocol.encode(
makePacket(payload = payload).apply { isRSR = true },
padding = false
)!!
assertEquals("frames must be the same length", plain.size, flagged.size)
val flagsIndex = 11 // version(1) + type(1) + ttl(1) + timestamp(8)
val diff = (plain[flagsIndex].toInt() and 0xFF) xor (flagged[flagsIndex].toInt() and 0xFF)
assertEquals("only bit 0x10 may differ", 0x10, diff)
for (i in plain.indices) {
if (i == flagsIndex) continue
assertEquals("byte $i must be unchanged", plain[i], flagged[i])
}
}
/**
* Marking an already-signed archived packet as a sync response must not invalidate its
* signature: the signing preimage excludes the flag, exactly as it excludes TTL.
*
* This is the case that would make the change worse than the bug it fixes. A replayed
* packet is signed once, when first sent, and marked later when served from the archive.
* If the flag reached the preimage, every replayed packet would fail verification.
*/
@Test
fun markingAPacketAsRsrDoesNotChangeTheSigningPreimage() {
val payload = "archived message".toByteArray()
val asSigned = makePacket(payload = payload, ttl = 7u)
val asReplayed = makePacket(payload = payload, ttl = 7u).apply { isRSR = true }
assertArrayEquals(
"signing preimage must be identical with and without the RSR flag",
asSigned.toBinaryDataForSigning(),
asReplayed.toBinaryDataForSigning()
)
}
/**
* End to end: a packet signed when it was first sent still verifies after it is marked as a
* sync response and put back on the wire.
*
* The preimage test above proves the bytes match; this walks the path the archive takes,
* with a real Ed25519 key: sign, mark, encode, decode, verify.
*/
@Test
fun aSignedPacketStillVerifiesAfterBeingMarkedAndRoundTripped() {
val keyPair = Ed25519KeyPairGenerator().apply {
init(Ed25519KeyGenerationParameters(SecureRandom()))
}.generateKeyPair()
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
val publicKey = keyPair.public as Ed25519PublicKeyParameters
// 1. Signed once, when the message is first broadcast (unmarked, real TTL).
val outgoing = makePacket(payload = "signed when sent".toByteArray(), ttl = 7u)
val signedBytes = outgoing.toBinaryDataForSigning()
assertNotNull("signing preimage must exist", signedBytes)
val signature = Ed25519Signer().run {
init(true, privateKey)
update(signedBytes!!, 0, signedBytes.size)
generateSignature()
}
outgoing.signature = signature
// 2. Later served from the archive: marked, TTL dropped, signature untouched.
val replayed = outgoing.copy(ttl = 0u, isRSR = true)
// 3. Over the wire and back.
val decoded = BinaryProtocol.decode(BinaryProtocol.encode(replayed, padding = false)!!)
assertNotNull("replayed packet must decode", decoded)
assertTrue("the mark must survive the wire", decoded!!.isRSR)
// 4. Verified by the receiver, from the decoded packet.
val verifier = Ed25519Signer().apply { init(false, publicKey) }
val preimageAtReceiver = decoded.toBinaryDataForSigning()
assertNotNull(preimageAtReceiver)
verifier.update(preimageAtReceiver!!, 0, preimageAtReceiver.size)
assertTrue(
"a marked, replayed packet must still verify against the original signature",
verifier.verifySignature(decoded.signature!!)
)
}
/**
* The archive path after the wire-payload change: a packet that arrived on the wire is
* decoded, capturing its original payload bytes, later served as a sync response, and
* re-encoded. The re-encoding must reuse the stored bytes instead of recompressing, so
* the only bytes that differ between the original frame and the replayed frame are the
* TTL byte and the flags byte carrying the mark, and the original signature must still
* verify on the far side.
*/
@Test
fun aMarkedReplayOfADecodedPacketReusesTheWireBytesAndStillVerifies() {
val keyPair = Ed25519KeyPairGenerator().apply {
init(Ed25519KeyGenerationParameters(SecureRandom()))
}.generateKeyPair()
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
val publicKey = keyPair.public as Ed25519PublicKeyParameters
// 1. A compressible packet, signed when first broadcast (unmarked, real TTL).
val outgoing = makePacket(payload = "repeat ".repeat(120).toByteArray(), ttl = 7u)
val signedBytes = outgoing.toBinaryDataForSigning()
assertNotNull("signing preimage must exist", signedBytes)
outgoing.signature = Ed25519Signer().run {
init(true, privateKey)
update(signedBytes!!, 0, signedBytes.size)
generateSignature()
}
val originalFrame = BinaryProtocol.encode(outgoing, padding = false)!!
// 2. Received and archived: decode captures the wire payload.
val archived = BinaryProtocol.decode(originalFrame)
assertNotNull("archived packet must decode", archived)
assertNotNull("decode must capture the wire payload", archived!!.wirePayload)
// 3. Served from the archive: marked, TTL dropped, re-encoded.
val replayedFrame =
BinaryProtocol.encode(archived.copy(ttl = 0u, isRSR = true), padding = false)!!
// 4. Byte reuse: the frames differ only at the TTL byte and the flags byte.
assertEquals("frame length must not change", originalFrame.size, replayedFrame.size)
val changed = originalFrame.indices.filter { originalFrame[it] != replayedFrame[it] }
assertEquals("only the TTL byte and the flags byte may change", listOf(2, 11), changed)
// 5. The receiver still verifies the original signature.
val decoded = BinaryProtocol.decode(replayedFrame)
assertNotNull(decoded)
assertTrue("the mark must survive the replay", decoded!!.isRSR)
val preimage = decoded.toBinaryDataForSigning()
assertNotNull(preimage)
val verifier = Ed25519Signer().apply { init(false, publicKey) }
verifier.update(preimage!!, 0, preimage.size)
assertTrue(
"a replay reusing the wire bytes must verify against the original signature",
verifier.verifySignature(decoded.signature!!)
)
}
/**
* The decoder ignores flag bits it does not recognize instead of failing on them.
*
* This pins the property older builds rely on when they meet a marked packet, using an
* undefined bit because 0x10 is known to this decoder. It is a regression pin for the
* property and not evidence about any particular older build.
*/
@Test
fun unrecognizedFlagBitsAreIgnoredOnDecode() {
val payload = "compat".toByteArray()
val encoded = BinaryProtocol.encode(makePacket(payload = payload), padding = false)!!
val tampered = encoded.copyOf()
val flagsIndex = 11
tampered[flagsIndex] = (tampered[flagsIndex].toInt() or 0x40).toByte() // undefined bit
val decoded = BinaryProtocol.decode(tampered)
assertNotNull("a packet carrying an unknown flag bit must still decode", decoded)
assertTrue("payload must be intact", decoded!!.payload.contentEquals(payload))
assertFalse("an unrelated bit must not be read as isRSR", decoded.isRSR)
}
private fun assertPacketEquals(expected: BitchatPacket, actual: BitchatPacket) {
assertEquals("version", expected.version, actual.version)
assertEquals("type", expected.type, actual.type)

View File

@ -0,0 +1,140 @@
package com.bitchat.android.sync
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* The archive replay path must mark what it serves as a solicited sync response.
* BinaryProtocolTest covers the wire format; this pins the replay sites, where the flag could
* be dropped while the format still supports it.
*/
class GossipSyncRsrFlagTest {
private val sent = mutableListOf<Pair<String, BitchatPacket>>()
private lateinit var scope: CoroutineScope
private lateinit var manager: GossipSyncManager
private val requester = "aabbccddeeff0011"
private val delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sent.add(peerID to packet)
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
}
private val config = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = 100
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
}
@Before
fun setUp() {
sent.clear()
scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
manager = GossipSyncManager(myPeerID = "1122334455667788", scope = scope, configProvider = config)
manager.delegate = delegate
}
@After
fun tearDown() {
scope.cancel()
}
private fun broadcastMessage(payload: String, ageMillis: Long): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = ByteArray(8) { 0x11 },
recipientID = SpecialRecipients.BROADCAST,
timestamp = (System.currentTimeMillis() - ageMillis).toULong(),
payload = payload.toByteArray(),
signature = ByteArray(64) { 0x22 },
ttl = 7u
)
/** A filter the requester builds when it holds nothing: everything we have is missing. */
private fun requestForNothingHeld() = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0))
@Test
fun replayedMessagesAreMarkedAsSolicitedSyncResponses() {
val original = broadcastMessage("history from an hour ago", ageMillis = 60 * 60 * 1000L)
manager.onPublicPacketSeen(original)
manager.handleRequestSync(requester, requestForNothingHeld())
assertEquals("one archived message should have been served", 1, sent.size)
val (peer, served) = sent.single()
assertEquals(requester, peer)
assertTrue("a replayed packet must be marked as a sync response", served.isRSR)
assertEquals("a sync response must not be relayed onward", 0u.toUByte(), served.ttl)
}
@Test
fun replayPreservesTheOriginalTimestampAndPayload() {
val original = broadcastMessage("unchanged", ageMillis = 3 * 60 * 60 * 1000L)
manager.onPublicPacketSeen(original)
manager.handleRequestSync(requester, requestForNothingHeld())
val served = sent.single().second
assertEquals("timestamp must not be rewritten", original.timestamp, served.timestamp)
assertTrue("payload must not be rewritten", served.payload.contentEquals(original.payload))
assertTrue(
"signature must be carried through unchanged",
served.signature.contentEquals(original.signature)
)
}
private fun announce(ageMillis: Long): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = ByteArray(8) { 0x33 },
recipientID = null,
timestamp = (System.currentTimeMillis() - ageMillis).toULong(),
payload = "nickname".toByteArray(),
signature = ByteArray(64) { 0x44 },
ttl = 7u
)
/**
* The announce replay site must mark what it serves too: a dropped announce takes the
* author's message history with it. The message-path tests leave the announce archive empty
* and would stay green with this site unmarked.
*/
@Test
fun replayedAnnouncesAreMarkedAsSolicitedSyncResponses() {
manager.onPublicPacketSeen(announce(ageMillis = 150_000L))
manager.handleRequestSync(requester, requestForNothingHeld())
assertEquals("the archived announce should have been served", 1, sent.size)
val served = sent.single().second
assertEquals(MessageType.ANNOUNCE.value, served.type)
assertTrue("a replayed announce must be marked as a sync response", served.isRSR)
assertEquals("a sync response must not be relayed onward", 0u.toUByte(), served.ttl)
}
@Test
fun archivingDoesNotMarkThePacketWeStored() {
// Serving marks the packet and storing must not. If archiving marked it, a packet would
// be flagged on paths that are not sync responses.
val original = broadcastMessage("stored", ageMillis = 1000L)
manager.onPublicPacketSeen(original)
assertFalse("the caller's packet must not be mutated", original.isRSR)
}
}

View File

@ -72,7 +72,7 @@ Sender behavior:
Receiver behavior:
- Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream.
- For each locally stored public packet ID:
- Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only.
- Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` and the RSR flag (`0x10` in the packet header) set, to the requester only.
- For announcements, send only the latest announcement per (sender peerID).
- For broadcast messages, send all missing ones.
@ -83,7 +83,7 @@ Announcement retention and pruning (consensus):
- LEAVE handling: upon receiving a LEAVE message from a peer, immediately remove that peer’s stored announcement from the sync candidate set.
- Stale/offline peer handling: when a peer is considered stale/offline (e.g., last announcement older than 60 seconds), immediately remove that peer’s stored announcement from the sync candidate set.
Important: original packets are sent unmodified to preserve original signatures (e.g., ANNOUNCE). They MUST NOT be relayed beyond immediate neighbors. Implementations SHOULD send these response packets with TTL=0 (local-only) and, when possible, route them only to the requesting peer without altering the original packet contents.
Important: original packets are sent unmodified to preserve original signatures (e.g., ANNOUNCE), except for two header fields that are outside the signed bytes: TTL, and the RSR flag (`0x10`), which marks the packet as a solicited sync response so a receiver applying a timestamp freshness window can exempt it. They MUST NOT be relayed beyond immediate neighbors. Implementations SHOULD send these response packets with TTL=0 (local-only) and, when possible, route them only to the requesting peer.
## Scope and Types Included
@ -122,6 +122,7 @@ Backed by `DebugPreferenceManager` getters and setters:
## Compatibility Notes
- GCS hashing and TLV structures are fully specified above; other implementations should use the same hashing scheme and payload layout for interoperability.
- Responses set the RSR flag (`0x10`) in the packet header. The flag is excluded from the signing preimage on both platforms, like TTL, so an archived packet can be marked when served without invalidating its signature.
- REQUEST_SYNC and responses are local-only and MUST NOT be relayed. Implementations SHOULD use TTL=0 to prevent relaying. If an implementation requires TTL>0 for local delivery, it MUST still ensure that REQUEST_SYNC and responses are not relayed beyond direct neighbors (e.g., by special-casing these types in relay logic).
## Consensus vs. Configurable