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 ------------------------------------