F2 and F5 on Android: say an unsealed send and a changed address at the time

The iPhone app has said both since 2026-09-23 (flamenetmessenger
e030d63c). Android had the same facts and kept them to itself.

F2, SPEC section 14.5: "a user who has enabled sealed sending SHOULD be
told when a message could not be sent sealed." A send falls back to an
identified one when the recipient's delivery key has gone stale. It is
still encrypted per device; what changes is that the relay is told who
sent it. The sealing record in Privacy counted that, which answers "how
often" and never "did this one". The conversation now says so, in the
same words as iOS, keyed on the peer the send was to. onSendSealed
carries the peer id for that. In memory only, as on iOS, and a later
sealed send does not take it back, because the message that went
identified still did.

F5. The account key comes from the passphrase and a salt the account
service hands back at every sign-in and never stores on the phone, so
that service can serve a different salt, the phone derives a different
fnid, and the sign-in still succeeds. The new fnid replaced the old one
in the saved credential with nothing comparing them. It is compared
before the save now, for the same account on the same relay only (a
different account on a shared phone is a different person), and the
old address is shown above Your address in Settings.

What this does not fix is custody: the salt still lives on one service.

Eleven tests. The notices are rendered with Compose and read back, the
two pure pieces are tested including their controls (same address is
no change; a sealed send records nothing), and the call sites are
pinned from source because losing any of them changes nothing a user
would see: the message still sends and the sign-in still works.
Falsified by unhooking the chat screen and making the record ignore
whether the send was sealed; three tests fail.

358 app unit tests, no failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-26 14:39:31 -04:00
co-authored by Claude Opus 5.5
parent 4cdc0f9545
commit d72a852899
6 changed files with 337 additions and 6 deletions
@@ -740,7 +740,11 @@ fun ColumnScope.AccountScreen(
SettingsSection { ContactListPrivacySection(model) }
PortalBand()
SettingsSection { AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle) }
SettingsSection {
// Beside the address it replaced (audit F5).
phase.addressChangedFrom?.let { AddressChangedNotice(it) }
AddressCard(fnid = phase.fnid, relay = phase.relay, handle = phase.handle)
}
PortalBand()
SettingsSection { MailboxesSection(model, phase) }
@@ -1691,6 +1695,8 @@ fun ColumnScope.ChatScreen(
* changed.
*/
heldMessages: Map<Long, Int> = emptyMap(),
/** Conversations where a send could not go sealed (§14.5). */
unsealedSendAt: Map<Long, Long> = emptyMap(),
) {
// Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
@@ -1818,6 +1824,13 @@ fun ColumnScope.ChatScreen(
// One line, not three panels.
if (chat.peerId != null) ConversationStatusLine(model, chat)
// §14.5, told at the time rather than only counted in Privacy.
if (chat.peerId != null && unsealedSendAt[chat.peerId] != null) {
val row = buddies.firstOrNull { it.userId == chat.peerId }
val name = row?.displayName?.ifBlank { row.handle }.orEmpty()
UnsealedSendNotice(name.ifBlank { "This person" })
}
}
// §6.15/§6.19, which message a sheet is currently about.
@@ -308,6 +308,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
myUserId = current.userId,
groups = current.groups,
heldMessages = current.heldMessages,
unsealedSendAt = current.unsealedSendAt,
)
} else {
// The content area is weighted, and the tab bar is not.
@@ -109,6 +109,17 @@ class MessengerViewModel(
* not be derived.
*/
val fnid: String? = null,
/**
* The address this device had before this sign-in derived a
* different one (audit F5). Null is the ordinary case. See
* [AddressChange].
*/
val addressChangedFrom: String? = null,
/**
* Conversations where a send could not go sealed, and when
* (§14.5, audit F2). In memory only. See [UnsealedSends].
*/
val unsealedSendAt: Map<Long, Long> = emptyMap(),
val buddies: List<MessengerApi.Buddy> = emptyList(),
/**
* Whether the contact list was actually FETCHED, as opposed to
@@ -633,6 +644,10 @@ class MessengerViewModel(
runCatching { AccountIdentity.claim(relay, accountKeySeed, account.userId) }
}
// Compared before it is saved, not only saved: the new fnid used to
// replace the old one here without anything looking at either.
val addressChangedFrom = AddressChange.previousAddress(previous, sameAccount, fnid, config.host)
vault.saveCredential(
Vault.Credential(
relayBaseUrl = config.baseUrl,
@@ -672,6 +687,7 @@ class MessengerViewModel(
deviceId = device.deviceId,
relay = config.host,
fnid = fnid,
addressChangedFrom = addressChangedFrom,
status = "Registered with ${preKeys.size} one-time prekeys published.",
// The switch has to read what is actually enforced.
requireKeyLog = strictKeyLog,
@@ -3562,8 +3578,16 @@ class MessengerViewModel(
private fun wireSealingPolicy(session: Session) {
val settings = MediaSettings(getApplication())
session.conversations.requireSealed = { settings.requireSealedSending }
session.onSendSealed = { wasSealed, at ->
session.onSendSealed = { peerUserId, wasSealed, at ->
settings.sealingRecord = settings.sealingRecord.recording(wasSealed, at)
// And on the conversation, not only in a counter somebody has to go
// looking for. On the main thread, because `update` reads and then
// writes the whole state and a send finishes on IO.
if (!wasSealed) {
viewModelScope.launch(Dispatchers.Main) {
update { it.copy(unsealedSendAt = UnsealedSends.record(it.unsealedSendAt, peerUserId, wasSealed, at)) }
}
}
}
}
@@ -0,0 +1,122 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.Vault
/**
* Two things the iPhone app started saying on 2026-09-23 (audit F2 and F5),
* said here too. Both were facts the app already had and kept to itself.
*/
/**
* The address this device had before this sign-in derived a different one, or
* null, which is the ordinary case (finding F5).
*
* The account key comes from the passphrase and a salt the account service
* holds and hands back at every sign-in. It is never written to this phone. So
* that service can serve a different salt, the phone derives a different fnid,
* and the same service verifies it: the sign-in succeeds and the person now
* holds a different identity. The new fnid used to replace the old one in the
* saved credential without anything comparing the two, which made the
* substitution silent, and silence is what makes it worth doing.
*
* Only for the SAME account on the SAME relay. A different account on a shared
* phone is a different person, not a changed address.
*/
object AddressChange {
fun previousAddress(
previous: Vault.Credential?,
sameAccount: Boolean,
fnid: String?,
host: String,
): String? {
if (previous == null || !sameAccount) return null
val was = previous.fnid ?: return null
// No new address is not a changed one: it means this sign-in could not
// derive one, and AddressCard already says so.
val now = fnid ?: return null
if (was == now) return null
return "$was@$host"
}
}
/**
* Conversations where a send fell back from sealed to identified, and when
* (SPEC §14.5, finding F2).
*
* §14.5: "a user who has enabled sealed sending SHOULD be told when a message
* could not be sent sealed." The sealing record in Privacy settings counts
* the fallback, which answers "how often" and never "did this one".
*
* In memory only, as on iOS. The fallback is a fact about a moment, the notice
* exists to be seen at that moment, and the running total already carries it
* across restarts. A later sealed send does not clear it: the message that went
* identified still did.
*/
object UnsealedSends {
fun record(
seen: Map<Long, Long>,
peerUserId: Long,
wasSealed: Boolean,
at: Long,
): Map<Long, Long> = if (wasSealed) seen else seen + (peerUserId to at)
}
/**
* Said in the conversation where it happened. A note, not an alarm: nothing
* was readable by anyone, and the usual cause is the other person reinstalling.
* Same words as the iPhone app.
*/
@Composable
fun UnsealedSendNotice(name: String) {
Surface(
color = Palette.SurfaceAlt,
shape = RoundedCornerShape(Radius.medium),
modifier = Modifier.fillMaxWidth().testTag("unsealed-send"),
) {
Column(modifier = Modifier.padding(Space.md)) {
Text(
"The server was told you sent this",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
"$name's delivery key had gone stale, so this message could not be sent " +
"sealed. It is encrypted either way; what changed is that the server " +
"knows it came from you. It usually sorts itself out once they write back.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
}
}
/** Beside the address it replaced. Same words as the iPhone app. */
@Composable
fun AddressChangedNotice(was: String) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = Space.md).testTag("address-changed")) {
Text(
"Your address changed",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
"This device used to be $was. Signing in just now produced a different " +
"address. That happens if you changed your passphrase. If you did not, the " +
"account server has given this device a different identity, and people who " +
"had your old address can no longer reach you.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
}
@@ -533,8 +533,9 @@ class Session(
lifetimeSeconds = lifetimeSeconds,
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING,
)
// ios#16, every send's outcome, not just this one's.
onSendSealed(sent.sealed, at)
// ios#16, every send's outcome, not just this one's. And who it was to,
// so the conversation can say so when it was not sealed (§14.5).
onSendSealed(peerUserId, sent.sealed, at)
// Queued, not abandoned.
if (!sent.reachedSomeone) enqueue(peerUserId, plaintext, at)
return sent
@@ -1675,9 +1676,10 @@ class Session(
}
/**
* Told after every 1:1 send whether it was sealed (ios#16).
* Told after every 1:1 send who it was to and whether it was sealed
* (ios#16).
*/
var onSendSealed: (Boolean, Long) -> Unit = { _, _ -> }
var onSendSealed: (peerUserId: Long, wasSealed: Boolean, at: Long) -> Unit = { _, _, _ -> }
fun reactionsFor(storyKey: String): Map<Long, String> = storyReactions[storyKey].orEmpty()
@@ -0,0 +1,169 @@
package io.flamenet.messenger.app
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import io.flamenet.e2e.Vault
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Audit F2 and F5 on Android: two facts the app had and kept to itself are
* said at the time, as the iPhone app has said them since 2026-09-23.
*
* F2, SPEC §14.5: a message that could not go sealed is named in its
* conversation, not only counted in Privacy settings.
*
* F5: an address that comes back different at sign-in is compared with the
* one it replaces and shown beside it, rather than overwritten unseen.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class TheAppSaysItAtTheTimeTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private fun credential(
fnid: String?,
userId: Long = 7,
) = Vault.Credential(
relayBaseUrl = "https://relay.example",
username = "ada",
appPassword = "x",
userId = userId,
fnid = fnid,
)
// -- F5, which address changed ------------------------------------------.
@Test
fun `a different address on the same account is named, with the old one`() {
val was = AddressChange.previousAddress(credential("fnid:old"), sameAccount = true, fnid = "fnid:new", host = "relay.example")
assertEquals("fnid:old@relay.example", was)
}
/** The control: without it, a function that always answered would pass. */
@Test
fun `the same address is not a change`() {
assertNull(AddressChange.previousAddress(credential("fnid:same"), sameAccount = true, fnid = "fnid:same", host = "relay.example"))
}
@Test
fun `a first sign-in, another account, or no address at all is not a change`() {
assertNull(AddressChange.previousAddress(null, sameAccount = true, fnid = "fnid:new", host = "h"), "first sign-in")
assertNull(
AddressChange.previousAddress(credential("fnid:old"), sameAccount = false, fnid = "fnid:new", host = "h"),
"somebody else signing in on a shared phone is not a changed address",
)
assertNull(AddressChange.previousAddress(credential(null), sameAccount = true, fnid = "fnid:new", host = "h"), "nothing saved before")
assertNull(AddressChange.previousAddress(credential("fnid:old"), sameAccount = true, fnid = null, host = "h"), "nothing derived now")
}
// -- F2, which conversation went unsealed --------------------------------.
@Test
fun `an unsealed send names its conversation and a sealed one does not`() {
val none = emptyMap<Long, Long>()
assertEquals(none, UnsealedSends.record(none, peerUserId = 4, wasSealed = true, at = 100))
assertEquals(mapOf(4L to 100L), UnsealedSends.record(none, peerUserId = 4, wasSealed = false, at = 100))
}
/** The message that went identified still did, whatever the next one does. */
@Test
fun `a later sealed send does not take it back`() {
val after = UnsealedSends.record(mapOf(4L to 100L), peerUserId = 4, wasSealed = true, at = 200)
assertEquals(mapOf(4L to 100L), after)
}
// -- What they say --------------------------------------------------------.
@Test
fun `the unsealed notice names the person and says it is still encrypted`() {
compose.setContent { UnsealedSendNotice("Grace") }
compose.onNodeWithTag("unsealed-send").assertIsDisplayed()
compose.onNodeWithText("The server was told you sent this").assertIsDisplayed()
compose.onNodeWithText("Grace's delivery key had gone stale", substring = true).assertIsDisplayed()
compose.onNodeWithText("It is encrypted either way", substring = true).assertIsDisplayed()
}
/**
* Nothing was readable by anyone, so the notice must not suggest it was.
* Read from the notice's own source, bounded, because ChatScreens carries
* other notices where some of these words are correct.
*/
@Test
fun `the unsealed notice does not overstate what happened`() {
val source = File("src/main/kotlin/io/flamenet/messenger/app/SaidAtTheTime.kt").readText()
val start = source.indexOf("fun UnsealedSendNotice")
assertTrue(start >= 0, "SaidAtTheTime.kt was not read")
val notice = source.substring(start, minOf(source.length, start + 1400)).lowercase()
for (forbidden in listOf("not encrypted", "unencrypted", "in the clear", "insecure")) {
assertFalse(notice.contains(forbidden), "the notice suggests the message was readable: \"$forbidden\"")
}
}
@Test
fun `the address notice shows the address it replaced`() {
compose.setContent { AddressChangedNotice("fnid:old@relay.example") }
compose.onNodeWithTag("address-changed").assertIsDisplayed()
compose.onNodeWithText("Your address changed").assertIsDisplayed()
compose.onNodeWithText("This device used to be fnid:old@relay.example.", substring = true).assertIsDisplayed()
}
// -- Wired, not only defined ---------------------------------------------.
//
// Each of these is a call site on a path that nothing else would notice
// losing: the message still sends and the sign-in still succeeds.
private fun main(name: String) = File("src/main/kotlin/io/flamenet/messenger/app/$name").readText()
@Test
fun `the send tells the view model who it was to`() {
assertTrue(main("Session.kt").contains("onSendSealed(peerUserId, sent.sealed, at)"))
val vm = main("MessengerViewModel.kt")
val wiring = vm.substring(vm.indexOf("private fun wireSealingPolicy"))
assertTrue(
wiring.take(1200).contains("UnsealedSends.record(it.unsealedSendAt, peerUserId, wasSealed, at)"),
"the fallback is counted and no longer names the conversation",
)
}
@Test
fun `the conversation reads what the send recorded`() {
val chat = main("ChatScreens.kt")
assertTrue(chat.contains("unsealedSendAt[chat.peerId] != null"), "the notice is not keyed on the record")
assertTrue(chat.contains("UnsealedSendNotice(name.ifBlank"), "the notice is defined and never placed")
assertTrue(
main("MainActivity.kt").contains("unsealedSendAt = current.unsealedSendAt"),
"the chat screen is never handed the record, so it reads an empty map",
)
}
/** Compared BEFORE the new address is saved, or there is nothing left to compare with. */
@Test
fun `sign-in compares the address before it saves the new one`() {
val vm = main("MessengerViewModel.kt")
val compared = vm.indexOf("AddressChange.previousAddress(previous, sameAccount, fnid, config.host)")
assertTrue(compared >= 0, "sign-in no longer compares the address")
// The next save after the comparison is the one right below it.
val saved = vm.indexOf("vault.saveCredential(", compared)
assertTrue(
saved > compared && saved - compared < 600,
"the comparison is not directly before the save, so it may compare the new address with itself",
)
assertTrue(vm.contains("addressChangedFrom = addressChangedFrom,"), "compared and then dropped")
assertTrue(
main("ChatScreens.kt").contains("phase.addressChangedFrom?.let { AddressChangedNotice(it) }"),
"the notice is never placed beside the address",
)
}
}