mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(concord): add invite-link codec and bundle key (CORD-05)
- ConcordKeyDerivation.inviteBundleKey: derives the bundle decryption key from a
link's 16-byte unlock token via hkdf32(token, "concord/invite-key")
- InviteRelayDictionary: the v4 stock relay set + id<->url mapping
- ConcordInviteLink: encode/decode the {base}/invite/{naddr}#{fragment} link and
the [version=4][flags][relays?][token:16] fragment (stock-set flag, dictionary
ids, wss:// host and full-url relay entries), rejecting non-v4 versions; builds
the naddr (33301, link_signer, d="") and parses it back
Tests cover stock/dictionary/literal/full-url relay round-trips, wrong-version
rejection, full URL round-trip through naddr, and token-bound bundle key
derivation. Green on :quartz:jvmTest. Pinned to Concord v2 (Armada) constants.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
+189
@@ -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<String>,
|
||||
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<String>? = null,
|
||||
): String {
|
||||
require(token.size == TOKEN_LEN) { "token must be $TOKEN_LEN bytes, was ${token.size}" }
|
||||
val out = ArrayList<Byte>(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<String>()
|
||||
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<String>? = 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)
|
||||
}
|
||||
}
|
||||
+56
@@ -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<String> =
|
||||
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<Int, String> =
|
||||
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<String, Int> = 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]
|
||||
}
|
||||
+10
@@ -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 ------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
+97
@@ -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<IllegalArgumentException> { 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user