Keep a half-written message across a relaunch
Sealed in the vault, not in SharedPreferences — and that is the whole point. A draft is plaintext the person has not decided to send, very often the most sensitive text in the app because it is the thing they are still weighing. SharedPreferences writes readable XML that survives into backups; the vault seals it under the conversation's own key with the record name bound into the AAD, and forgetEverything takes it on sign-out. There is a test that asserts the bytes on disk do not contain the text, because the wrong implementation would pass every other test here. Keyed by the same threadFor* ids the transcript uses, so a group called "7", the contact who is user 7, and the address "7" are three different drafts. Every write to the composer state goes through one setDraft: send clears it by assigning directly in three places, and any one of those that forgot to clear the stored copy would bring the message back next time as a draft of something already sent. Closes #47 on iOS side; Android half of the same feature Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1fc1a561b3
commit
f95cb1e6aa
@@ -88,6 +88,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
private const val REPLY_REMINDER = "reply-reminder"
|
||||
private const val ROSTER = "roster"
|
||||
private const val GROUP_NAMES = "group-names"
|
||||
private const val DRAFTS = "drafts"
|
||||
private const val GROUP_SECRETS = "group-secrets"
|
||||
private const val DEVICE_OWNER_PREFIX = "devowner/"
|
||||
private const val KT_ISSUER_PIN = "kt-issuer-pin"
|
||||
@@ -551,6 +552,40 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
||||
writeJson(GROUP_NAMES, json)
|
||||
}
|
||||
|
||||
/**
|
||||
* A half-written message, kept across a relaunch.
|
||||
*
|
||||
* 🔴 **In the vault rather than in SharedPreferences, and that is the whole
|
||||
* point.** A draft is plaintext the person has not decided to send — very
|
||||
* often the most sensitive text in the app, because it is the thing they
|
||||
* are still weighing. `SharedPreferences` writes it as readable XML that
|
||||
* survives into backups; here it is sealed under the same key as the
|
||||
* conversation, with the record's own name bound into the AAD, and
|
||||
* [forgetEverything] takes it on sign-out.
|
||||
*
|
||||
* ⚠️ Keyed by the same `threadFor*` ids the transcript uses, so a group, a
|
||||
* local contact and somebody on another relay cannot collide — see the note
|
||||
* on [threadForAddress] for why that third case is not theoretical.
|
||||
*/
|
||||
fun draft(threadId: String): String? =
|
||||
readJson(DRAFTS)?.optString(threadId)?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
|
||||
* looks empty and reports itself as having content, which on this screen
|
||||
* means an enabled send button that sends nothing.
|
||||
*
|
||||
* ⚠️ Stored AS TYPED though, not trimmed: somebody who left a trailing
|
||||
* space was mid-word, and handing back a subtly different string than the
|
||||
* one they left is the kind of small wrongness that makes an app feel
|
||||
* untrustworthy.
|
||||
*/
|
||||
fun saveDraft(threadId: String, text: String) {
|
||||
val json = readJson(DRAFTS) ?: JSONObject()
|
||||
if (text.isBlank()) json.remove(threadId) else json.put(threadId, text)
|
||||
writeJson(DRAFTS, json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a contact's picture.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Half-written messages, kept across a relaunch.
|
||||
*
|
||||
* 🔴 **The reason this is not a two-line SharedPreferences change** is where a
|
||||
* draft has to live. It is plaintext the person has not decided to send — very
|
||||
* often the most sensitive text in the app, because it is the thing they are
|
||||
* still weighing. `SharedPreferences` writes readable XML that survives into
|
||||
* backups. In the vault it is sealed under the same key as the conversation,
|
||||
* with the record's own name bound into the AAD, and `forgetEverything` takes
|
||||
* it on sign-out.
|
||||
*/
|
||||
class DraftsThatSurviveTest {
|
||||
|
||||
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()
|
||||
|
||||
// ---------------------------------------------------------------- surviving
|
||||
|
||||
@Test
|
||||
fun `a draft survives being stored and read back`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val thread = Vault.threadForUser(7)
|
||||
|
||||
Vault(storage, secret).saveDraft(thread, "meet at 6")
|
||||
assertEquals("meet at 6", Vault(storage, secret).draft(thread))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **On disk it must be ciphertext.** This is the assertion the whole
|
||||
* ticket is about: the same feature built on SharedPreferences would put
|
||||
* the text in readable XML, and no other test here would notice.
|
||||
*/
|
||||
@Test
|
||||
fun `a draft is not readable on disk`() {
|
||||
val storage = MapStorage()
|
||||
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "the thing I have not sent")
|
||||
|
||||
val onDisk = storage.records.values.joinToString("") { bytes ->
|
||||
bytes.joinToString("") { byte -> (byte.toInt() and 0xFF).toChar().toString() }
|
||||
}
|
||||
assertFalse(onDisk.contains("the thing I have not sent"),
|
||||
"the draft was written in the clear")
|
||||
}
|
||||
|
||||
/** ⚠️ Multi-line text and awkward characters survive the round trip. */
|
||||
@Test
|
||||
fun `awkward text survives`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val awkward = "line one\n\"quoted\"\ttabbed \\ backslash 🙂"
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), awkward)
|
||||
assertEquals(awkward, Vault(storage, secret).draft(Vault.threadForUser(1)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- forgetting
|
||||
|
||||
/**
|
||||
* 🔴 Clearing the composer has to clear what was stored. A draft that
|
||||
* outlives the message it became comes back as a draft of something already
|
||||
* sent, which is worse than not having drafts at all.
|
||||
*/
|
||||
@Test
|
||||
fun `clearing the composer forgets the draft`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val thread = Vault.threadForUser(7)
|
||||
|
||||
Vault(storage, secret).saveDraft(thread, "meet at 6")
|
||||
Vault(storage, secret).saveDraft(thread, "")
|
||||
assertNull(Vault(storage, secret).draft(thread))
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Blank is not a draft. Storing " " would restore a composer that
|
||||
* looks empty and reports itself as having content, which on this screen
|
||||
* means an enabled send button that sends nothing.
|
||||
*/
|
||||
@Test
|
||||
fun `whitespace is not a draft`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
val thread = Vault.threadForUser(7)
|
||||
|
||||
for (blank in listOf("", " ", " ", "\n", "\t\n ")) {
|
||||
Vault(storage, secret).saveDraft(thread, "something")
|
||||
Vault(storage, secret).saveDraft(thread, blank)
|
||||
assertNull(Vault(storage, secret).draft(thread),
|
||||
"kept \"$blank\", which would enable send on an empty composer")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ But stored as typed, not trimmed. Somebody who left a trailing space
|
||||
* was mid-word, and handing back a subtly different string than the one
|
||||
* they left is the kind of small wrongness that makes an app feel
|
||||
* untrustworthy.
|
||||
*/
|
||||
@Test
|
||||
fun `text is kept exactly as it was typed`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "meet at ")
|
||||
assertEquals("meet at ", Vault(storage, secret).draft(Vault.threadForUser(1)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- which thread
|
||||
|
||||
/**
|
||||
* 🔴 Three kinds of conversation share one record, so the ids must not
|
||||
* collide. A group called "7", the person who is user 7, and an address
|
||||
* "7" are three different threads — and the transcript already names them
|
||||
* apart for exactly this reason.
|
||||
*/
|
||||
@Test
|
||||
fun `three kinds of conversation cannot share a draft`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "to the contact")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForGroup("7"), "to the group")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForAddress("7"), "to the address")
|
||||
|
||||
val vault = Vault(storage, secret)
|
||||
assertEquals("to the contact", vault.draft(Vault.threadForUser(7)))
|
||||
assertEquals("to the group", vault.draft(Vault.threadForGroup("7")))
|
||||
assertEquals("to the address", vault.draft(Vault.threadForAddress("7")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a conversation with no draft has none`() {
|
||||
assertNull(Vault(MapStorage(), key()).draft(Vault.threadForUser(99)))
|
||||
}
|
||||
|
||||
/** Writing one draft must not disturb another. */
|
||||
@Test
|
||||
fun `drafts do not overwrite each other`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "first")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(2), "second")
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(1), "")
|
||||
|
||||
assertNull(Vault(storage, secret).draft(Vault.threadForUser(1)))
|
||||
assertEquals("second", Vault(storage, secret).draft(Vault.threadForUser(2)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sign-out
|
||||
|
||||
/**
|
||||
* 🔴 Signing out must take drafts with it. Leaving the one piece of
|
||||
* plaintext the person never chose to send behind, after the credential
|
||||
* that justified it is gone, is exactly the residue the vault exists to
|
||||
* avoid.
|
||||
*/
|
||||
@Test
|
||||
fun `signing out forgets drafts`() {
|
||||
val storage = MapStorage()
|
||||
val secret = key()
|
||||
|
||||
Vault(storage, secret).saveDraft(Vault.threadForUser(7), "meet at 6")
|
||||
Vault(storage, secret).forgetEverything()
|
||||
|
||||
assertTrue(storage.records.isEmpty(), "something survived sign-out")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A vault opened with a different key cannot read the draft — the same
|
||||
* guarantee as everything else in here, asserted because a draft is the
|
||||
* newest thing in it and the easiest to get wrong.
|
||||
*/
|
||||
@Test
|
||||
fun `another key cannot read a draft`() {
|
||||
val storage = MapStorage()
|
||||
Vault(storage, key()).saveDraft(Vault.threadForUser(7), "meet at 6")
|
||||
|
||||
val other = Vault(storage, key())
|
||||
val read = runCatching { other.draft(Vault.threadForUser(7)) }
|
||||
assertNotEquals("meet at 6", read.getOrNull())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user