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:
co-authored by
Claude Opus 5
parent
e07660559c
commit
9ced7dc3f0
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user