diff --git a/.kotlin/sessions/kotlin-compiler-1766305696648671588.salive b/.kotlin/sessions/kotlin-compiler-1766305696648671588.salive new file mode 100644 index 0000000..e69de29 diff --git a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt index 190db8b..3a0b81e 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountsClient.kt @@ -31,8 +31,15 @@ class AccountsClient( private val base = baseUrl.trimEnd('/').toHttpUrl() - /** The product name this client asks for a session under. */ - private val product = "messenger" + /** + * The product a session is scoped to. + * + * ⚠️ **Sessions are per product, and that is the privacy property.** The + * token carries a pseudonym derived for this product, so Messenger and + * Files receive different opaque ids for the same person and cannot + * correlate their users with each other. + */ + private val defaultProduct = "messenger" /** * What the service advertises for a handle. @@ -66,7 +73,7 @@ class AccountsClient( ) } - fun signIn(handle: String, passphrase: String): Session { + fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session { val params = parameters(handle) // ⚠️ The ADVERTISED iteration count wins over the local constant. An // account created under an older count must still be derivable, and the diff --git a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt index a063a7a..09d7dfa 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt @@ -67,6 +67,30 @@ class LiveShapesTest { private fun handle(tag: String) = "shape$tag" + (System.nanoTime() % 10_000_000) + /** + * Register against the shared account service, pacing for its rate limit. + * + * ⚠️ The throttle is the service working correctly, not a client failure — + * three rounds across two shapes is a dozen registrations in a burst, and + * any sane account service refuses that. So this backs off and retries + * rather than reporting a product bug, and still FAILS if the service never + * lets us through: a rate limit that never clears is a real problem, and + * skipping on it would hide one. + */ + private fun registerPaced(accounts: AccountsClient, name: String) { + var wait = 15_000L + repeat(5) { attempt -> + try { + accounts.register(name, PASSPHRASE) + return + } catch (e: AccountsClient.Failure.RateLimited) { + if (attempt == 4) throw e + Thread.sleep(wait) + wait = (wait * 2).coerceAtMost(120_000) + } + } + } + // -- What each shape claims about itself --------------------------------- @Test @@ -200,7 +224,7 @@ class LiveShapesTest { val name = handle(tag) val descriptor = RelayDiscovery.describe(base)!! val accounts = AccountsClient(descriptor.accountsUrl!!) - accounts.register(name, PASSPHRASE) + registerPaced(accounts, name) return RelayAccounts(base).sharedSession(accounts.signIn(name, PASSPHRASE).token, name) } @@ -230,7 +254,7 @@ class LiveShapesTest { assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay") val accounts = AccountsClient(descriptor!!.accountsUrl!!) - accounts.register(name, PASSPHRASE) + registerPaced(accounts, name) val session = accounts.signIn(name, PASSPHRASE) assertTrue(session.token.isNotEmpty(), "no session token from the account service") diff --git a/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt new file mode 100644 index 0000000..52fb019 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LiveTwoProductsTest.kt @@ -0,0 +1,117 @@ +package io.flamenet.client + +import okhttp3.Request +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * One credential, two products — and whether either is optional. + * + * The claim the product makes is narrow and worth testing exactly: **hosting + * only Messenger leaves nothing missing.** Flamenet Files is not a dependency, + * a fallback, or a hidden requirement. What the shared account service buys is + * one credential across both, and nothing else. + * + * ⚠️ Creates a real account on `accounts.flamenet.io`. Enabled by + * `FN_LIVE_MANAGED=1`. + */ +class LiveTwoProductsTest { + + private val accountsUrl = "https://accounts.flamenet.io" + private val messengerRelay = "https://relay.flamenet.io" + private val filesHost = "https://files.flamenet.io" + private val localRelay = "https://relay1.flamenet.io" + + private fun needManaged() = assumeTrue( + System.getenv("FN_LIVE_MANAGED") == "1", + "set FN_LIVE_MANAGED=1 — this creates a real account") + + private fun needFleet() = assumeTrue( + System.getenv("FN_LIVE_FLEET") != null, "set FN_LIVE_FLEET") + + /** + * ⚠️ **The Messenger-only case, which is the one that matters.** A relay + * keeping its own accounts must work with no account service and no Files + * anywhere — no probe of either, no degraded mode, no missing feature. + */ + @Test + fun `a messenger-only self-hoster needs no account service and no files`() { + needFleet() + val descriptor = RelayDiscovery.describe(localRelay) + assertTrue(descriptor != null, "the relay served no descriptor") + assertEquals(RelayDiscovery.Accounts.LOCAL, descriptor!!.accounts) + assertTrue(descriptor.accountsUrl == null, + "a local relay named an account service: ${descriptor.accountsUrl}") + + // And it can actually run an account, start to finish, with nothing + // else involved. + val handle = "solo" + (System.nanoTime() % 10_000_000) + val account = RelayAccounts(localRelay) + .signUp(handle, "$handle@example.invalid", PASSPHRASE) + assertEquals(handle, account.username) + assertTrue(account.appPassword.isNotEmpty()) + assertEquals(account.userId, RelayAccounts(localRelay).signIn(handle, PASSPHRASE).userId) + } + + /** Files names the same account service Messenger's managed relay does. */ + @Test + fun `files and the managed relay point at the same account service`() { + needManaged() + val http = RelayClient.defaultHttpClient() + val filesConfig = http.newCall( + Request.Builder().url("$filesHost/api/config").get().build() + ).execute().use { JSONObject(it.body?.string().orEmpty()) } + + assertEquals(accountsUrl, filesConfig.getString("accountsURL"), + "Files uses a different account service than Messenger") + assertEquals(accountsUrl, RelayDiscovery.describe(messengerRelay)?.accountsUrl) + } + + /** + * One handle and passphrase, signed in to both products. + * + * ⚠️ And the two tokens must carry **different** pseudonyms. That is the + * whole reason sessions are product-scoped: if Messenger and Files received + * the same opaque id, the two services could correlate their users, which + * is exactly what a shared account service must not enable. + */ + @Test + fun `one account signs in to both products without letting them correlate`() { + needManaged() + val accounts = AccountsClient(accountsUrl) + val handle = "both" + (System.nanoTime() % 10_000_000) + accounts.register(handle, PASSPHRASE) + + val messengerSession = accounts.signIn(handle, PASSPHRASE, product = "messenger") + val filesSession = accounts.signIn(handle, PASSPHRASE, product = "files") + assertTrue(messengerSession.token.isNotEmpty(), "no messenger session") + assertTrue(filesSession.token.isNotEmpty(), "no files session") + + assertNotEquals(messengerSession.pseudonym, filesSession.pseudonym, + "the two products got the SAME pseudonym — they could correlate their users") + + // The messenger half is not merely issued, it is accepted: exchanged at + // the relay for a working relay credential. + val relayAccount = RelayAccounts(messengerRelay) + .sharedSession(messengerSession.token, handle) + assertTrue(relayAccount.appPassword.isNotEmpty(), + "the relay refused a session the account service issued") + + // ⚠️ A files token must NOT be accepted by the messenger relay. The + // product scope is a boundary, not a label; if the relay took it, a + // token minted for one product would work on another. + val refused = runCatching { + RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle) + } + assertTrue(refused.isFailure, + "the messenger relay accepted a token scoped to Files") + } + + private companion object { + const val PASSPHRASE = "a-long-enough-passphrase" + } +}