All three deployment shapes, and the one that was unreachable

"Self-hosted with shared identity" could not work. Both clients decided where
accounts lived from a hardcoded list of relay hostnames, and a stranger's relay
is never on that list -- so somebody self-hosting could have the shared FlameNet
account service configured on their relay and the app would still authenticate
against the relay itself. Mode 2 existed on the server and nowhere else.

The relay already published the answer at `/.well-known/flamenet/relay`:
`accounts: shared|local` and the service URL. Nothing read it.

Now the client asks, and then checks the answer against a pinned list of account
SERVICES rather than of relays. That keeps the property the hostname list was
protecting -- an operator cannot point their users' passphrase-derived secrets
at a host they control, because a service the client does not already trust is
treated as local -- while letting any relay adopt the shared service. Unknown or
unreachable descriptor also means local, which is the safe direction: the
passphrase goes only to the server the user typed in.

relay2 is now configured as that third shape, so all three exist to test:
managed (relay.flamenet.io), self-hosted shared (relay2), self-hosted local
(relay1, relay3).

`LiveShapesTest` covers sign-up three rounds per shape, a conversation per
shape, that each relay reports what it actually is, and that a relay naming an
untrusted account service is refused. Fresh handles every round, so rounds are
repeatable by construction rather than by cleanup.

⚠️ Two things worth keeping:

Skips are announced. `return` from an unmet precondition makes a test that did
nothing report PASSED -- I had four of those reporting green before switching to
`assumeTrue`, which is the same silent-skip trap I had already criticised in the
Swift interop harness. A suite of silent skips is indistinguishable from a suite
that works.

The handle travels beside the shared-session token, never inside it. The account
service will not put a handle in a token, so a relay cannot correlate its users
with another product's; the relay keys on the product-scoped pseudonym and uses
the handle only the first time it sees one. Tested by signing in twice and
asserting the same relay account comes back.

Also deploys the discovery-document fix to relay.flamenet.io, with a database
backup and a rollback image. The transparency-log root is byte-identical after
the deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-24 05:28:15 -04:00
co-authored by Claude Opus 5
parent e07660559c
commit 9ced7dc3f0
6 changed files with 443 additions and 38 deletions
@@ -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(
@@ -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<SiteConfig?>(null)
val siteFlow: StateFlow<SiteConfig?> = _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
@@ -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."
}
}