diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt new file mode 100644 index 0000000000..3f32739fab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** The decoded contents of an invite-link URL fragment. */ +class InviteFragment( + /** The 16-byte unlock token (derives the bundle key; never sent to a server). */ + val token: ByteArray, + /** The resolved bootstrap relay URLs (stock set, or the encoded custom list). */ + val relays: List, + val usedStockRelays: Boolean, +) + +/** A parsed invite-link URL: its addressable pointer plus the private fragment. */ +class ParsedInviteLink( + val naddr: String, + val linkSignerPubKey: String, + val kind: Int, + val fragment: InviteFragment, +) + +/** + * Codec for Concord invite links (CORD-05): + * + * ``` + * {base}/invite/{naddr}#{fragment} + * ``` + * + * The `naddr` is a public NIP-19 pointer to the kind-33301 bundle + * `(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]. + * + * 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 + private const val WSS_PREFIX = "wss://" + private const val TOKEN_LEN = 16 + + /** + * 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). + */ + @OptIn(ExperimentalEncodingApi::class) + fun encodeFragment( + token: ByteArray, + relays: List? = null, + ): String { + require(token.size == TOKEN_LEN) { "token must be $TOKEN_LEN bytes, was ${token.size}" } + val out = ArrayList(2 + TOKEN_LEN) + out.add(VERSION.toByte()) + + val useStock = relays == null || relays == InviteRelayDictionary.STOCK + if (useStock) { + out.add(FLAG_STOCK_RELAYS.toByte()) + } else { + require(relays!!.size <= MAX_RELAYS) { "at most $MAX_RELAYS relays, was ${relays.size}" } + out.add(0) + out.add(relays.size.toByte()) + for (r in relays) { + val id = InviteRelayDictionary.idOf(r) + when { + id != null -> out.add(id.toByte()) + r.startsWith(WSS_PREFIX) -> { + val host = r.substring(WSS_PREFIX.length).encodeToByteArray() + require(host.size <= 255) { "relay host too long" } + out.add(MARKER_WSS_HOST.toByte()) + out.add(host.size.toByte()) + host.forEach { out.add(it) } + } + else -> { + val url = r.encodeToByteArray() + require(url.size <= 255) { "relay url too long" } + out.add(MARKER_FULL_URL.toByte()) + out.add(url.size.toByte()) + url.forEach { out.add(it) } + } + } + } + } + token.forEach { out.add(it) } + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(out.toByteArray()) + } + + /** + * Decodes an invite [fragment]. Throws for a malformed fragment or a version + * other than [VERSION] (lower = legacy, higher = newer than this client). + * Unknown dictionary ids are skipped rather than aborting the parse. + */ + @OptIn(ExperimentalEncodingApi::class) + fun decodeFragment(fragment: String): InviteFragment { + val bytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(fragment) + require(bytes.size >= 2 + TOKEN_LEN) { "fragment too short" } + val version = bytes[0].toInt() and 0xFF + require(version == VERSION) { "unsupported invite fragment version $version" } + val flags = bytes[1].toInt() and 0xFF + + var pos = 2 + val relays = ArrayList() + var usedStock = false + if (flags and FLAG_STOCK_RELAYS != 0) { + relays.addAll(InviteRelayDictionary.STOCK) + usedStock = true + } else { + val count = bytes[pos++].toInt() and 0xFF + repeat(count) { + val marker = bytes[pos++].toInt() and 0xFF + when (marker) { + MARKER_WSS_HOST -> { + val len = bytes[pos++].toInt() and 0xFF + relays.add(WSS_PREFIX + bytes.decodeToString(pos, pos + len)) + pos += len + } + MARKER_FULL_URL -> { + val len = bytes[pos++].toInt() and 0xFF + relays.add(bytes.decodeToString(pos, pos + len)) + pos += len + } + else -> InviteRelayDictionary.urlOf(marker)?.let { relays.add(it) } // unknown id: skip + } + } + } + + require(bytes.size - pos == TOKEN_LEN) { "trailing bytes are not a $TOKEN_LEN-byte token" } + return InviteFragment(bytes.copyOfRange(pos, pos + TOKEN_LEN), relays, usedStock) + } + + /** Builds a full shareable invite URL under [base]. */ + fun buildUrl( + base: String, + linkSignerPubKey: String, + token: ByteArray, + relays: List? = null, + ): String { + val naddr = NAddress.create(ConcordKinds.INVITE_BUNDLE, linkSignerPubKey, "", null) + val trimmed = base.trimEnd('/') + return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}" + } + + /** Parses a full invite URL back into its pointer + fragment, or null if malformed. */ + fun parseUrl(url: String): ParsedInviteLink? { + val hash = url.indexOf('#') + if (hash < 0) return null + val fragment = + try { + decodeFragment(url.substring(hash + 1)) + } catch (_: Exception) { + return null + } + val marker = url.indexOf("/invite/") + if (marker < 0) return null + val naddr = url.substring(marker + "/invite/".length, hash) + val parsed = NAddress.parse(naddr) ?: return null + if (parsed.kind != ConcordKinds.INVITE_BUNDLE) return null + return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt new file mode 100644 index 0000000000..701411e907 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +/** + * The invite-link relay dictionary (CORD-05 §Relay Dictionary), version 4, + * pinned to the Concord v2 reference client. Referencing a relay by its dictionary + * id keeps invite links compact; the stock set is selected by a single flag so the + * common invite carries zero relay bytes. + * + * Dictionary ids run 1–254. Id 0 is reserved as the "wss:// literal host" marker + * and 255 as the "full URL" marker in the fragment encoding (see [ConcordInviteLink]). + */ +object InviteRelayDictionary { + /** The stock relay set carried by flag 0x01 (the four v4 primaries). */ + val STOCK: List = + listOf( + "wss://jskitty.com/nostr", + "wss://asia.vectorapp.io/nostr", + "wss://relay.ditto.pub", + "wss://relay.dreamith.to", + ) + + /** id → relay url, for the ids that fit in a single dictionary byte (1–254). */ + val BY_ID: Map = + mapOf( + 1 to "wss://jskitty.com/nostr", + 2 to "wss://asia.vectorapp.io/nostr", + 3 to "wss://relay.ditto.pub", + 4 to "wss://relay.dreamith.to", + ) + + private val ID_BY_URL: Map = BY_ID.entries.associate { (id, url) -> url to id } + + fun idOf(url: String): Int? = ID_BY_URL[url] + + fun urlOf(id: Int): String? = BY_ID[id] +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index d63f3b21ec..c261c1d94d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -180,6 +180,16 @@ object ConcordKeyDerivation { identity: String, ): ByteArray = hkdf32(voiceMediaKey, buildInfo(ConcordLabels.VOICE_SENDER, sha256(identity.encodeToByteArray()))) + // ---- CORD-05 invite bundle key -------------------------------------------- + + /** + * Derives the invite bundle decryption key from a link's 16-byte unlock + * [token] (CORD-05): `hkdf32(token, "concord/invite-key" ‖ 0x00)`. The token + * lives only in the URL fragment, so a server that sees the naddr can never + * open the bundle. + */ + fun inviteBundleKey(token: ByteArray): ByteArray = hkdf32(token, buildInfo(ConcordLabels.INVITE_KEY)) + // ---- CORD-06 rekey locator ------------------------------------------------ /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt new file mode 100644 index 0000000000..5e7dc437b7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordInviteLinkTest { + private val token = ByteArray(16) { it.toByte() } + private val signer = KeyPair().pubKey.toHexKey() + + @Test + fun stockFragmentRoundTrips() { + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays = null)) + assertContentEquals(token, frag.token) + assertTrue(frag.usedStockRelays) + assertEquals(InviteRelayDictionary.STOCK, frag.relays) + + // passing the exact stock set also collapses to the stock flag + val fromStockList = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, InviteRelayDictionary.STOCK)) + assertTrue(fromStockList.usedStockRelays) + } + + @Test + fun dictionaryRelaysRoundTrip() { + val relays = listOf("wss://relay.ditto.pub", "wss://jskitty.com/nostr") // ids 3 and 1 + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertFalse(frag.usedStockRelays) + assertEquals(relays, frag.relays) + assertContentEquals(token, frag.token) + } + + @Test + fun literalHostAndFullUrlRelaysRoundTrip() { + val relays = listOf("wss://myrelay.example/nostr", "ws://plain.example") + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertEquals(relays, frag.relays) + } + + @Test + @OptIn(ExperimentalEncodingApi::class) + fun rejectsWrongVersion() { + // craft a version-3 fragment: [3, 0x01, token...] + val bytes = byteArrayOf(3, 0x01) + token + val legacy = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + assertFailsWith { ConcordInviteLink.decodeFragment(legacy) } + } + + @Test + fun fullUrlRoundTripsThroughNaddr() { + val url = ConcordInviteLink.buildUrl("https://vector.chat", signer, token) + assertTrue(url.startsWith("https://vector.chat/invite/naddr")) + + val parsed = ConcordInviteLink.parseUrl(url) + assertNotNull(parsed) + assertEquals(signer, parsed.linkSignerPubKey) + assertEquals(ConcordKinds.INVITE_BUNDLE, parsed.kind) + assertContentEquals(token, parsed.fragment.token) + } + + @Test + fun inviteBundleKeyIsDeterministicAndTokenBound() { + val k = ConcordKeyDerivation.inviteBundleKey(token) + assertEquals(32, k.size) + assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token)) + assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey()) + } +}