fix: don't cap Concord invite links at 3 bootstrap relays

`ConcordInviteLink.encodeFragment` refused to encode more than
`MAX_RELAYS = 3` bootstrap relays, but both mint call sites —
`AccountConcordActions.mintConcordInvite` and `amy concord invite` — hand it
the community's full relay list. A community with more than three relays
therefore blew up the invite button with
`IllegalArgumentException: at most 3 relays, was 5`.

The cap was self-imposed: nothing in the fragment format needs it. The layout
is `[version][flags][count][relays...][token:16]` and the relay count is a
whole byte, so the format's own ceiling is 255. `MAX_RELAYS` is deleted and
the only remaining guard is that ceiling — without it a 256-relay list would
wrap the count byte to 0 and silently strand every relay in the fragment.

The stock set still collapses to flag `0x01` and zero relay bytes, so the
common invite is unchanged in length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcaCNiqN8T9izve4AXcake
This commit is contained in:
Claude
2026-09-03 18:25:22 +00:00
parent 536e27dc8f
commit 401f6246ab
2 changed files with 54 additions and 7 deletions
@@ -53,15 +53,14 @@ class ParsedInviteLink(
* `(33301, link_signer_pubkey, d="")`. The `#fragment` is **never sent to any
* server**: it is base64url of `[version=4][flags][relays?][token:16]`, carrying
* the 16-byte unlock token (→ [com.vitorpamplona.quartz.concord.crypto
* .ConcordKeyDerivation.inviteBundleKey]) and, when flag `0x01` is unset, up to
* three bootstrap relays encoded against [InviteRelayDictionary].
* .ConcordKeyDerivation.inviteBundleKey]) and, when flag `0x01` is unset, the
* bootstrap relays encoded against [InviteRelayDictionary].
*
* Pinned to the Concord v2 reference client for interop.
*/
object ConcordInviteLink {
const val VERSION = 4
const val FLAG_STOCK_RELAYS = 0x01
const val MAX_RELAYS = 3
private const val MARKER_WSS_HOST = 0
private const val MARKER_FULL_URL = 255
@@ -70,8 +69,13 @@ object ConcordInviteLink {
/**
* Encodes the fragment for [token] and optional [relays]. Passing null or the
* exact stock set uses flag `0x01` and emits no relay bytes; otherwise up to
* [MAX_RELAYS] relays are encoded (dictionary id, `wss://` host, or full URL).
* exact stock set uses flag `0x01` and emits no relay bytes; otherwise every
* relay is encoded (dictionary id, `wss://` host, or full URL).
*
* The only ceiling is the format's own: the relay count is a single byte, so at
* most 255 relays fit. Each carries its own byte cost, so a long list makes a long
* link — pick relays that can actually serve the bundle rather than pasting a
* whole relay list in.
*/
@OptIn(ExperimentalEncodingApi::class)
fun encodeFragment(
@@ -86,7 +90,7 @@ object ConcordInviteLink {
if (useStock) {
out.add(FLAG_STOCK_RELAYS.toByte())
} else {
require(relays.size <= MAX_RELAYS) { "at most $MAX_RELAYS relays, was ${relays.size}" }
require(relays.size <= 255) { "relay count must fit in one byte, was ${relays.size}" }
out.add(0)
out.add(relays.size.toByte())
for (r in relays) {
@@ -157,7 +161,10 @@ object ConcordInviteLink {
return InviteFragment(bytes.copyOfRange(pos, pos + TOKEN_LEN), relays, usedStock)
}
/** Builds a full shareable invite URL under [base]. */
/**
* Builds a full shareable invite URL under [base], carrying every relay in [relays]
* as the bootstrap set (or the stock flag when null / exactly the stock set).
*/
fun buildUrl(
base: String,
linkSignerPubKey: String,
@@ -66,6 +66,46 @@ class ConcordInviteLinkTest {
assertEquals(relays, frag.relays)
}
@Test
fun buildUrlCarriesEveryRelayOfAnOversizedList() {
// A community with five relays used to crash the mint ("at most 3 relays, was 5").
// There is no cap below the format's own, so all five make the round trip.
val relays =
listOf(
"wss://one.example",
"wss://two.example",
"wss://three.example",
"wss://four.example",
"wss://five.example",
)
val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays))
assertNotNull(parsed)
assertEquals(relays, parsed.fragment.relays)
assertContentEquals(token, parsed.fragment.token)
}
@Test
fun buildUrlKeepsTheFourStockRelaysAsTheStockFlag() {
// The stock set still collapses to flag 0x01 and zero relay bytes rather than
// being spelled out as four dictionary ids.
val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, InviteRelayDictionary.STOCK))
assertNotNull(parsed)
assertTrue(parsed.fragment.usedStockRelays)
assertEquals(InviteRelayDictionary.STOCK, parsed.fragment.relays)
}
@Test
fun encodesTheLargestRelayListTheCountByteCanHold() {
// 255 is the format ceiling, not a policy one: the relay count is a single byte.
val relays = List(255) { "wss://relay$it.example" }
val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays))
assertEquals(relays, frag.relays)
assertContentEquals(token, frag.token)
// One more would silently wrap the count byte to 0 and strand every relay, so it throws.
assertFailsWith<IllegalArgumentException> { ConcordInviteLink.encodeFragment(token, relays + "wss://overflow.example") }
}
@Test
@OptIn(ExperimentalEncodingApi::class)
fun rejectsWrongVersion() {