diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 3457683..21464bc 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1369,6 +1369,11 @@ fun ColumnScope.ChatScreen( // identities and no single answer, so this is for one-to-one threads only. if (chat.peerId != null) IdentityPanel(model, chat) + // Directly on the thread, for the same reason as the identity panel: both + // are facts about this conversation, and a fact the user has to go looking + // for is one they will not rely on. + if (chat.peerId != null) DisappearingPanel(model, chat) + LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"), @@ -2598,3 +2603,71 @@ fun RegisteredDevicesSection( } } } + +/** + * The disappearing-message timer for one conversation (§6.11). + * + * 🔴 **On the thread, not in a menu.** The request was "fewer taps to turn on + * and clearer per-conversation state", and both halves are the same problem: a + * timer nobody can see is a timer nobody trusts. This shows what is set and + * changes it in one tap. + * + * ⚠️ Both sides delete. The peer is told inside the ratchet and adopts the same + * timer, so the copy says "for both of you" — a timer only the setter honoured + * would be worse than none, because they would believe their words were gone + * from a device still holding them. + */ +@Composable +private fun ColumnScope.DisappearingPanel( + model: MessengerViewModel, + chat: MessengerViewModel.Phase.Chat, +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm)) { + Text("Disappearing messages", style = MaterialTheme.typography.titleSmall) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = Space.xs), + horizontalArrangement = Arrangement.spacedBy(Space.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + DISAPPEAR_CHOICES.forEach { (label, seconds) -> + KitChip( + text = label, + selected = chat.disappearSeconds == seconds, + onClick = { model.setDisappearing(seconds) }, + tag = "disappear-$seconds", + ) + } + } + + Text( + if (chat.disappearSeconds == 0) { + "Messages stay until one of you deletes them." + } else { + "New and existing messages disappear from BOTH phones " + + "${DISAPPEAR_LABELS[chat.disappearSeconds] ?: "after the timer"} " + + "after they are sent. The relay is never told this is set." + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = Space.xs), + ) + } +} + +/** + * ⚠️ Presets, not a free-form duration. Every extra option is a decision the + * user has to make about something they mostly want to be roughly right, and + * the arbitrary values are the ones nobody can reason about later. + */ +private val DISAPPEAR_CHOICES = listOf( + "Off" to 0, + "1 hour" to 3_600, + "1 day" to 86_400, + "1 week" to 604_800, +) + +private val DISAPPEAR_LABELS = mapOf( + 3_600 to "an hour", + 86_400 to "a day", + 604_800 to "a week", +) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 77907bd..71f1b26 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -205,6 +205,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val trust: IdentityTrust = IdentityTrust.UNKNOWN, /** The number both people read aloud. Null until something is pinned. */ val safetyNumber: String? = null, + /** + * This conversation's disappearing-message timer, seconds, 0 = off. + * + * ⚠️ Shown on the thread rather than buried in a menu. A timer the + * user cannot see is one they cannot trust — and both sides of the + * conversation are deleting on it, so it is a fact about the room, + * not a preference. + */ + val disappearSeconds: Int = 0, ) /** @@ -553,6 +562,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { thread, peerName, current.history(thread), peerId = peerId, trust = current.conversations.trust.state(peerId), safetyNumber = current.conversations.trust.safetyNumber(peerId), + disappearSeconds = current.conversations.disappearSeconds(peerId), ), // Opening the thread is what marks it read. Nothing is sent to // the relay for this — read state is local, because a read @@ -792,6 +802,35 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + // -- Disappearing messages (§6.11) --------------------------------------- + + /** + * Set the timer for the open conversation, on both sides. + * + * ⚠️ The peer is told inside the ratchet and adopts it. That is what makes + * this worth offering: a timer only this device honoured would leave the + * user believing their words were gone from a phone still holding them. + */ + fun setDisappearing(seconds: Int) = viewModelScope.launch { + val current = session ?: return@launch + val chat = signedIn()?.openChat ?: return@launch + val peerId = chat.peerId ?: return@launch + val done = withContext(Dispatchers.IO) { + runCatching { current.conversations.setDisappearing(peerId, seconds) } + } + update { state -> + state.copy( + openChat = state.openChat?.copy( + disappearSeconds = current.conversations.disappearSeconds(peerId), + // Applying the timer expires anything already past it, so + // the thread has to be re-read rather than left on screen. + lines = current.history(chat.threadId), + ), + error = done.exceptionOrNull()?.let(::describe), + ) + } + } + // -- Presence ------------------------------------------------------------ /** diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index ef0210c..5e45ca8 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -566,6 +566,31 @@ class Conversations( return Sent(delivered, failed, sealed = peerKey != null) } + // -- Disappearing messages (§6.11) --------------------------------------- + + /** + * Set this conversation's timer, and tell the other side. + * + * 🔴 **Symmetric by design.** Either party may set it and both delete, so + * the announcement rides the ratchet as ordinary control traffic — the + * relay never learns that a conversation is timed, let alone for how long. + * A timer only one side honoured would be worse than none: the person who + * turned it on would believe their words were gone from a device still + * holding them. + * + * ⚠️ Stored locally **first**. If the send fails, this device still expires + * its own copy — a half-applied timer that deletes too much is recoverable + * by asking again; one that deletes too little is a broken promise. + */ + fun setDisappearing(toUserId: Long, seconds: Int) { + vault.saveDisappearSeconds(Vault.threadForUser(toUserId), seconds) + sendPlaintext(toUserId, Content.disappearing(seconds)) + } + + /** This conversation's timer in seconds, 0 when off. */ + fun disappearSeconds(toUserId: Long): Int = + vault.disappearSeconds(Vault.threadForUser(toUserId)) + /** * Read the inbox, decrypt what we can, and advance the watermark. * @@ -795,7 +820,12 @@ class Conversations( } /** Take in the control messages this layer owns, rather than showing them. */ - private fun consumeControl(fromUser: Long, content: Content) { + /** + * ⚠️ `internal`, not private: this is the seam where a control envelope + * becomes a stored fact, and it is where the wiring is worth asserting + * directly rather than through a full ratchet round trip. + */ + internal fun consumeControl(fromUser: Long, content: Content) { when (content.kind) { Content.Kind.DELIVERY_KEY -> { val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return @@ -818,6 +848,13 @@ class Conversations( val raw = content.raw ?: return trust.gossip(raw.optInt("size"), raw.optString("root")) } + // The other side set a timer (§6.11). Adopted rather than offered: + // a timer both parties have to agree to separately is a timer that + // is off whenever one of them has not noticed the prompt. + Content.Kind.DISAPPEAR -> { + val seconds = Content.disappearSeconds(content) ?: return + vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds) + } else -> Unit } } diff --git a/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt b/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt new file mode 100644 index 0000000..0f8789f --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/DisappearingControlTest.kt @@ -0,0 +1,78 @@ +package io.flamenet.client + +import io.flamenet.e2e.Content +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockWebServer +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The seam where a peer's timer announcement becomes a stored fact (§6.11). + * + * 🔴 **Symmetric or worthless.** If only the party who set the timer expires + * their copy, the feature is a lie: they believe their words are gone from a + * phone that is still holding them. So a `ttl` envelope from the other side has + * to be adopted, not merely offered — which is what this asserts. + */ +class DisappearingControlTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + @AfterTest fun stop() { server.shutdown() } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + 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 conversations(): Pair { + val identity = DeviceIdentity.create() + val vault = Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ).also { it.saveIdentity(identity) } + val relay = RelayClient(server.url("/").toString(), "alice", "app pass word") + return Conversations(identity, vault, relay) to vault + } + + @Test + fun `a timer the peer set is adopted`() { + val (conversations, vault) = conversations() + conversations.consumeControl(42L, Content.parse(Content.disappearing(86_400))) + assertEquals(86_400, vault.disappearSeconds(Vault.threadForUser(42L))) + assertEquals(86_400, conversations.disappearSeconds(42L)) + } + + /** Turning it off has to travel too, or it can only ever be tightened. */ + @Test + fun `a peer turning the timer off is adopted`() { + val (conversations, vault) = conversations() + conversations.consumeControl(42L, Content.parse(Content.disappearing(3_600))) + conversations.consumeControl(42L, Content.parse(Content.disappearing(0))) + assertEquals(0, vault.disappearSeconds(Vault.threadForUser(42L))) + } + + /** ⚠️ Per conversation. One contact's timer must not touch another's. */ + @Test + fun `the timer applies only to the conversation it came from`() { + val (conversations, _) = conversations() + conversations.consumeControl(42L, Content.parse(Content.disappearing(3_600))) + assertEquals(3_600, conversations.disappearSeconds(42L)) + assertEquals(0, conversations.disappearSeconds(99L)) + } + + /** The announcement is control traffic and never lands in the thread. */ + @Test + fun `the announcement is never shown as a message`() { + val parsed = Content.parse(Content.disappearing(60)) + assertEquals(false, parsed.isRenderable) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt index b2d1f1f..0dbb4a5 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt @@ -191,12 +191,21 @@ data class Content( */ const val AVATAR = "avatar" + /** + * A per-conversation disappearing-message timer, in seconds (§6.11). + * + * Announced inside the ratchet so the relay never learns that a + * conversation is timed, let alone for how long. Carries `ctl` because + * it postdates the original four control kinds. + */ + const val DISAPPEAR = "ttl" + /** * ⚠️ Consumed, never shown. Each of these is a real message the web * client sends today, and rendering one puts raw JSON in somebody's * chat. */ - val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR) + val CONTROL = setOf(DELIVERY_KEY, KEY_LOG_HEAD, SENT_COPY, AVATAR, DISAPPEAR) /** * Every kind this build can act on. @@ -306,6 +315,21 @@ data class Content( fun avatar(ref: Attachment.Ref): String = assemble(Kind.AVATAR, attachment = ref) + /** + * Announce this conversation's disappearing-message timer (§6.11). + * + * `seconds` is 0 to turn it off. Hand-assembled like everything else + * here so `fnc` stays the first key — the marker is a prefix test, and + * a reordered top level silently becomes a plain-text message showing + * as raw JSON in somebody's chat. + */ + fun disappearing(seconds: Int): String = + "{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":true,\"secs\":$seconds}" + + /** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */ + fun disappearSeconds(content: Content): Int? = + if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null + /** JSON string escaping, via the library, so control characters survive. */ private fun quote(value: String): String = JSONObject.quote(value) diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 00ea5e4..eea6765 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -62,6 +62,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val OUTBOX = "outbox" private const val CALL_LOG = "call-log" private const val STORY_AUDIENCE = "story-audience" + private const val DISAPPEAR = "disappear" private const val ROSTER = "roster" private const val DEVICE_OWNER_PREFIX = "devowner/" private const val KT_ISSUER_PIN = "kt-issuer-pin" @@ -512,8 +513,19 @@ class Vault(private val storage: Storage, private val key: SecretKey) { * this to matter, the fix is an append-only record format, not a bigger * buffer. */ - fun history(threadId: String): org.json.JSONArray = - readJson(historyName(threadId))?.optJSONArray("messages") ?: org.json.JSONArray() + /** + * A thread, with anything past its disappearing-message timer already gone. + * + * 🔴 **Expiry happens here rather than on a schedule.** Reading is the one + * path every caller shares, so pruning here is what makes it impossible to + * see — or keep — an expired message. A sweep that ran on a timer would + * leave the data on disk between runs, and "disappearing" that leaves the + * data in the vault is a claim, not a feature. + * + * `now` is injectable so the behaviour can be tested without sleeping. + */ + fun history(threadId: String, now: Long = System.currentTimeMillis()): org.json.JSONArray = + pruneExpired(threadId, now) fun append(threadId: String, message: JSONObject) { val existing = history(threadId) @@ -521,6 +533,62 @@ class Vault(private val storage: Storage, private val key: SecretKey) { writeJson(historyName(threadId), JSONObject().put("messages", existing)) } + // -- Disappearing messages (§6.11) --------------------------------------- + + /** + * This conversation's timer in seconds, 0 when off. + * + * ⚠️ In the vault, like everything else. A per-conversation timer is a + * statement about how sensitive a relationship is, which is precisely the + * metadata this app keeps off the server. + */ + fun disappearSeconds(threadId: String): Int = + readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0 + + fun saveDisappearSeconds(threadId: String, seconds: Int) { + val all = readJson(DISAPPEAR) ?: JSONObject() + if (seconds <= 0) all.remove(threadId) else all.put(threadId, seconds) + writeJson(DISAPPEAR, all) + } + + /** + * Drop everything in this thread older than its timer, and persist that. + * + * 🔴 **Called from [history], so nothing can read past it.** Expiry that + * runs on a timer, or only when a screen opens, leaves the messages on disk + * — and "disappearing" that leaves the data sitting in the vault is a claim + * rather than a feature. Reading is the one path every caller shares. + * + * ⚠️ Ages are measured from the timestamp this device recorded, which for a + * received message is when it arrived and for a sent one is when it was + * sent. Deliberately not a clock value the peer supplies: a sender could + * otherwise backdate a message so it vanished from the recipient's phone + * immediately, or forward-date one so it never expired at all. + * + * Returns the surviving messages. + */ + fun pruneExpired(threadId: String, now: Long): org.json.JSONArray { + val seconds = disappearSeconds(threadId) + val stored = readJson(historyName(threadId))?.optJSONArray("messages") + ?: org.json.JSONArray() + if (seconds <= 0 || stored.length() == 0) return stored + + val cutoff = now - seconds * 1000L + val kept = org.json.JSONArray() + for (i in 0 until stored.length()) { + val m = stored.getJSONObject(i) + // A message with no timestamp predates the field. Kept rather than + // destroyed: an unknown age is not evidence of an old one, and + // deleting somebody's history on a guess is unrecoverable. + val at = m.optLong("at", 0L) + if (at == 0L || at >= cutoff) kept.put(m) + } + if (kept.length() != stored.length()) { + writeJson(historyName(threadId), JSONObject().put("messages", kept)) + } + return kept + } + /** * Messages typed but not yet delivered to anybody. * diff --git a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt index 25900ae..26eb83b 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/ContentTest.kt @@ -178,6 +178,45 @@ class ContentTest { } } + /** + * Disappearing-message timers (§6.11). + * + * 🔴 The timer travels **inside the ratchet**. Sent any other way the relay + * would learn that a conversation is timed and for how long — metadata this + * feature exists to avoid producing, on a product whose claim is that the + * server knows as little as possible. + */ + @Test + fun `a disappearing timer round-trips and is control traffic`() { + val parsed = Content.parse(Content.disappearing(86_400)) + assertEquals(Content.Kind.DISAPPEAR, parsed.kind) + assertEquals(86_400, Content.disappearSeconds(parsed)) + assertTrue(parsed.isControl, "the timer is never shown as a message") + assertFalse(parsed.isRenderable) + } + + /** Zero is how the timer is turned off, and must survive as zero. */ + @Test + fun `turning the timer off round-trips as zero`() { + val parsed = Content.parse(Content.disappearing(0)) + assertEquals(0, Content.disappearSeconds(parsed)) + } + + /** + * ⚠️ `fnc` must stay the first key. The marker is a prefix test, so a + * reordered top level makes the envelope decode as a plain text message — + * raw JSON in somebody's conversation. + */ + @Test + fun `the timer envelope starts with the marker`() { + assertTrue(Content.disappearing(60).startsWith("""{"fnc":1,""")) + } + + @Test + fun `asking a non-timer envelope for its timer gives nothing`() { + assertNull(Content.disappearSeconds(Content.parse(Content.text("hello")))) + } + @Test fun `an unknown attachment kind shows a label rather than disappearing`() { val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""") diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt new file mode 100644 index 0000000..41ad53e --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/DisappearingTest.kt @@ -0,0 +1,109 @@ +package io.flamenet.e2e + +import javax.crypto.KeyGenerator +import org.json.JSONObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Disappearing messages (§6.11), at the vault. + * + * 🔴 **Expiry runs on read, and that is the point.** A sweep on a timer leaves + * expired messages on disk between runs, and a "disappearing" message still + * sitting in the vault is a claim rather than a feature. Reading is the one + * path every caller shares. + */ +class DisappearingTest { + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + 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 line(text: String, at: Long) = + JSONObject().put("text", text).put("mine", true).put("at", at) + + private val now = 1_700_000_000_000L + + @Test + fun `with no timer nothing is removed`() { + val v = vault() + v.append("t", line("ancient", 1L)) + assertEquals(1, v.history("t", now).length()) + } + + @Test + fun `messages older than the timer are gone on the next read`() { + val v = vault() + v.saveDisappearSeconds("t", 60) + v.append("t", line("old", now - 120_000)) + v.append("t", line("fresh", now - 10_000)) + + val kept = v.history("t", now) + assertEquals(1, kept.length()) + assertEquals("fresh", kept.getJSONObject(0).optString("text")) + } + + /** ⚠️ Gone from disk, not merely filtered out of the answer. */ + @Test + fun `expiry is a deletion, not a filter`() { + val v = vault() + v.saveDisappearSeconds("t", 60) + v.append("t", line("old", now - 120_000)) + v.history("t", now) + + // Read again with the timer switched off: an expired message must not + // come back, which it would if the first read had only hidden it. + v.saveDisappearSeconds("t", 0) + assertEquals(0, v.history("t", now).length()) + } + + /** + * ⚠️ A message with no timestamp predates the field. An unknown age is not + * evidence of an old one, and deleting somebody's history on a guess cannot + * be undone. + */ + @Test + fun `a message with no timestamp is kept`() { + val v = vault() + v.saveDisappearSeconds("t", 60) + v.append("t", JSONObject().put("text", "from before").put("mine", true)) + assertEquals(1, v.history("t", now).length()) + } + + @Test + fun `the timer is per conversation`() { + val v = vault() + v.saveDisappearSeconds("timed", 60) + v.append("timed", line("old", now - 120_000)) + v.append("untimed", line("old", now - 120_000)) + + assertEquals(0, v.history("timed", now).length()) + assertEquals(1, v.history("untimed", now).length()) + } + + @Test + fun `turning the timer off forgets it`() { + val v = vault() + v.saveDisappearSeconds("t", 3600) + assertEquals(3600, v.disappearSeconds("t")) + v.saveDisappearSeconds("t", 0) + assertEquals(0, v.disappearSeconds("t")) + } + + /** Exactly at the boundary the message survives — off-by-one deletes data. */ + @Test + fun `a message exactly at the cutoff survives`() { + val v = vault() + v.saveDisappearSeconds("t", 60) + v.append("t", line("edge", now - 60_000)) + assertTrue(v.history("t", now).length() == 1) + } +}