feat(cordn): carry the messages in a backup, so a restore keeps the conversation

Restoring a backup handed back the groups with an empty history. The archive
carried the MLS state, the cursor and the key packages but not one message,
and the cursor it *did* carry told the sync loop the stream had been read to
the end — so the coordinator was never asked to resend them either. Verified
on the tablet with a true no-op round trip (export, restore that same file
minutes later): both groups came back with names, icons, members and health,
and every message gone, still gone after a cold start, and gone from the
Messages inbox entirely for want of anything to sort on.

That is not what the screen offers. Its first line is that losing the device
without a backup loses "everything already said in it", which only reads one
way.

Archive version 2 adds the delivered messages per group, written as the same
JSON the on-disk message log already stores so the two cannot drift. Version 1
files still open, and still restore empty — there is nothing in them to do
better with. The import writes the messages back before the cursor is trusted
again, because nothing will re-deliver them afterwards.

The alternative was to reset the cursor on restore and re-pull from the
coordinator, which does hold the complete ordered history. It is cheaper, but
it only recovers the epochs the restored state can still open; carrying the
plaintext is whole regardless of how often the group has rotated. Vitor picked
this one. The "What is in the file" copy now says messages are in there, since
that materially changes what the file is worth to whoever finds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-26 12:56:24 -04:00
co-authored by Claude Opus 5
parent 12f4970d5f
commit bf99eb740c
4 changed files with 99 additions and 7 deletions
@@ -794,6 +794,7 @@ class CordnRuntime(
state = state,
cursor = groupStore.loadCursor(gid),
joinedViaRequest = groupStore.loadJoinedViaRequest(gid),
messages = groupStore.loadMessages(gid),
)
}
@@ -847,6 +848,10 @@ class CordnRuntime(
store.saveGroup(group.gid, group.state)
group.cursor?.let { store.saveCursor(group.gid, it) }
if (group.joinedViaRequest) store.saveJoinedViaRequest(group.gid)
// Before the cursor is trusted again. The cursor says the
// stream has been read to here, so nothing will re-deliver
// these -- if they are not written back now they are gone.
group.messages.forEach { store.appendMessage(group.gid, it) }
}
archive.keyPackages.forEach { keyPackage ->
+1 -1
View File
@@ -350,7 +350,7 @@
<string name="cordn_backup_explainer">Your nsec cannot bring cordn groups back. A cordn group lives as encryption state on this device and an ordered stream on a coordinator that cannot read it — lose the device without a backup and the group is gone, including everything already said in it.</string>
<string name="cordn_backup_passphrase">Passphrase</string>
<string name="cordn_backup_contents_title">What is in the file</string>
<string name="cordn_backup_contents_body">Your group encryption keys, your reading positions, and your key packages. Anyone who opens it can read those groups. The passphrase is the only thing protecting it once it leaves Amethyst.</string>
<string name="cordn_backup_contents_body">Your group encryption keys, your key packages, and every message in those groups. Anyone who opens it can read the conversations. The passphrase is the only thing protecting it once it leaves Amethyst.</string>
<string name="cordn_backup_export">Export</string>
<string name="cordn_backup_exported">Backup saved.</string>
<string name="cordn_backup_restore_title">Restore</string>
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.cordn
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec
import com.vitorpamplona.quartz.cordn.sync.GroupCursor
import com.vitorpamplona.quartz.mls.codec.TlsReader
import com.vitorpamplona.quartz.mls.codec.TlsWriter
@@ -83,7 +85,25 @@ import com.vitorpamplona.quartz.utils.RandomInstance
* that is expected rather than a gap.
*/
object CordnBackup {
const val VERSION = 1
/**
* Version 2 adds the delivered messages to each group.
*
* Version 1 carried the MLS state, the cursor and the key packages but not
* a single message, and restoring it handed back the groups with an empty
* history: the messages were not in the file, and the cursor that *was*
* in the file told the sync loop it had already read to the end, so the
* coordinator was never asked to resend them. A backup that loses the
* conversation is not the thing the screen promises.
*
* The history could instead be re-pulled by resetting the cursor, since
* the coordinator holds it, but only for epochs the restored state can
* still open. Carrying the plaintext is what makes the restore whole
* regardless of how many times the group has rotated since.
*/
const val VERSION = 2
/** The last version that carried no messages; still readable. */
private const val VERSION_WITHOUT_MESSAGES = 1
/**
* scrypt cost, as `log2(N)`.
@@ -115,6 +135,14 @@ object CordnBackup {
val state: ByteArray,
val cursor: GroupCursor?,
val joinedViaRequest: Boolean,
/**
* The group's delivered messages, in cursor order.
*
* Empty when the archive was written by version 1, which carried
* none — a v1 restore still yields an empty conversation, and
* there is nothing in the file to do better with.
*/
val messages: List<CordnDeliveredMessage> = emptyList(),
) {
override fun equals(other: Any?): Boolean =
this === other ||
@@ -125,7 +153,8 @@ object CordnBackup {
state.contentEquals(other.state) &&
cursor?.fetchCursor == other.cursor?.fetchCursor &&
cursor?.lastCursor == other.cursor?.lastCursor &&
joinedViaRequest == other.joinedViaRequest
joinedViaRequest == other.joinedViaRequest &&
messages == other.messages
)
override fun hashCode(): Int {
@@ -134,6 +163,7 @@ object CordnBackup {
result = 31 * result + state.contentHashCode()
result = 31 * result + (cursor?.fetchCursor?.hashCode() ?: 0)
result = 31 * result + joinedViaRequest.hashCode()
result = 31 * result + messages.hashCode()
return result
}
}
@@ -188,7 +218,7 @@ object CordnBackup {
val reader = TlsReader(sealed.copyOfRange(MAGIC.size, HEADER_LENGTH))
val version = reader.readUint16()
require(version == VERSION) { "unknown cordn backup version $version" }
require(version == VERSION || version == VERSION_WITHOUT_MESSAGES) { "unknown cordn backup version $version" }
val logN = reader.readUint8()
val salt = reader.readBytes(SALT_LENGTH)
@@ -198,7 +228,7 @@ object CordnBackup {
val header = sealed.copyOfRange(0, HEADER_LENGTH)
val plaintext = ChaCha20Poly1305.decrypt(sealed.copyOfRange(HEADER_LENGTH, sealed.size), header, nonce, key)
return decode(plaintext)
return decode(plaintext, version)
}
private fun header(
@@ -242,6 +272,13 @@ object CordnBackup {
writer.putUint64(cursor.lastCursor)
}
writer.putUint8(if (it.joinedViaRequest) 1 else 0)
// uint32: a long-running group's log is not bounded by 65535.
// Each entry is the same JSON the on-disk message log stores, so
// the archive and the store cannot drift out of step.
writer.putUint32(it.messages.size.toLong())
it.messages.forEach { message ->
writer.putOpaque4(CordnDeliveredMessageCodec.encode(message).encodeToByteArray())
}
}
writer.putUint16(archive.keyPackages.size)
@@ -253,7 +290,10 @@ object CordnBackup {
return writer.toByteArray()
}
private fun decode(bytes: ByteArray): Archive {
private fun decode(
bytes: ByteArray,
version: Int,
): Archive {
val reader = TlsReader(bytes)
val accountPubKey = reader.readOpaque2().decodeToString()
val coordinators = CoordinatorListCodec.decode(reader.readOpaque4())
@@ -264,7 +304,19 @@ object CordnBackup {
val gid = reader.readOpaque2().decodeToString()
val state = reader.readOpaque4()
val cursor = if (reader.readUint8() == 1) GroupCursor(reader.readUint64(), reader.readUint64()) else null
Archive.Group(coordinator, gid, state, cursor, reader.readUint8() == 1)
val joinedViaRequest = reader.readUint8() == 1
val messages =
if (version == VERSION_WITHOUT_MESSAGES) {
emptyList()
} else {
// A message that will not parse is dropped rather than
// failing the whole restore: losing one row beats
// refusing to bring the account back at all.
(0 until reader.readUint32()).mapNotNull {
CordnDeliveredMessageCodec.decodeOrNull(reader.readOpaque4().decodeToString())
}
}
Archive.Group(coordinator, gid, state, cursor, joinedViaRequest, messages)
}
val keyPackages =
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.cordn
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage
import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope
import com.vitorpamplona.quartz.cordn.sync.GroupCursor
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlin.test.Test
@@ -55,6 +57,7 @@ class CordnBackupTest {
state = byteArrayOf(1, 2, 3, 4),
cursor = GroupCursor(fetchCursor = 7, lastCursor = 9),
joinedViaRequest = true,
messages = listOf(delivered("hello", 7), delivered("again", 8)),
),
CordnBackup.Archive.Group(
coordinatorPubKey = coordinator,
@@ -162,6 +165,38 @@ class CordnBackupTest {
assertFailsWith<IllegalArgumentException> { CordnBackup.seal(archive, "pw", logN = 40) }
}
/**
* The id has to hash the contents: `CordnEnvelope.fromJsonObject` checks
* it, so an envelope with a made-up id decodes to null and the archive
* appears to have lost the message.
*/
private fun delivered(
content: String,
cursor: Long,
): CordnDeliveredMessage {
val unsigned =
CordnEnvelope(
id = "",
pubKey = account,
createdAt = 1_700_000_000L + cursor,
kind = 9,
tags = arrayOf(arrayOf("h", "room-1")),
content = content,
)
return CordnDeliveredMessage(unsigned.copy(id = unsigned.computedId()), cursor)
}
@Test
fun `messages survive the round trip`() {
val opened = CordnBackup.open(CordnBackup.seal(archive, "pw", cheap), "pw")
val room1 = opened.groups.first { it.gid == "room-1" }
assertEquals(listOf("hello", "again"), room1.messages.map { it.envelope.content })
assertEquals(listOf(7L, 8L), room1.messages.map { it.cursor })
// The group carrying none still round-trips as none.
assertEquals(emptyList(), opened.groups.first { it.gid == "room-2" }.messages)
}
private companion object {
const val MAGIC_LENGTH = 8