diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index de35337..e2e206a 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -94,12 +94,15 @@ private fun Root(model: MessengerViewModel = viewModel()) { @Composable private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewModel.Phase.SignedOut) { val relay by model.relayInput.collectAsStateWithLifecycle() + // Observed, not read once: the account mode is discovered from the relay a + // moment after the address is typed, and the label has to catch up. + val site by model.siteFlow.collectAsStateWithLifecycle() var handle by remember { mutableStateOf("") } var passphrase by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } var registering by remember { mutableStateOf(false) } - val needsEmail = registering && - model.site?.accountProvider == SiteConfig.AccountProvider.LOCAL + // Only a relay keeping its own accounts needs an address for reset mail. + val needsEmail = registering && site?.accountProvider == SiteConfig.AccountProvider.LOCAL Text("Flamenet Messenger", style = MaterialTheme.typography.headlineMedium) @@ -115,8 +118,12 @@ private fun ColumnScope.SignIn(model: MessengerViewModel, phase: MessengerViewMo // Say which kind of server this is BEFORE the passphrase is typed. Whether // the passphrase leaves the device differs between the two, and that is not // something to disclose afterwards. - model.site?.let { - Text(it.identityDescription, style = MaterialTheme.typography.bodySmall) + site?.let { + Text( + it.identityDescription, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("identity-mode"), + ) } OutlinedTextField( diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index e6b649f..774582f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -92,11 +92,38 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { private var session: Session? = null private var polling: kotlinx.coroutines.Job? = null - val site: SiteConfig? get() = SiteConfig.from(_relayInput.value) + val site: SiteConfig? get() = _site.value ?: SiteConfig.from(_relayInput.value) - fun setRelay(value: String) { _relayInput.value = value } + private val _site = MutableStateFlow(null) + val siteFlow: StateFlow = _site.asStateFlow() + private var describeJob: kotlinx.coroutines.Job? = null - init { restore() } + /** + * Setting the relay kicks off discovery. + * + * ⚠️ Debounced, and the result is discarded if the address changed while + * the request was in flight — otherwise a slow answer for a half-typed + * hostname arrives late and relabels a different server. + */ + fun setRelay(value: String) { + _relayInput.value = value + _site.value = SiteConfig.from(value) + describeJob?.cancel() + describeJob = viewModelScope.launch { + kotlinx.coroutines.delay(400) + val config = SiteConfig.from(value) ?: return@launch + val found = withContext(Dispatchers.IO) { + io.flamenet.client.RelayDiscovery.describe(config.baseUrl) + } + if (_relayInput.value != value) return@launch + _site.value = config.also { it.discovered = found } + } + } + + init { + restore() + setRelay(_relayInput.value) + } /** Open the vault. A device already registered here goes straight in. */ private fun restore() = viewModelScope.launch { @@ -191,9 +218,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { // derived secret reaches the account service. The token that // comes back is then exchanged for a relay session — the relay // learns a product-scoped pseudonym and not the handle. - val accounts = AccountsClient(SiteConfig.SHARED_ACCOUNT_SERVICE) + val accounts = AccountsClient(config.accountServiceUrl) if (register) accounts.register(handle, passphrase) - relayAccounts.sharedSession(accounts.signIn(handle, passphrase).token) + relayAccounts.sharedSession(accounts.signIn(handle, passphrase).token, handle) } // A relay-local account authenticates against the relay itself, // which is what "this server keeps its own accounts" means. The diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt index 77848f8..db4c4a3 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt @@ -25,26 +25,6 @@ data class SiteConfig(val baseUrl: String) { } companion object { - /** - * The hosts whose accounts live in the shared service. - * - * ⚠️ **A fixed list, decided here rather than asked of the relay.** - * Letting a relay declare its own identity provider would mean a - * compromised relay could name a host it controls and collect - * passphrase-derived secrets for accounts that are not its own. A - * self-hoster is unaffected: an unlisted host is treated as a relay - * with its own provider, which is the correct default. - */ - private val SHARED_ACCOUNT_HOSTS = setOf( - "flamenet.io", - "www.flamenet.io", - "messenger.flamenet.io", - // The same service under a second name, not a second service. It - // has to be listed explicitly — an unlisted host is treated as - // self-hosted, and that would silently be the wrong provider. - "relay.flamenet.io", - ) - const val SHARED_ACCOUNT_SERVICE = "https://accounts.flamenet.io" /** @@ -72,23 +52,40 @@ data class SiteConfig(val baseUrl: String) { val host: String get() = runCatching { URI(baseUrl).host }.getOrNull().orEmpty() /** - * Derived from the host every time rather than stored: a persisted value - * would outlive a change to the list above and quietly keep using the - * wrong provider. + * Where this relay's accounts live. + * + * ⚠️ **Asked of the relay, then checked against a pinned list of account + * SERVICES.** It used to be a hardcoded list of relay hostnames, which was + * safe and wrong: a self-hosted relay could never be on it, so the + * "self-hosted, shared identity" shape was unreachable. Now the relay says + * which service it uses and the client decides whether that service is one + * it will send a derived secret to — see + * [io.flamenet.client.RelayDiscovery.TRUSTED_ACCOUNT_SERVICES]. A rogue + * operator can only name a service the client already trusts. + * + * Defaults to LOCAL until the descriptor has been fetched, which is the + * safe direction: the passphrase goes only to the server the user typed. */ + var discovered: io.flamenet.client.RelayDiscovery.Descriptor? = null + val accountProvider: AccountProvider - get() = if (host.lowercase() in SHARED_ACCOUNT_HOSTS) { + get() = if (discovered?.usesSharedAccounts == true) { AccountProvider.SHARED } else { AccountProvider.LOCAL } + val accountServiceUrl: String + get() = discovered?.accountsUrl ?: SHARED_ACCOUNT_SERVICE + val usesSharedAccounts: Boolean get() = accountProvider == AccountProvider.SHARED /** What to tell the user this relay does about identity, in their words. */ val identityDescription: String - get() = when (accountProvider) { - AccountProvider.SHARED -> "One Flamenet account, shared with Flamenet Files." - AccountProvider.LOCAL -> "This server keeps its own accounts." + get() = when { + discovered == null -> "Checking what this server uses…" + accountProvider == AccountProvider.SHARED -> + "One Flamenet account, shared with Flamenet Files." + else -> "This server keeps its own accounts." } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index 6625102..eca890f 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -62,8 +62,20 @@ class RelayAccounts( * the handle, so the relay learns an opaque id and nothing that ties the * person to any other FlameNet product. */ - fun sharedSession(accountToken: String): Account = - account(post("api/flamenet/v1/messenger/session", JSONObject().put("token", accountToken))) + /** + * ⚠️ The handle travels **beside** the token, not inside it. The account + * service will not put a handle in a token, so that a relay cannot + * correlate its users with another product's — the token carries a + * product-scoped pseudonym instead. The relay uses the handle only the + * first time it sees that pseudonym; after that the pseudonym decides, and + * honouring the handle would let somebody rename themselves into an + * existing account. + */ + fun sharedSession(accountToken: String, handle: String, displayName: String? = null): Account = + account(post("api/flamenet/v1/messenger/session", JSONObject() + .put("token", accountToken) + .put("handle", handle) + .also { if (!displayName.isNullOrEmpty()) it.put("display_name", displayName) })) /** * Ask the relay to send a reset code. diff --git a/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt b/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt new file mode 100644 index 0000000..4f99677 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt @@ -0,0 +1,90 @@ +package io.flamenet.client + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject + +/** + * What a relay says about itself (`GET /.well-known/flamenet/relay`). + * + * ⚠️ **A client must know where accounts live before it sends a credential + * anywhere.** The two modes stretch the passphrase differently and send the + * result to different hosts; guessing wrong means sending an authentication + * secret to a service that was never meant to receive it. + * + * This replaces a hardcoded list of hostnames. The list was safe but wrong: it + * meant a **self-hosted relay could never use the shared account service**, + * because it would never be on the list — so the "self-hosted, shared identity" + * deployment shape was unreachable in practice. + */ +object RelayDiscovery { + + /** + * Account services a client will send a derived secret to. + * + * ⚠️ **This is the security boundary, and it is pinned here rather than + * taken from the relay.** The relay says *which* service it uses; this says + * which services are acceptable. Without the pin, an operator could point + * their users' passphrase-derived secrets at a host they control, and the + * whole point of stretching on-device would be lost. + * + * A self-hoster running their own account service is not blocked by this — + * their relay reports `local` and authenticates against itself. + */ + val TRUSTED_ACCOUNT_SERVICES = setOf("https://accounts.flamenet.io") + + enum class Accounts { SHARED, LOCAL } + + data class Descriptor( + val audience: String, + val accounts: Accounts, + val accountsUrl: String?, + val federation: String, + val protocolVersions: List, + ) { + /** + * ⚠️ Shared **only** when the named service is one we already trust. + * A relay claiming `shared` with an unknown service is treated as + * local: refusing outright would break a legitimate deployment we + * simply do not know about, while believing it would hand over + * secrets. + */ + val usesSharedAccounts: Boolean + get() = accounts == Accounts.SHARED && accountsUrl in TRUSTED_ACCOUNT_SERVICES + } + + /** + * Ask a relay what it is. + * + * Returns null when the relay does not answer — an older deployment, or + * something that is not a relay at all. The caller then falls back to + * local, which is the safe default: it sends the passphrase only to the + * server the user typed in. + */ + fun describe(baseUrl: String, http: OkHttpClient = RelayClient.defaultHttpClient()): Descriptor? { + val url = baseUrl.trimEnd('/').toHttpUrl().newBuilder() + .addPathSegments(".well-known/flamenet/relay").build() + return try { + http.newCall(Request.Builder().url(url).get().build()).execute().use { response -> + if (!response.isSuccessful) return null + val json = JSONObject(response.body?.string().orEmpty()) + val versions = json.optJSONArray("protocolVersions") + Descriptor( + audience = json.optString("audience"), + accounts = if (json.optString("accounts") == "shared") { + Accounts.SHARED + } else { + Accounts.LOCAL + }, + accountsUrl = json.optString("accountsURL").takeIf { it.isNotEmpty() }, + federation = json.optString("federation", "closed"), + protocolVersions = (0 until (versions?.length() ?: 0)) + .map { versions!!.getInt(it) }, + ) + } + } catch (_: Exception) { + null + } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt new file mode 100644 index 0000000..a063a7a --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt @@ -0,0 +1,272 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * All three deployment shapes, three rounds each, repeatably. + * + * The shapes are not variations on a theme — they differ in **where a + * passphrase-derived secret is sent**, which is the one thing a messenger must + * not get wrong: + * + * - **Managed** (`relay.flamenet.io`) — accounts in the shared FlameNet + * service. One credential across Messenger and Files. + * - **Self-hosted, shared identity** (`relay2`) — somebody else's relay, still + * the shared account service. This shape was **unreachable** until the + * client started reading `/.well-known/flamenet/relay`: the old hardcoded + * hostname list could never contain a stranger's relay. + * - **Self-hosted, local** (`relay1`, `relay3`) — the relay is its own + * identity provider. The default, and not a lesser mode. + * + * ⚠️ **Every round uses fresh handles.** Reusing one makes round two fail with + * "that name is taken" — a relay answering correctly, which reads as a product + * bug. Rounds are therefore repeatable by construction rather than by cleanup. + * + * ⚠️ Managed rounds create real accounts on `accounts.flamenet.io`. Enabled + * explicitly by `FN_LIVE_MANAGED=1`, because that is not litter to leave by + * accident. + * + * FN_LIVE_FLEET=https://relay1.flamenet.io FN_LIVE_MANAGED=1 \ + * ./gradlew :client:test --tests '*LiveShapesTest*' + */ +class LiveShapesTest { + + /** + * ⚠️ **Skips are announced, not silent.** `return` from an unmet + * precondition makes a test that did nothing report as PASSED, and a suite + * of those is indistinguishable from a suite that works. `assumeTrue` + * reports SKIPPED, which is the truth. + */ + private fun needFleet() = assumeTrue( + System.getenv("FN_LIVE_FLEET") != null, "set FN_LIVE_FLEET to run against the fleet") + + private fun needManaged() = assumeTrue( + System.getenv("FN_LIVE_MANAGED") == "1", + "set FN_LIVE_MANAGED=1 — these create real accounts on accounts.flamenet.io") + + private val local = "https://relay1.flamenet.io" + private val sharedSelfHosted = "https://relay2.flamenet.io" + private val managed = "https://relay.flamenet.io" + + private class MapStorage : Vault.Storage { + private val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private fun handle(tag: String) = "shape$tag" + (System.nanoTime() % 10_000_000) + + // -- What each shape claims about itself --------------------------------- + + @Test + fun `each relay reports the shape it actually is`() { + needFleet() + + val localDescriptor = RelayDiscovery.describe(local) + assertTrue(localDescriptor != null, "relay1 served no descriptor") + assertEquals(RelayDiscovery.Accounts.LOCAL, localDescriptor!!.accounts) + assertTrue(!localDescriptor.usesSharedAccounts) + + val sharedDescriptor = RelayDiscovery.describe(sharedSelfHosted) + assertTrue(sharedDescriptor != null, "relay2 served no descriptor") + assertEquals(RelayDiscovery.Accounts.SHARED, sharedDescriptor!!.accounts) + assertTrue(sharedDescriptor.usesSharedAccounts, + "relay2 names ${sharedDescriptor.accountsUrl}, which is not trusted") + + if (System.getenv("FN_LIVE_MANAGED") == "1") { + val managedDescriptor = RelayDiscovery.describe(managed) + assertTrue(managedDescriptor?.usesSharedAccounts == true, + "the managed relay does not report shared accounts") + } + } + + /** + * ⚠️ A relay claiming `shared` with an account service we do not trust must + * NOT be believed. This is the check that stops an operator pointing their + * users' derived secrets at a host they control. + */ + @Test + fun `a relay naming an untrusted account service is treated as local`() { + val rogue = RelayDiscovery.Descriptor( + audience = "rogue.example", + accounts = RelayDiscovery.Accounts.SHARED, + accountsUrl = "https://accounts.rogue.example", + federation = "closed", + protocolVersions = listOf(1), + ) + assertTrue(!rogue.usesSharedAccounts, + "a rogue account service was accepted — derived secrets would go there") + } + + // -- Sign-up, three rounds, per shape ------------------------------------ + + @Test + fun `local self-hosted sign-up works three times running`() { + needFleet() + repeat(3) { round -> localRound(local, "l$round") } + } + + @Test + fun `shared self-hosted sign-up works three times running`() { + needFleet(); needManaged() + repeat(3) { round -> sharedRound(sharedSelfHosted, "s$round") } + } + + @Test + fun `managed sign-up works three times running`() { + needManaged() + repeat(3) { round -> sharedRound(managed, "m$round") } + } + + // -- Messaging on each shape --------------------------------------------- + + /** + * A conversation on every shape, so "it works" is not a claim about one + * relay's configuration. + * + * ⚠️ Deliberately three separate conversations rather than one crossing + * relays. Cross-relay delivery is federation, which is a different + * mechanism with a different failure mode; conflating them would let a + * federation problem masquerade as a messaging problem. + */ + @Test + fun `a conversation works on a local self-hosted relay`() { + needFleet() + talk(local) { tag -> localAccount(local, tag) } + } + + /** + * ⚠️ Split per shape rather than one test covering all three. The shared + * shapes go through the account service, which rate-limits — correctly — + * and a single combined test lets one shape's throttle hide another + * shape's result. + */ + @Test + fun `a conversation works on a shared self-hosted relay`() { + needFleet(); needManaged() + talk(sharedSelfHosted) { tag -> sharedAccount(sharedSelfHosted, tag) } + } + + @Test + fun `a conversation works on the managed relay`() { + needManaged() + talk(managed) { tag -> sharedAccount(managed, tag) } + } + + private fun talk(base: String, make: (String) -> RelayAccounts.Account) { + val alice = ready(base, make("ta")) + val bob = ready(base, make("tb")) + + val text = "hello on $base" + val sent = alice.second.send(bob.first, text) + assertTrue(sent.reachedSomeone, "$base delivered nothing: ${sent.failed}") + + val got = bob.second.poll() + assertEquals(1, got.size, "$base: expected one message, got ${got.size}") + assertEquals(text, got.single().text) + } + + /** An account plus a registered device and a ready conversation layer. */ + private fun ready(base: String, account: RelayAccounts.Account): + Pair { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()) + vault.saveIdentity(identity) + val relay = RelayClient(base, account.username, account.appPassword) + relay.useRelayAuth(identity) + relay.registerDevice(identity, identity.generateOneTimePreKeys(10)) + vault.saveIdentity(identity) + return account.userId to + Conversations(identity, vault, relay).also { it.myUserId = account.userId } + } + + private fun localAccount(base: String, tag: String): RelayAccounts.Account { + val name = handle(tag) + return RelayAccounts(base).signUp(name, "$name@example.invalid", PASSPHRASE) + } + + private fun sharedAccount(base: String, tag: String): RelayAccounts.Account { + val name = handle(tag) + val descriptor = RelayDiscovery.describe(base)!! + val accounts = AccountsClient(descriptor.accountsUrl!!) + accounts.register(name, PASSPHRASE) + return RelayAccounts(base).sharedSession(accounts.signIn(name, PASSPHRASE).token, name) + } + + // -- One round of each kind ---------------------------------------------- + + /** Relay-local: the relay is its own identity provider. */ + private fun localRound(base: String, tag: String) { + val name = handle(tag) + val account = RelayAccounts(base).signUp(name, "$name@example.invalid", PASSPHRASE) + assertEquals(name, account.username) + registerAndProve(base, account) + + // Signing in again must return a working credential for the same + // account, not create a second one. + val again = RelayAccounts(base).signIn(name, PASSPHRASE) + assertEquals(account.userId, again.userId) + } + + /** + * Shared identity: the passphrase is stretched on this device, only the + * derived secret reaches the account service, and the token it returns is + * exchanged with the relay for a relay credential. + */ + private fun sharedRound(base: String, tag: String) { + val name = handle(tag) + val descriptor = RelayDiscovery.describe(base) + assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay") + + val accounts = AccountsClient(descriptor!!.accountsUrl!!) + accounts.register(name, PASSPHRASE) + val session = accounts.signIn(name, PASSPHRASE) + assertTrue(session.token.isNotEmpty(), "no session token from the account service") + + val account = RelayAccounts(base).sharedSession(session.token, name) + assertTrue(account.appPassword.isNotEmpty()) + registerAndProve(base, account) + + // ⚠️ A second sign-in must land on the SAME relay account. The relay + // keys on the product-scoped pseudonym, not the handle; if it keyed on + // the handle, signing in twice would either fail or make a duplicate. + val second = RelayAccounts(base) + .sharedSession(accounts.signIn(name, PASSPHRASE).token, name) + assertEquals(account.userId, second.userId, + "a second sign-in produced a different relay account") + } + + /** Register a device and prove the account is actually reachable. */ + private fun registerAndProve(base: String, account: RelayAccounts.Account) { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()) + vault.saveIdentity(identity) + + val relay = RelayClient(base, account.username, account.appPassword) + relay.useRelayAuth(identity) + relay.registerDevice(identity, identity.generateOneTimePreKeys(10)) + + // Reachable means a stranger can fetch a bundle WITH a one-time prekey. + // A device published without them can send and can never be started + // with, which looks identical from the account's own side. + val bundles = relay.fetchKeys(account.userId) + val mine = bundles.firstOrNull { it.deviceId == identity.deviceId } + assertTrue(mine != null, "$base served no bundle for the device just registered") + assertTrue(mine!!.oneTimePreKey != null, "$base served no one-time prekey") + } + + private companion object { + const val PASSPHRASE = "a-long-enough-passphrase" + } +}