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:
Paul Hitt
2026-08-29 11:23:04 -04:00
co-authored by Claude Opus 4.8
parent c9e386ec0d
commit f9f063a211
8 changed files with 471 additions and 4 deletions
@@ -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)
@@ -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.
*
@@ -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"}""")
@@ -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)
}
}