From 933b522273b618363607317a0109776fc5130756 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 02:58:24 +0000 Subject: [PATCH] feat(nestsClient): NIP-98 auth + nests room-info client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3a of the Clubhouse/nests integration. Adds a new KMP module `nestsClient` (Android + JVM targets) that owns the HTTP control plane for talking to a nests audio-room backend. No transport/audio yet — that arrives in 3b with WebTransport + MoQ. Surface: - `NestsAuth.header(signer, url, method)` signs a kind 27235 event via Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use `Authorization: Nostr ` header value. - `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown fields so newer nests server revisions don't break older clients). - `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls `/` with the signed NIP-98 header and returns the MoQ endpoint + token the audio layer will need. - `OkHttpNestsClient` (jvmAndroid) is the default implementation shared between Android and desktop JVM. Tests: - `NestsRoomInfoTest` (commonTest) covers full/minimal payloads, unknown-field tolerance, missing-endpoint rejection, and URL construction edge cases. - `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the base64 back through Quartz's JacksonMapper, and asserts the signature verifies and the url+method tags bind correctly. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- nestsClient/build.gradle.kts | 84 +++++++++++++++++ .../vitorpamplona/nestsclient/NestsAuth.kt | 52 +++++++++++ .../vitorpamplona/nestsclient/NestsClient.kt | 60 +++++++++++++ .../nestsclient/NestsRoomInfo.kt | 74 +++++++++++++++ .../nestsclient/NestsRoomInfoTest.kt | 90 +++++++++++++++++++ .../nestsclient/OkHttpNestsClient.kt | 78 ++++++++++++++++ .../nestsclient/NestsAuthTest.kt | 80 +++++++++++++++++ settings.gradle | 1 + 8 files changed, 519 insertions(+) create mode 100644 nestsClient/build.gradle.kts create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt create mode 100644 nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt create mode 100644 nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt create mode 100644 nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt create mode 100644 nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts new file mode 100644 index 000000000..77a761da7 --- /dev/null +++ b/nestsClient/build.gradle.kts @@ -0,0 +1,84 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) + alias(libs.plugins.serialization) +} + +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + android { + namespace = "com.vitorpamplona.nestsclient" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + withHostTest {} + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + api(project(":quartz")) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + } + } + + jvmMain { + dependsOn(jvmAndroid) + } + + androidMain { + dependsOn(jvmAndroid) + } + + jvmTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt new file mode 100644 index 000000000..04a1b7f27 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsAuth.kt @@ -0,0 +1,52 @@ +/* + * 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.nestsclient + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent + +/** + * Helper for building the `Authorization: Nostr ` header required by + * nests audio-room HTTP endpoints (NIP-98). + * + * The header value binds the signed event to a specific URL + method so the + * backend can reject replayed or repurposed tokens. We deliberately do NOT + * cache the signed event — the nests server enforces a short (~60 s) validity + * window on `created_at`, so callers should build a fresh header per request. + */ +object NestsAuth { + /** + * Sign a kind 27235 event for (`url`, `method`) and return it as a + * ready-to-use `Authorization` header value (`"Nostr "`). + * + * Optionally include a payload hash when the request has a body. + */ + suspend fun header( + signer: NostrSigner, + url: String, + method: String, + payload: ByteArray? = null, + ): String { + val template = HTTPAuthorizationEvent.build(url = url, method = method, file = payload) + val signed = signer.sign(template) + return signed.toAuthToken() + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt new file mode 100644 index 000000000..82e4ab56a --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsClient.kt @@ -0,0 +1,60 @@ +/* + * 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.nestsclient + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * High-level entry point for talking to a nests-compatible audio-room backend. + * + * Phase 3a only exposes the HTTP control plane — resolving a room's MoQ + * endpoint + token via NIP-98 auth. Phase 3b will add the WebTransport/MoQ + * transport on top, keeping this interface stable so audio-room callers only + * depend on [resolveRoom] for the control-plane step. + */ +interface NestsClient { + /** + * Fetch [NestsRoomInfo] for a specific room. + * + * @param serviceBase value of the NIP-53 kind 30312 `service` tag + * (e.g. `https://nostrnests.com/api/v1/nests`) + * @param roomId the event's `d` tag + * @param signer signs the NIP-98 auth event that the server uses to verify + * the caller owns the pubkey it claims + * @throws NestsException on transport errors, non-2xx responses, or malformed + * JSON + */ + suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo +} + +/** + * Single exception type surfaced to UI code so callers don't have to know about + * platform HTTP libraries (OkHttp on Android/JVM today). + */ +class NestsException( + message: String, + cause: Throwable? = null, + val status: Int? = null, +) : RuntimeException(message, cause) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt new file mode 100644 index 000000000..bf1c82597 --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfo.kt @@ -0,0 +1,74 @@ +/* + * 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.nestsclient + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * Information returned by a nests audio-room backend when a client + * authenticates against `/api/v1/nests/`. + * + * Field names mirror the nests reference server payload: + * ``` + * { "endpoint": "...", "token": "...", "codec": "opus", "sample_rate": 48000 } + * ``` + * All fields except [endpoint] are optional so the client can negotiate with + * alternate nests implementations that omit codec/token metadata. + */ +@Serializable +data class NestsRoomInfo( + val endpoint: String, + val token: String? = null, + val codec: String? = null, + @SerialName("sample_rate") val sampleRate: Int? = null, + val transport: String? = null, + val extra: Map = emptyMap(), +) { + companion object { + private val json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + fun parse(body: String): NestsRoomInfo = json.decodeFromString(serializer(), body) + } +} + +/** + * Build the URL a client should call to resolve [NestsRoomInfo] for a specific + * room. [serviceBase] comes from the `service` tag of the NIP-53 kind 30312 + * event; [roomId] is the event's `d` tag. + * + * Example: `https://nostrnests.com/api/v1/nests` + `abc-123` → + * `https://nostrnests.com/api/v1/nests/abc-123`. + */ +fun nestsRoomInfoUrl( + serviceBase: String, + roomId: String, +): String { + val trimmed = serviceBase.trimEnd('/') + val encoded = roomId.trim() + return "$trimmed/$encoded" +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt new file mode 100644 index 000000000..95b68bc1c --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsRoomInfoTest.kt @@ -0,0 +1,90 @@ +/* + * 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.nestsclient + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class NestsRoomInfoTest { + @Test + fun parses_full_payload() { + val info = + NestsRoomInfo.parse( + """{"endpoint":"https://relay.example.com/moq","token":"abc.def","codec":"opus","sample_rate":48000,"transport":"webtransport"}""", + ) + assertEquals("https://relay.example.com/moq", info.endpoint) + assertEquals("abc.def", info.token) + assertEquals("opus", info.codec) + assertEquals(48000, info.sampleRate) + assertEquals("webtransport", info.transport) + } + + @Test + fun tolerates_minimal_payload() { + val info = NestsRoomInfo.parse("""{"endpoint":"https://r.example.com/moq"}""") + assertEquals("https://r.example.com/moq", info.endpoint) + assertNull(info.token) + assertNull(info.codec) + assertNull(info.sampleRate) + } + + @Test + fun ignores_unknown_fields() { + val info = + NestsRoomInfo.parse( + """{"endpoint":"https://r.example.com/moq","token":"t","future_knob":"future_value"}""", + ) + assertEquals("t", info.token) + } + + @Test + fun rejects_missing_endpoint() { + assertFailsWith { + NestsRoomInfo.parse("""{"token":"t"}""") + } + } + + @Test + fun builds_room_info_url_from_service_and_room_id() { + assertEquals( + "https://nostrnests.com/api/v1/nests/abc-123", + nestsRoomInfoUrl("https://nostrnests.com/api/v1/nests", "abc-123"), + ) + } + + @Test + fun trims_trailing_slash_from_service_base() { + assertEquals( + "https://nostrnests.com/api/v1/nests/xyz", + nestsRoomInfoUrl("https://nostrnests.com/api/v1/nests/", "xyz"), + ) + } + + @Test + fun trims_whitespace_from_room_id() { + assertEquals( + "https://a.example.com/api/v1/nests/room", + nestsRoomInfoUrl("https://a.example.com/api/v1/nests", " room "), + ) + } +} diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt new file mode 100644 index 000000000..c0b2fe06e --- /dev/null +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/OkHttpNestsClient.kt @@ -0,0 +1,78 @@ +/* + * 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.nestsclient + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException + +/** + * OkHttp-backed [NestsClient] used on JVM + Android. A shared [OkHttpClient] + * can be injected so the app reuses connection pools / interceptors across + * the process; the default constructor creates a dedicated client. + */ +class OkHttpNestsClient( + private val http: OkHttpClient = OkHttpClient(), +) : NestsClient { + override suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo { + val url = nestsRoomInfoUrl(serviceBase, roomId) + val authHeader = NestsAuth.header(signer = signer, url = url, method = "GET") + + val request = + Request + .Builder() + .url(url) + .get() + .header("Authorization", authHeader) + .header("Accept", "application/json") + .build() + + return withContext(Dispatchers.IO) { + runCatching { http.newCall(request).execute() } + .getOrElse { throw NestsException("Failed to reach $url", it) } + .use { response -> + val body = response.body.string() + if (!response.isSuccessful) { + throw NestsException( + "nests server returned ${response.code} for $url", + status = response.code, + ) + } + try { + NestsRoomInfo.parse(body) + } catch (e: IOException) { + throw NestsException("Malformed nests response from $url", e) + } catch (e: IllegalArgumentException) { + throw NestsException("Malformed nests response from $url", e) + } catch (e: kotlinx.serialization.SerializationException) { + throw NestsException("Malformed nests response from $url", e) + } + } + } + } +} diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt new file mode 100644 index 000000000..0dc1cca78 --- /dev/null +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/NestsAuthTest.kt @@ -0,0 +1,80 @@ +/* + * 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.nestsclient + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalEncodingApi::class) +class NestsAuthTest { + @Test + fun header_has_nostr_prefix_and_decodes_to_signed_27235_event() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val header = NestsAuth.header(signer, "https://nostrnests.com/api/v1/nests/abc", "GET") + + assertTrue(header.startsWith("Nostr "), "header must start with `Nostr `, got: $header") + + val token = header.removePrefix("Nostr ") + val json = Base64.decode(token).decodeToString() + val event = JacksonMapper.fromJson(json) as HTTPAuthorizationEvent + + assertEquals(HTTPAuthorizationEvent.KIND, event.kind) + assertEquals("https://nostrnests.com/api/v1/nests/abc", event.url()) + assertEquals("GET", event.method()) + assertTrue(event.verify(), "signature must verify") + } + + @Test + fun header_binds_to_the_exact_url_and_method() = + runTest { + val signer = NostrSignerInternal(KeyPair()) + val a = NestsAuth.header(signer, "https://a.example.com/foo", "GET") + val b = NestsAuth.header(signer, "https://a.example.com/foo", "POST") + val c = NestsAuth.header(signer, "https://a.example.com/bar", "GET") + + fun tagsOf(header: String) = + (JacksonMapper.fromJson(Base64.decode(header.removePrefix("Nostr ")).decodeToString()) as HTTPAuthorizationEvent).let { + Triple(it.url(), it.method(), it.id) + } + + val (urlA, methodA, idA) = tagsOf(a) + val (urlB, methodB, idB) = tagsOf(b) + val (urlC, methodC, idC) = tagsOf(c) + + assertEquals("https://a.example.com/foo", urlA) + assertEquals("GET", methodA) + assertEquals("POST", methodB) + assertEquals("https://a.example.com/bar", urlC) + // Same url+method with different signer instance still produces same binding fields but new id. + assertTrue(idA != idB, "different method must produce a distinct event id") + assertTrue(idA != idC, "different url must produce a distinct event id") + } +} diff --git a/settings.gradle b/settings.gradle index ab5462e42..192a1480d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,5 +36,6 @@ include ':benchmark' include ':quartz' include ':commons' include ':ammolite' +include ':nestsClient' include ':desktopApp' include ':cli'