Another account on the same install starts with an empty vault

Found on the emulator against a local relay: after a plain sign-out of
luke1009 and a sign-up as mira1009 on the same install, mira saw luke's
conversation with lina1009, carried andy1009's handle certificate from two
accounts earlier, and sent her first message to lina on luke's ratchet
session. The iPhone could not open it ("no session for envelope 95 from
a26ac1d7") and mira's first contact never arrived. Main has the same sign-in
path: a different account got a new device identity (android#22) and kept
every other record.

AccountSwitch.forgetPreviousAccountIfSwitching empties the vault when a
different account (another user id or relay) signs in, before its device is
made. The same account signing back in keeps everything, as before. The
last account's device identity was already being replaced at that point,
so nothing forgotten was still usable by it.

AnotherAccountStartsWithNothingTest: 2 red, then 4/4 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-09 14:26:53 -04:00
co-authored by Claude Opus 5.5
parent 69a7bd72c6
commit 8842479bb8
3 changed files with 107 additions and 0 deletions
@@ -0,0 +1,28 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Vault
/** What happens to the vault when somebody signs in. */
object AccountSwitch {
/**
* Before a different account takes over this install, forget the last
* one's records: its conversations, sessions, delivery keys, drafts and
* handle certificate. A plain sign-out keeps the vault so the SAME person
* can sign back in; another account on the same install got a new device
* but inherited the rest, saw the last account's conversations and sent
* on its ratchet sessions, which the far end could not open. The last
* account's device was already replaced at this point, so what is
* forgotten could not have been used by it again.
*
* True when something was forgotten.
*/
fun forgetPreviousAccountIfSwitching(
vault: Vault,
previous: Vault.Credential?,
sameAccount: Boolean,
): Boolean {
if (previous == null || sameAccount) return false
vault.forgetEverything()
return true
}
}
@@ -620,6 +620,7 @@ class MessengerViewModel(
(previous.userId == account.userId && previous.relayBaseUrl == config.baseUrl)
val storedIdentity = if (sameAccount) vault.loadIdentity() else null
if (!sameAccount) identity = null
AccountSwitch.forgetPreviousAccountIfSwitching(vault, previous, sameAccount)
val device =
identity
@@ -0,0 +1,78 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Vault
import java.io.File
import javax.crypto.KeyGenerator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Found on the emulator against a local relay, 2026-10-09: a plain sign-out
* keeps the vault so the same person can sign back in, and a DIFFERENT
* account signing in on the same install got a new device identity but kept
* everything else. mira1009 saw luke1009's conversation with lina1009 and
* sent her first message on luke's ratchet session, which the iPhone could not open ("no session for
* envelope 95"). A different account now starts with an empty vault.
*/
class AnotherAccountStartsWithNothingTest {
private class MapStorage : Vault.Storage {
val records = mutableMapOf<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private fun vault() = Vault(MapStorage(), KeyGenerator.getInstance("AES").apply { init(256) }.generateKey())
private fun credential(user: Long) = Vault.Credential("http://127.0.0.1:18810", "u$user", "pw", user)
@Test
fun `a different account finds none of the last one's records`() {
val v = vault()
v.saveCredential(credential(3))
v.saveGroupName("g1", "luke's group")
v.saveDraft("user:4", "for lina")
assertTrue(AccountSwitch.forgetPreviousAccountIfSwitching(v, credential(3), sameAccount = false))
assertNull(v.groupName("g1"))
assertEquals(null, v.draft("user:4"))
assertNull(runCatching { v.loadCredential() }.getOrNull())
}
@Test
fun `the same account keeps everything`() {
val v = vault()
v.saveCredential(credential(3))
v.saveGroupName("g1", "luke's group")
assertFalse(AccountSwitch.forgetPreviousAccountIfSwitching(v, credential(3), sameAccount = true))
assertNotNull(v.groupName("g1"))
}
@Test
fun `a first sign-in has nothing to forget`() {
assertFalse(AccountSwitch.forgetPreviousAccountIfSwitching(vault(), null, sameAccount = true))
}
@Test
fun `sign-in forgets before the new identity is saved`() {
val vm = File("src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt").readText()
val forget = vm.indexOf("AccountSwitch.forgetPreviousAccountIfSwitching(")
val newIdentity = vm.indexOf("DeviceIdentity.create().also {")
assertTrue(forget in 0 until newIdentity, "sign-in does not forget the last account before making this one's device")
}
}