Disappearing messages, symmetric and from send time (§6.11)
The relay has had per-conversation lifetimes since the port and **no client could set one** — its own comment says so. For E2E conversations the server timer would not have helped anyway: it expires rows in the legacy plaintext table, not the envelopes these apps actually exchange. So this is client-side, which is also the only place it can be honest. **Symmetric.** Either party sets the timer; the other is told inside the ratchet and adopts it. A timer only the setter honoured would be worse than none — they would believe their words were gone from a phone still holding them. **Inside the ratchet**, so the relay never learns that a conversation is timed, let alone for how long. It rides as ordinary control traffic carrying the `ctl` flag from §6.10, which is what makes it safe to add at all: without that, this envelope would surface in every older install as an "[unsupported attachment]" bubble. **Expiry runs on read**, in `Vault.history`. 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, so nothing can see — or keep — an expired message. ⚠️ **Ages come from this device's own timestamp**, not one the peer supplies. "From send time" in practice means when this device sent or received it, which differs by seconds and cannot be manipulated: a peer who chose the clock could backdate a message so it vanished from your phone immediately, or forward-date one so it never expired. ⚠️ **A message with no timestamp is kept.** Those predate the field, and an unknown age is not evidence of an old one — deleting somebody's history on a guess cannot be undone. The panel sits on the thread rather than in a menu, because "fewer taps to turn it on" and "clearer per-conversation state" are the same problem: a timer nobody can see is one nobody trusts. Presets only — arbitrary durations are the ones nobody can reason about later. engine 155, client 94, app 32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c9e386ec0d
commit
f9f063a211
@@ -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<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 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user