From ff975bf36d16b480eba571b9b36a51b5c850825a Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Mon, 24 Aug 2026 15:39:29 -0400 Subject: [PATCH] Trust the relay you typed, pin the relays that are ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting a rule I got wrong earlier today. I had the client accept a named account service only if it was on a pinned list containing exactly accounts.flamenet.io — which is safe and breaks the case the server already supports: a self-hoster running Messenger and Files together, with their own account service holding the one account across both. The file server has taken ENCLAVE_ACCOUNTS_URL for exactly that since it shipped, and main's relay makes FN_ACCOUNTS_URL configurable for the same reason. A client refusing the answer would make "run both" mean "run them with two separate logins". The rule is asymmetric, and the asymmetry is the point: - For flamenet.io and its aliases the account service is PINNED in the client and whatever those relays advertise is ignored. Compromising one of ours must not let it redirect its users' passphrase-derived secrets. - Every other relay is believed. The user typed that hostname themselves; a server they chose naming its own account service is a choice they already made, and it cannot redirect anybody else's users. Two tests, one for each half: a pinned relay advertising an attacker's service still authenticates against ours, and a self-hosted relay may name its own. --- .../io/flamenet/messenger/app/SiteConfig.kt | 17 ++++--- .../io/flamenet/client/RelayDiscovery.kt | 47 ++++++++++++------- .../io/flamenet/client/LiveShapesTest.kt | 39 +++++++++++---- 3 files changed, 69 insertions(+), 34 deletions(-) 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 db4c4a3..7729287 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt @@ -54,14 +54,13 @@ data class SiteConfig(val baseUrl: String) { /** * 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. + * ⚠️ **Asked of the relay, except for the relays that are ours.** For + * flamenet.io and its aliases the account service is pinned in the client + * and what they advertise is ignored, so compromising one cannot redirect + * its users' derived secrets. Every other relay is believed: the user typed + * that hostname, and a self-hoster running Messenger and Files together + * needs their own account service to be usable across both. See + * [io.flamenet.client.RelayDiscovery.PINNED_RELAYS]. * * Defaults to LOCAL until the descriptor has been fetched, which is the * safe direction: the passphrase goes only to the server the user typed. @@ -76,7 +75,7 @@ data class SiteConfig(val baseUrl: String) { } val accountServiceUrl: String - get() = discovered?.accountsUrl ?: SHARED_ACCOUNT_SERVICE + get() = discovered?.effectiveAccountsUrl ?: SHARED_ACCOUNT_SERVICE val usesSharedAccounts: Boolean get() = accountProvider == AccountProvider.SHARED diff --git a/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt b/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt index 4f99677..08425ca 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayDiscovery.kt @@ -21,18 +21,30 @@ import org.json.JSONObject object RelayDiscovery { /** - * Account services a client will send a derived secret to. + * Relays whose account service is **pinned here and not taken from them**. * - * ⚠️ **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. + * ⚠️ These are ours, and what they advertise is ignored: a compromise of + * one must not be able to redirect its users' passphrase-derived secrets to + * a host the attacker controls. * - * A self-hoster running their own account service is not blocked by this — - * their relay reports `local` and authenticates against itself. + * ⚠️ **Every other relay is believed**, and that is deliberate rather than + * lax. A self-hoster running Messenger and Files together wants one account + * across both, held by their own service — the file server has taken + * `ENCLAVE_ACCOUNTS_URL` for exactly that since it shipped, and a client + * that refused the relay's answer would make "run both" mean "run them with + * two separate logins". The user typed that hostname themselves; a server + * they chose naming its own account service is a choice they already made, + * and it cannot redirect anybody else's users. */ - val TRUSTED_ACCOUNT_SERVICES = setOf("https://accounts.flamenet.io") + val PINNED_RELAYS = mapOf( + "flamenet.io" to OUR_ACCOUNT_SERVICE, + "www.flamenet.io" to OUR_ACCOUNT_SERVICE, + "messenger.flamenet.io" to OUR_ACCOUNT_SERVICE, + // The same service under a second name, not a second service. + "relay.flamenet.io" to OUR_ACCOUNT_SERVICE, + ) + + const val OUR_ACCOUNT_SERVICE = "https://accounts.flamenet.io" enum class Accounts { SHARED, LOCAL } @@ -43,15 +55,18 @@ object RelayDiscovery { val federation: String, val protocolVersions: List, ) { + val usesSharedAccounts: Boolean get() = accounts == Accounts.SHARED && accountsUrl != null + /** - * ⚠️ 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. + * The service to actually authenticate against. + * + * ⚠️ For a relay we pin, **ours** — whatever it advertised. For anyone + * else, what they advertised. That asymmetry is the whole rule: our + * relays cannot be made to redirect their users, and a relay somebody + * chose to type can name its own service. */ - val usesSharedAccounts: Boolean - get() = accounts == Accounts.SHARED && accountsUrl in TRUSTED_ACCOUNT_SERVICES + val effectiveAccountsUrl: String? + get() = PINNED_RELAYS[audience.lowercase()] ?: accountsUrl } /** diff --git a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt index f4a8c57..723e8a3 100644 --- a/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/LiveShapesTest.kt @@ -126,21 +126,42 @@ class LiveShapesTest { } /** - * ⚠️ 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. + * ⚠️ **Our relays cannot be made to redirect their users.** Whatever + * `relay.flamenet.io` advertises, the client authenticates against the + * service pinned in the app — so compromising one of ours does not let it + * harvest passphrase-derived secrets. */ @Test - fun `a relay naming an untrusted account service is treated as local`() { - val rogue = RelayDiscovery.Descriptor( - audience = "rogue.example", + fun `a pinned relay cannot redirect its users to another account service`() { + val hijacked = RelayDiscovery.Descriptor( + audience = "relay.flamenet.io", accounts = RelayDiscovery.Accounts.SHARED, - accountsUrl = "https://accounts.rogue.example", + accountsUrl = "https://accounts.attacker.example", federation = "closed", protocolVersions = listOf(1), ) - assertTrue(!rogue.usesSharedAccounts, - "a rogue account service was accepted — derived secrets would go there") + assertEquals(RelayDiscovery.OUR_ACCOUNT_SERVICE, hijacked.effectiveAccountsUrl, + "a pinned relay redirected its users' derived secrets") + } + + /** + * ⚠️ And a relay that is NOT ours IS believed — deliberately. A self-hoster + * running Messenger and Files together needs one account across both, held + * by their own service. Refusing here would make "run both" mean "run them + * with two separate logins". The user typed that hostname; it cannot + * redirect anybody else's users. + */ + @Test + fun `a self-hosted relay may name its own account service`() { + val theirs = RelayDiscovery.Descriptor( + audience = "relay.someone-else.example", + accounts = RelayDiscovery.Accounts.SHARED, + accountsUrl = "https://accounts.someone-else.example", + federation = "closed", + protocolVersions = listOf(1), + ) + assertTrue(theirs.usesSharedAccounts) + assertEquals("https://accounts.someone-else.example", theirs.effectiveAccountsUrl) } // -- Sign-up, three rounds, per shape ------------------------------------