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:
co-authored by
Claude Opus 5.5
parent
4cdc0f9545
commit
d72a852899
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user