Trust the relay you typed, pin the relays that are ours
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<Int>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user