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:
co-authored by
Claude Opus 4.8
parent
0a7c112b8b
commit
91f58956cf
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user