Check every key the relay can prove, and reach other relays

Two things this app could describe and not do.

**Key transparency was a switch almost nobody found.** `requireKeyLog`
was off by default, so the log was published, verified in tests, and
checked for approximately no one. The reason it was opt-in — a relay
predating §19 keeps no log, and refusing every peer there is worse than
the attack — argues for skipping relays that CANNOT answer, never for
skipping the ones that can. `enforceKeyLogIfServed` draws that line: ask
the head route once, enforce fully if it answers, skip if it is absent.
The switch survives as the stricter question underneath — refuse a relay
that keeps no log at all — which is a real choice for somebody who knows
their server publishes one.

Two things make that safe rather than merely automatic. The head is
asked for SEPARATELY, because `enforceKeyLog` reads three routes and a
relay wanting to skip the check would otherwise only have to 404 the
middle one. And a relay that has answered before is remembered, so it
cannot publish a log until clients are talking and then stop — logs do
not disappear, and that is a fork rather than an absence. The flag is
its own vault entry rather than the pin, because the pin can arrive
from gossip and would make a logless relay look dishonest.

Both subject forms are now accepted for a leaf. A leaf names `fnid:`
once an account has claimed an address and `local:` before, and the hash
committed to whichever was true when it was written — so a contact who
registered a device and then claimed an address has older leaves under
one form and newer ones under the other. A verifier computing a single
subject would refuse half the network on the day claiming turned on.
Either candidate still requires the relay to have appended a leaf
binding the exact key it served.

**Federation was iOS-only.** The relay has spoken it for a while and so
has iPhone; Android could neither send to an address nor read one that
arrived. Now: the three remote routes, an always-sealed send that
carries our address as the return path, and a receive side that files a
federated message under that address rather than under `from_user`,
which their relay never set. Filing by user id would drop every message
from every other relay into thread 0 — or worse, into a local contact's,
if their row numbers happened to collide.

The address keyspace is kept apart from the local one throughout, and
that is the load-bearing decision. A remote sender is pinned, bound and
threaded by `fnid@relay`; they cannot write a local delivery key or
avatar, cannot post into a group, and cannot ring a phone, because all
four are addressed by a user id that means nothing across the boundary.

Wired end to end rather than left as a capability: the add-contact field
recognises an address and offers to message it — the QR scanner already
prefilled that field and had its address looked up as a handle — and
conversations on other relays get their own section, without which a
reply is unreachable short of retyping 52 characters of base32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 15:40:28 -04:00
co-authored by Claude Opus 4.8
parent 0a7c112b8b
commit 91f58956cf
14 changed files with 1601 additions and 67 deletions
@@ -76,6 +76,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import io.flamenet.client.IdentityTrust
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.SafetyNumber
import io.flamenet.e2e.Vault
import java.text.SimpleDateFormat
@@ -159,6 +160,60 @@ fun ColumnScope.BuddyList(
query.isBlank() ||
it.displayName.contains(query, true) || it.handle.contains(query, true)
}
// 🔴 **Conversations on other relays get their own section**, because they
// are not contacts and cannot be made into any. A remote peer has no
// account here, so nothing adds them to a roster — and without a row, a
// reply that arrives sits in a thread with no way back to it short of
// retyping a 52-character derived key.
val remote = phase.remoteAddresses.reversed().filter {
query.isBlank() || it.contains(query, true)
}
if (remote.isNotEmpty()) {
SectionLabel("Other relays")
Column(modifier = Modifier.fillMaxWidth().testTag("remote-threads")) {
remote.forEach { address ->
val unread = Vault.threadForAddress(address) in phase.unread
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { model.openAddressChat(address) }
.padding(vertical = Kit.rowGap / 2)
.testTag("remote-$address"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
) {
// ⚠️ No presence dot and no initials. We know nothing about
// this person beyond an address — their relay does not tell
// ours whether they are online, and initials taken off a
// base32 key would be two meaningless characters dressed up
// as a name.
Avatar(name = "", online = false, picture = null, model = model)
Column(modifier = Modifier.weight(1f)) {
Text(
address.substringAfter('@'),
fontSize = 16.sp,
fontWeight = if (unread) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// The address in full underneath, monospaced. An fnid
// is a derived key, not a name — one that is nearly
// right belongs to somebody else — so it is never
// abbreviated to something that merely looks familiar.
Text(
address.substringBefore('@'),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = Palette.SecondaryText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
SectionLabel("Contacts")
}
Column(modifier = Modifier.fillMaxWidth().testTag("buddies")) {
shown.forEach { buddy ->
val name = buddy.displayName.ifBlank { buddy.handle }
@@ -773,11 +828,18 @@ fun ColumnScope.AccountScreen(
Spacer(Modifier.height(Space.sm))
}
// 🔴 This switch used to be what turned checking ON, and it was off by
// default — so the protection shipped and almost nobody had it.
// Checking is now automatic on any server that keeps a log; what is
// left here is the narrower question of what to do where there is
// nothing to check against.
SettingsRow(
label = "Require published keys",
description = "Refuse to send unless this server can prove the recipient's keys "
+ "appear in the public log it publishes. Older servers keep no log and every "
+ "message to them will fail.",
label = "Also refuse servers with no log",
description = "Your app already checks that a contact's keys appear in the public "
+ "log this server publishes, on its own, before the first message. A server "
+ "old enough to keep no log has nothing to show, and those chats go ahead "
+ "unchecked — turn this on to refuse them instead. Only if you know your "
+ "server publishes a log, or nobody on it will be reachable.",
) {
Switch(
checked = phase.requireKeyLog,
@@ -2604,27 +2666,52 @@ fun ColumnScope.AddContactForm(
return
}
// 🔴 **A scanned code is an ADDRESS, and this form treated everything
// as a username.** The scanner already prefills here, so somebody who
// scanned a card from another relay got their address looked up as a
// handle on ours, was told no such person exists, and had no other way
// in. One field either way — what it is, is a fact about the text, not
// a mode the person has to pick.
val card = remember(username) { ContactCard.decode(username) }
OutlinedTextField(
value = username,
onValueChange = {
username = it
if (phase.lookedUp != null || phase.lookupMiss != null) model.clearLookup()
},
label = { Text("Username") },
label = { Text(if (card != null) "Address" else "Username or address") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("new-contact"),
)
// ⚠️ **Look up first, then add.** Adding straight from a typed string
// means a typo silently becomes a contact who is a stranger, and the
// first thing sent to them is a message meant for somebody else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
if (card != null) {
Text(
"That's an address on ${card.relay}. Messages go straight there — nothing "
+ "to add to your contacts first, and you don't need an account on it.",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.testTag("address-recognised"),
)
KitPrimaryButton(
text = "Message them",
onClick = { model.openAddressChat(username); onDone() },
enabled = !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "message-address",
)
} else {
// ⚠️ **Look up first, then add.** Adding straight from a typed
// string means a typo silently becomes a contact who is a stranger,
// and the first thing sent to them is a message meant for somebody
// else.
KitPrimaryButton(
text = "Look up",
onClick = { model.lookupMember(username) },
enabled = username.isNotBlank() && !phase.busy,
modifier = Modifier.fillMaxWidth(),
tag = "lookup-contact",
)
}
phase.lookupMiss?.let {
Text(
@@ -11,6 +11,8 @@ import io.flamenet.client.RelayException
import io.flamenet.e2e.AccountKdf
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Ed25519
import io.flamenet.client.AccountIdentity
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.FNID
import io.flamenet.e2e.HistoryExport
import io.flamenet.e2e.Vault
@@ -95,6 +97,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/
val snoozedThreads: Set<String> = emptySet(),
/**
* Conversations with people on other relays, most recent last.
*
* 🔴 **Without this a federated conversation is write-only.** A
* remote peer is not in the roster — they have no account here and
* no user id — so a reply that arrives lands in a thread with
* nothing on any screen pointing at it. The address had to be
* retyped to get back in, which for a 52-character derived key is
* the same as unreachable.
*/
val remoteAddresses: List<String> = emptyList(),
/** Same question for the group list. See [buddiesLoaded]. */
val groupsLoaded: Boolean = false,
val groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
@@ -204,6 +218,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val lines: List<Session.Line>,
val peerId: Long? = null,
val group: io.flamenet.client.RelayClient.Group? = null,
/**
* `fnid@relay` when this conversation is with somebody on another
* relay, null when it is not.
*
* ⚠️ A third case beside [peerId] and [group], not a variant of the
* first. A remote peer's user id belongs to their relay and means
* nothing here, so every path that dispatches on `peerId` — sending,
* calling, the safety number, the disappearing timer — has to see
* `null` and take the address branch instead of quietly using a row
* number that points at somebody else.
*/
val address: String? = null,
/**
* What we believe about the other side's identity keys (§19).
*
@@ -462,6 +488,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
relay.registerDevice(device, preKeys)
vault.saveIdentity(device)
// 🔴 **Register the address, don't just derive it.** Deriving it makes
// it ours; claiming is what makes us *reachable* at it. A peer relay
// resolving `fnid@here` asks this relay, and until the claim lands the
// answer is "no such address" — so every account had an address it
// could show on a card and nobody could send to.
//
// ⚠️ `runCatching`, like the derivation above: a relay that is down,
// too old to have the route, or simply slow must not keep somebody out
// of their own messages. It is idempotent server-side and retried on
// the next sign-in, so losing one attempt costs one launch.
if (accountKeySeed != null) {
runCatching { AccountIdentity.claim(relay, accountKeySeed, account.userId) }
}
vault.saveCredential(Vault.Credential(
relayBaseUrl = config.baseUrl,
username = account.username,
@@ -479,6 +519,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
api = io.flamenet.client.MessengerApi(config.baseUrl, account.username, credential),
)
// 🔴 **Our return address, before the first federated send.** A relay
// that accepts a federated envelope records no sender, and our user id
// means nothing on it — so without this a message crosses the boundary,
// decrypts on their phone, and cannot be replied to.
//
// ⚠️ Through `ContactCard.canonical` rather than string-built: the
// recipient parses it with the same rules, and an address that would
// not survive the trip is better left unset than sealed. Half an
// address is worse than none.
session!!.conversations.myAddress =
fnid?.let { ContactCard.canonical("$it@${config.host}") }
val fetchedBuddies = runCatching { session!!.roster() }
val fetchedGroups = runCatching { session!!.relay.listGroups() }
@@ -502,6 +554,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// while sends were being refused, which is the same confusion in
// the opposite direction.
requireKeyLog = strictKeyLog,
remoteAddresses = session!!.remoteAddresses(),
buddies = fetchedBuddies.getOrDefault(emptyList()),
buddiesLoaded = fetchedBuddies.isSuccess,
avatars = session?.avatars(
@@ -610,6 +663,41 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Open a conversation with somebody on another relay.
*
* ⚠️ **Nothing is fetched here, and nothing is verified.** A remote thread
* exists as soon as an address has been typed, exactly as a local one
* exists before the first message — the address is checked when a message
* is actually sent, where a refusal has something to say to the person who
* caused it. Reaching across a relay boundary to validate a paste would
* also tell that relay somebody was thinking about one of its users.
*
* A malformed address opens nothing rather than opening a thread that can
* never send. `ContactCard.decode` is the one place that decides.
*/
fun openAddressChat(text: String) {
val current = session ?: return
val card = ContactCard.decode(text) ?: run {
update { it.copy(error = "That is not an address. It looks like " +
"fnid:… followed by @ and the relay's hostname.") }
return
}
if (card.address == current.conversations.myAddress) {
update { it.copy(error = "That is your own address.") }
return
}
val thread = Vault.threadForAddress(card.address)
update {
it.copy(
openChat = Phase.Chat(
thread, card.address, current.history(thread), address = card.address),
unread = it.unread - thread,
error = null,
)
}
}
fun openGroup(group: io.flamenet.client.RelayClient.Group) {
val current = session ?: return
val thread = Vault.threadForGroup(group.id)
@@ -1836,7 +1924,22 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val result = withContext(Dispatchers.IO) {
runCatching {
val group = chat.group
val address = chat.address
if (group != null) current.sendGroup(group, text)
// 🔴 Before the `peerId!!` below, because a remote thread has
// no peer id and that assertion would crash rather than send.
//
// ⚠️ No link preview. §6.12 fetches the card on the SENDER's
// device to keep the recipient off the URL, and doing that here
// would tell the site that somebody on this relay is writing to
// somebody on another one — a correlation the whole federated
// path exists to avoid. A plain URL is a perfectly good thing
// for a message to contain.
else if (address != null) {
val card = ContactCard.decode(address)
?: error("this conversation's address is unreadable")
current.sendToAddress(card, text)
}
// §6.12 — fetched HERE, on the sender's device, and carried
// inside the ciphertext. The recipient never touches the URL: a
// card each of them fetched would tell the site who read the
@@ -2023,6 +2126,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// A thread that is already open is being read,
// so it does not also become unread.
unread = state.unread + threads - setOfNotNull(open?.threadId),
// ⚠️ Refreshed here, not only at sign-in. A
// first message from another relay creates a
// thread nothing else on any screen points at —
// its sender is not in the roster and never will
// be — so a list computed once at launch would
// leave the reply unreachable until the next
// one.
remoteAddresses = current.remoteAddresses(),
)
}
}
@@ -4,6 +4,7 @@ import io.flamenet.client.Conversations
import io.flamenet.client.MessengerApi
import io.flamenet.client.RelayClient
import io.flamenet.e2e.Attachment
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
@@ -305,6 +306,25 @@ class Session(
return sent
}
/**
* Send to somebody on another relay, and record it in their thread.
*
* ⚠️ **Not queued on failure, unlike [send].** The local outbox retries by
* user id against this relay; a remote address has neither. A federated
* send that fails is reported so the person can try again rather than
* silently held in a queue that will never drain.
*/
fun sendToAddress(card: ContactCard, text: String): Conversations.Sent {
val sent = conversations.sendToAddress(card, Content.text(text))
record(Vault.threadForAddress(card.address), text, mine = true,
at = System.currentTimeMillis(),
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
return sent
}
/** Addresses on other relays this device has exchanged messages with. */
fun remoteAddresses(): List<String> = conversations.remoteAddresses()
/** One call, as the list draws it. */
data class CallEntry(
val peerId: Long,
@@ -670,8 +690,14 @@ class Session(
fun receive(): Set<String> {
val everything = conversations.poll()
everything.forEach { message ->
message.content.call?.let { signal ->
onCallSignal?.invoke(message.fromUser, signal, message.at)
// ⚠️ Local calls only. Signalling is addressed by user id the
// whole way down — the answer, the ICE candidates, the hang-up —
// so a federated offer would ring and then have nowhere to reply
// to. Ignored rather than half-rung.
if (message.fromAddress == null) {
message.content.call?.let { signal ->
onCallSignal?.invoke(message.fromUser, signal, message.at)
}
}
}
val arrived = everything.filter { it.content.isRenderable }
@@ -686,7 +712,7 @@ class Session(
// One that arrives already expired — posted while this device was
// offline — is dropped rather than shown late.
val ref = message.content.attachment
if (ref != null && ref.kind == Attachment.STORY) {
if (ref != null && ref.kind == Attachment.STORY && message.fromAddress == null) {
val story = Vault.Story(
id = ref.id,
authorId = message.fromUser,
@@ -708,15 +734,25 @@ class Session(
// member we REMOVED, whose 1:1 session outlives their membership —
// post into a group they do not belong to. The roster is the
// relay's; a group it does not list for us is not ours.
val gid = message.content.groupId
// ⚠️ A group id from another relay names a group on THEIR relay,
// which our roster check would look up here and either miss or,
// worse, match by coincidence. Groups do not federate yet, so a
// remote sender's group claim is ignored and the message files in
// their own thread.
val gid = if (message.fromAddress != null) null else message.content.groupId
if (gid != null) {
val known = groupRoster(gid)
if (known == null || message.fromUser !in known) {
continue
}
}
// 🔴 **A federated message files under its ADDRESS.** Their relay
// told ours nothing about who sent it, so `fromUser` is 0 — filing
// by it would drop every message from every other relay into one
// thread with no name on it, mixed together.
val thread = gid
?.let(Vault::threadForGroup)
?: message.fromAddress?.let(Vault::threadForAddress)
?: Vault.threadForUser(message.fromUser)
record(thread, message.content.text, mine = false,
attachment = message.content.attachment,