Keep off-the-record messages out of the outbox on disk #170

Merged
paulhitt merged 1 commits from fix/off-the-record-outbox into main 2026-09-22 11:53:30 +00:00
2 changed files with 129 additions and 1 deletions
@@ -1304,7 +1304,33 @@ class Vault(
*/
fun outbox(): JSONArray = readJson(OUTBOX)?.optJSONArray("pending") ?: JSONArray()
fun saveOutbox(pending: JSONArray) = writeJson(OUTBOX, JSONObject().put("pending", pending))
/**
* Write the queue, minus anything for an off-the-record conversation.
*
* An entry carries `to` and `text`, so this is the message and not a note
* about it. Every enqueue writes here directly, so an off-the-record send
* queued for later reached disk in plaintext the moment it was typed, for
* a conversation §6.28 says is gone when the app closes.
*
* The filter is here rather than at the four call sites for the same
* reason it is on [saveUnreadThreads]: a later caller cannot forget it.
* A group entry carries `thread` and no user-thread setting, so it passes
* through.
*
* Dropping a queued send is the promise, not the cost of it. The thread
* it belongs to is not kept either, so a message that survived would
* arrive into a conversation the app no longer has.
*/
fun saveOutbox(pending: JSONArray) {
val kept = JSONArray()
for (i in 0 until pending.length()) {
val entry = pending.optJSONObject(i) ?: continue
val to = entry.optLong("to", 0L)
if (to != 0L && offTheRecord(threadForUser(to))) continue
kept.put(entry)
}
writeJson(OUTBOX, JSONObject().put("pending", kept))
}
/**
* Calls placed and received, newest last.
@@ -0,0 +1,102 @@
package io.flamenet.e2e
import org.json.JSONArray
import org.json.JSONObject
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A queued send for an off-the-record conversation never reaches the vault.
*
* This is the message, not a note about it: an entry carries `to` and `text`.
* Every enqueue writes the whole queue back, so an off-the-record send queued
* for later was on disk in plaintext the moment it was typed.
*/
class OffTheRecordOutboxTest {
private class MapStorage : Vault.Storage {
val records = LinkedHashMap<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 key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
private fun entry(
to: Long,
text: String,
) = JSONObject().put("to", to).put("text", text).put("at", 1L)
private fun texts(array: JSONArray): List<String> =
(0 until array.length()).mapNotNull { array.optJSONObject(it)?.optString("text") }
@Test
fun `a send queued in an off the record thread is not written`() {
val storage = MapStorage()
val k = key()
Vault(storage, k).apply {
saveOffTheRecord(Vault.threadForUser(7L), true)
saveOutbox(JSONArray().put(entry(7L, "meet me at six")).put(entry(8L, "ordinary")))
}
val restored = texts(Vault(storage, k).outbox())
assertFalse("meet me at six" in restored, "the off-the-record message text was written to the vault")
assertTrue("ordinary" in restored)
assertEquals(1, restored.size)
}
@Test
fun `nothing is dropped when no thread is off the record`() {
val storage = MapStorage()
val k = key()
Vault(storage, k).saveOutbox(JSONArray().put(entry(7L, "one")).put(entry(8L, "two")))
assertEquals(listOf("one", "two"), texts(Vault(storage, k).outbox()))
}
/**
* A group entry names the group thread and carries the member it is going
* to, and a group has no off-the-record setting of its own.
*/
@Test
fun `a group entry passes through`() {
val storage = MapStorage()
val k = key()
Vault(storage, k).saveOutbox(
JSONArray().put(entry(8L, "to the group").put("thread", "group:g-1")),
)
assertEquals(listOf("to the group"), texts(Vault(storage, k).outbox()))
}
/**
* Turning it on does not reach back into what was already written, but the
* next save must drop it: the queue is rewritten whole every time.
*/
@Test
fun `the next save after turning it on clears the queue`() {
val storage = MapStorage()
val k = key()
val v = Vault(storage, k)
v.saveOutbox(JSONArray().put(entry(7L, "still here")))
assertEquals(listOf("still here"), texts(Vault(storage, k).outbox()))
v.saveOffTheRecord(Vault.threadForUser(7L), true)
v.saveOutbox(v.outbox())
assertTrue(texts(Vault(storage, k).outbox()).isEmpty())
}
}