Off-the-record conversations: the mechanism
A thread held in memory and written to disk by neither side. Close the app and it is gone: no file to seize, no backup to leak. It is a proposal, not a switch. The device turning it on sends the control envelope and keeps writing until the peer echoes it back. A build that does not know the kind never echoes, so the thread stays on the record on both sides rather than on one of them, which is worse than not having the feature because both people would believe the first thing. Every message reaches disk through one call, so one gate is enough, and a test pins that count so a second write path cannot quietly walk around it. Ratchet state is still persisted: messages stop decrypting without it, and it is not message content. The control for it comes next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
740e8411cb
commit
ce7f50230b
@@ -90,8 +90,14 @@ class Session(
|
|||||||
val id: String,
|
val id: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off-the-record messages, held here and nowhere else. They go when the
|
||||||
|
* process does, which is the feature rather than a defect.
|
||||||
|
*/
|
||||||
|
private val ephemeral = mutableMapOf<String, org.json.JSONArray>()
|
||||||
|
|
||||||
fun history(threadId: String): List<Line> {
|
fun history(threadId: String): List<Line> {
|
||||||
val stored = vault.history(threadId)
|
val stored = ephemeral[threadId] ?: vault.history(threadId)
|
||||||
return (0 until stored.length()).map {
|
return (0 until stored.length()).map {
|
||||||
val m = stored.getJSONObject(it)
|
val m = stored.getJSONObject(it)
|
||||||
Line(
|
Line(
|
||||||
@@ -153,8 +159,7 @@ class Session(
|
|||||||
*/
|
*/
|
||||||
forwarded: Boolean = false,
|
forwarded: Boolean = false,
|
||||||
) {
|
) {
|
||||||
vault.append(
|
val row =
|
||||||
threadId,
|
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("author", author)
|
.put("author", author)
|
||||||
.also { if (messageId != null) it.put("mid", messageId) }
|
.also { if (messageId != null) it.put("mid", messageId) }
|
||||||
@@ -172,8 +177,16 @@ class Session(
|
|||||||
.also { if (link != null) it.put("lnk", link.toJson()) }
|
.also { if (link != null) it.put("lnk", link.toJson()) }
|
||||||
// Written only when true, for the same reason `secs` is.
|
// Written only when true, for the same reason `secs` is.
|
||||||
.also { if (forwarded) it.put("fwd", true) }
|
.also { if (forwarded) it.put("fwd", true) }
|
||||||
.also { if (about != null) it.put("re", about.toJson()) },
|
.also { if (about != null) it.put("re", about.toJson()) }
|
||||||
)
|
|
||||||
|
// Off the record: the row is held in memory and nothing reaches disk.
|
||||||
|
// This is the one place a message is ever written, which is what makes
|
||||||
|
// a single gate enough.
|
||||||
|
if (vault.offTheRecord(threadId)) {
|
||||||
|
ephemeral.getOrPut(threadId) { org.json.JSONArray() }.put(row)
|
||||||
|
} else {
|
||||||
|
vault.append(threadId, row)
|
||||||
|
}
|
||||||
|
|
||||||
// A reply arriving cancels the reminder, and it is done HERE because
|
// A reply arriving cancels the reminder, and it is done HERE because
|
||||||
// this is the one place every inbound message passes through.
|
// this is the one place every inbound message passes through.
|
||||||
@@ -181,7 +194,19 @@ class Session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Clear a thread. Keeps the session, so the conversation still works. */
|
/** Clear a thread. Keeps the session, so the conversation still works. */
|
||||||
fun clearHistory(threadId: String) = vault.forgetHistory(threadId)
|
fun clearHistory(threadId: String) {
|
||||||
|
ephemeral.remove(threadId)
|
||||||
|
vault.forgetHistory(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this conversation is off the record. */
|
||||||
|
fun offTheRecord(peerUserId: Long): Boolean = conversations.offTheRecord(peerUserId)
|
||||||
|
|
||||||
|
/** Propose it to the other side; it takes effect when they echo. */
|
||||||
|
fun proposeOffTheRecord(
|
||||||
|
peerUserId: Long,
|
||||||
|
on: Boolean,
|
||||||
|
) = conversations.proposeOffTheRecord(peerUserId, on)
|
||||||
|
|
||||||
// -- Scheduled send
|
// -- Scheduled send
|
||||||
// -------------------------------------------------------.
|
// -------------------------------------------------------.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package io.flamenet.messenger.app
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation held in memory and written to disk by neither side.
|
||||||
|
*
|
||||||
|
* `Session` needs a vault and a relay, so this reads the source the way
|
||||||
|
* `SecondDeviceCallTest` does.
|
||||||
|
*/
|
||||||
|
class OffTheRecordTest {
|
||||||
|
private fun read(vararg paths: String): String = paths.map(::File).first { it.exists() }.readText()
|
||||||
|
|
||||||
|
private val session: String by lazy {
|
||||||
|
read(
|
||||||
|
"src/main/kotlin/io/flamenet/messenger/app/Session.kt",
|
||||||
|
"app/src/main/kotlin/io/flamenet/messenger/app/Session.kt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole design rests on there being one way onto disk. If a second
|
||||||
|
* appears, this fails and whoever added it has to gate it too.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a message reaches disk through exactly one call`() {
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
Regex("vault\\.append\\(").findAll(session).count(),
|
||||||
|
"more than one write path means the off-the-record gate can be walked around",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an off-the-record message is held in memory instead of written`() {
|
||||||
|
val start = session.indexOf("if (vault.offTheRecord(threadId))")
|
||||||
|
assertTrue(start >= 0, "record() does not check the setting")
|
||||||
|
val branch = session.substring(start, minOf(session.length, start + 400))
|
||||||
|
assertTrue(branch.contains("ephemeral.getOrPut(threadId)"))
|
||||||
|
assertTrue(branch.contains("vault.append(threadId, row)"), "the ordinary path still writes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the conversation still reads back while the app is open`() {
|
||||||
|
assertTrue(session.contains("ephemeral[threadId] ?: vault.history(threadId)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing a thread drops what was held in memory too`() {
|
||||||
|
val start = session.indexOf("fun clearHistory(")
|
||||||
|
assertTrue(start >= 0)
|
||||||
|
assertTrue(session.substring(start, start + 200).contains("ephemeral.remove(threadId)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `proposing it saves nothing until the other side answers`() {
|
||||||
|
val conv =
|
||||||
|
read(
|
||||||
|
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
|
||||||
|
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
|
||||||
|
)
|
||||||
|
val start = conv.indexOf("fun proposeOffTheRecord(")
|
||||||
|
assertTrue(start >= 0, "no proposal path")
|
||||||
|
val body = conv.substring(start, conv.indexOf("\n /**", start))
|
||||||
|
assertTrue(body.contains("sendPlaintext(toUserId, Content.offTheRecord(on))"))
|
||||||
|
assertTrue(
|
||||||
|
!body.contains("saveOffTheRecord"),
|
||||||
|
"the proposer must not turn it on before the peer has echoed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an arriving proposal is applied and echoed once`() {
|
||||||
|
val conv =
|
||||||
|
read(
|
||||||
|
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
|
||||||
|
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
|
||||||
|
)
|
||||||
|
val start = conv.indexOf("Content.Kind.OFF_RECORD ->")
|
||||||
|
assertTrue(start >= 0, "nothing handles it on the way in")
|
||||||
|
val branch = conv.substring(start, minOf(conv.length, start + 600))
|
||||||
|
assertTrue(branch.contains("vault.saveOffTheRecord"))
|
||||||
|
assertTrue(branch.contains("!Content.offTheRecordIsEcho(content)"), "an echo must not be echoed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -939,6 +939,23 @@ class Conversations(
|
|||||||
sendPlaintext(toUserId, Content.disappearing(seconds))
|
sendPlaintext(toUserId, Content.disappearing(seconds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propose that this conversation goes off the record, or comes back on.
|
||||||
|
*
|
||||||
|
* Nothing is saved here: the proposer waits for the peer to echo it. A
|
||||||
|
* build that does not understand the kind never echoes, so the thread stays
|
||||||
|
* on the record on both sides rather than on one of them.
|
||||||
|
*/
|
||||||
|
fun proposeOffTheRecord(
|
||||||
|
toUserId: Long,
|
||||||
|
on: Boolean,
|
||||||
|
) {
|
||||||
|
sendPlaintext(toUserId, Content.offTheRecord(on))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this conversation is off the record on this device. */
|
||||||
|
fun offTheRecord(toUserId: Long): Boolean = vault.offTheRecord(Vault.threadForUser(toUserId))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer a contact the bridge this phone uses (§6.19).
|
* Offer a contact the bridge this phone uses (§6.19).
|
||||||
*/
|
*/
|
||||||
@@ -1455,6 +1472,16 @@ class Conversations(
|
|||||||
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
|
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Content.Kind.OFF_RECORD -> {
|
||||||
|
val on = Content.offTheRecord(content) ?: return
|
||||||
|
vault.saveOffTheRecord(Vault.threadForUser(fromUser), on)
|
||||||
|
// Answer a proposal so the other side learns this build kept
|
||||||
|
// its side of it. An echo is not echoed back.
|
||||||
|
if (!Content.offTheRecordIsEcho(content)) {
|
||||||
|
runCatching { sendPlaintext(fromUser, Content.offTheRecord(on, echo = true)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,11 @@ data class Content(
|
|||||||
/** A screenshot notice (§6.27). */
|
/** A screenshot notice (§6.27). */
|
||||||
const val SCREENSHOT = "shot"
|
const val SCREENSHOT = "shot"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation held in memory and written to disk by neither side.
|
||||||
|
*/
|
||||||
|
const val OFF_RECORD = "otr"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A replacement for the text of an earlier message (§6.15).
|
* A replacement for the text of an earlier message (§6.15).
|
||||||
*/
|
*/
|
||||||
@@ -283,6 +288,7 @@ data class Content(
|
|||||||
DISAPPEAR,
|
DISAPPEAR,
|
||||||
SCREENSHOT,
|
SCREENSHOT,
|
||||||
BRIDGE,
|
BRIDGE,
|
||||||
|
OFF_RECORD,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,6 +593,20 @@ data class Content(
|
|||||||
// `1`, not `true`.
|
// `1`, not `true`.
|
||||||
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
|
"{\"fnc\":1,\"kind\":${quote(Kind.DISAPPEAR)},\"ctl\":1,\"secs\":$seconds}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a conversation off the record, or back on.
|
||||||
|
*
|
||||||
|
* Sent twice: the proposal, and the peer's [echo] of it. A build that
|
||||||
|
* does not know this kind never echoes, which is how the proposer finds
|
||||||
|
* out that the other side would have kept writing to disk.
|
||||||
|
*/
|
||||||
|
fun offTheRecord(
|
||||||
|
on: Boolean,
|
||||||
|
echo: Boolean = false,
|
||||||
|
): String =
|
||||||
|
"{\"fnc\":1,\"kind\":${quote(Kind.OFF_RECORD)},\"ctl\":1,\"otr\":${if (on) 1 else 0}" +
|
||||||
|
(if (echo) ",\"echo\":1" else "") + "}"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell somebody their disappearing message was screenshotted (§6.27).
|
* Tell somebody their disappearing message was screenshotted (§6.27).
|
||||||
*/
|
*/
|
||||||
@@ -642,6 +662,13 @@ data class Content(
|
|||||||
/** The timer a [Kind.DISAPPEAR] envelope carries, or null if it is not one. */
|
/** 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
|
fun disappearSeconds(content: Content): Int? = if (content.kind == Kind.DISAPPEAR) content.raw?.optInt("secs", 0) else null
|
||||||
|
|
||||||
|
/** Whether an [Kind.OFF_RECORD] envelope turns it on, or null if it is not one. */
|
||||||
|
fun offTheRecord(content: Content): Boolean? =
|
||||||
|
if (content.kind == Kind.OFF_RECORD) (content.raw?.optInt("otr", 0) ?: 0) != 0 else null
|
||||||
|
|
||||||
|
/** True when this is the peer's echo rather than a fresh proposal. */
|
||||||
|
fun offTheRecordIsEcho(content: Content): Boolean = (content.raw?.optInt("echo", 0) ?: 0) != 0
|
||||||
|
|
||||||
/** JSON string escaping, via the library, so control characters survive. */
|
/** JSON string escaping, via the library, so control characters survive. */
|
||||||
private fun quote(value: String): String = JSONObject.quote(value)
|
private fun quote(value: String): String = JSONObject.quote(value)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class Vault(
|
|||||||
private const val STORY_MUTES = "story-mutes"
|
private const val STORY_MUTES = "story-mutes"
|
||||||
private const val STORY_ARCHIVE = "story-archive"
|
private const val STORY_ARCHIVE = "story-archive"
|
||||||
private const val DISAPPEAR = "disappear"
|
private const val DISAPPEAR = "disappear"
|
||||||
|
private const val OFF_RECORD = "offrecord"
|
||||||
|
|
||||||
/** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */
|
/** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */
|
||||||
private const val BRIDGE_OFFERS = "bridge-offers"
|
private const val BRIDGE_OFFERS = "bridge-offers"
|
||||||
@@ -1015,6 +1016,22 @@ class Vault(
|
|||||||
*/
|
*/
|
||||||
fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
|
fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
|
||||||
|
|
||||||
|
/** Whether this conversation is off the record (§6.28). */
|
||||||
|
fun offTheRecord(threadId: String): Boolean = readJson(OFF_RECORD)?.optBoolean(threadId, false) ?: false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember that a conversation is off the record. The setting is kept, and
|
||||||
|
* only the setting: the messages themselves never reach disk at all.
|
||||||
|
*/
|
||||||
|
fun saveOffTheRecord(
|
||||||
|
threadId: String,
|
||||||
|
on: Boolean,
|
||||||
|
) {
|
||||||
|
val all = readJson(OFF_RECORD) ?: JSONObject()
|
||||||
|
if (on) all.put(threadId, true) else all.remove(threadId)
|
||||||
|
writeJson(OFF_RECORD, all)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bridge line a contact offered, and who offered it (§6.19).
|
* A bridge line a contact offered, and who offered it (§6.19).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package io.flamenet.e2e
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning a conversation off the record.
|
||||||
|
*
|
||||||
|
* The envelope is a proposal, not an instruction: it takes effect when the
|
||||||
|
* other side echoes it, because a build that does not know the kind would go on
|
||||||
|
* writing to disk while this one believed it had stopped.
|
||||||
|
*/
|
||||||
|
class OffTheRecordEnvelopeTest {
|
||||||
|
@Test
|
||||||
|
fun `a proposal says so, and is not itself an echo`() {
|
||||||
|
val wire = Content.offTheRecord(true)
|
||||||
|
assertTrue(wire.contains("\"otr\":1"))
|
||||||
|
assertTrue(wire.contains("\"ctl\":1"))
|
||||||
|
assertFalse(wire.contains("echo"))
|
||||||
|
val read = Content.parse(wire)
|
||||||
|
assertEquals(true, Content.offTheRecord(read))
|
||||||
|
assertFalse(Content.offTheRecordIsEcho(read))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an echo is marked, so it is not answered with another`() {
|
||||||
|
val read = Content.parse(Content.offTheRecord(true, echo = true))
|
||||||
|
assertEquals(true, Content.offTheRecord(read))
|
||||||
|
assertTrue(Content.offTheRecordIsEcho(read))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `turning it back on is the same envelope carrying a zero`() {
|
||||||
|
val read = Content.parse(Content.offTheRecord(false))
|
||||||
|
assertEquals(false, Content.offTheRecord(read))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control traffic: consumed, and never drawn as a message. */
|
||||||
|
@Test
|
||||||
|
fun `it is control traffic and never renderable`() {
|
||||||
|
assertTrue(Content.Kind.OFF_RECORD in Content.Kind.CONTROL)
|
||||||
|
assertTrue(Content.Kind.OFF_RECORD in Content.Kind.KNOWN)
|
||||||
|
assertFalse(Content.parse(Content.offTheRecord(true)).isRenderable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `another kind is not mistaken for one`() {
|
||||||
|
assertNull(Content.offTheRecord(Content.parse(Content.disappearing(60))))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user