Refuse a home record older than one already seen (§20.4, Android half) #70

Merged
paulhitt merged 2 commits from feat/local-salt-and-multihoming into main 2026-09-04 20:49:33 +00:00
9 changed files with 828 additions and 24 deletions
+35 -5
View File
@@ -73,8 +73,8 @@ From `Sources/Auth/*` and `Sources/Views/Sign*`.
| Change password | ✅ signs the device out, because that is what it does |
| Delete account | ✅ confirms in place, erases the phone too |
| Relay picker / `SiteConfig` equivalent | ✅ `SiteConfig.kt`, a `relay` field, and an `identity-mode` label |
| Deterministic local-relay KDF salt | ⚪ |
| Multi-homing (publish + read home records) | ⚪ |
| Deterministic local-relay KDF salt | ✅ `AccountKdf.localAccountSalt`, pinned to the published vector, and wired into sign-in for a LOCAL relay |
| Multi-homing (publish + read home records) | ✅ publish, read, verify and the serial floor — see below |
⚠️ Sign-up is where the three deployment modes become visible to a user, and
each derives its salt differently. Getting this wrong does not error — it
@@ -94,15 +94,15 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
| History at rest | ✅ encrypted, keyed by person not device |
| Session restore across restarts | ✅ credential and device both survive |
| Reactions | ✅ transcript carries a `mid`; long-press gives a five-emoji row, one emoji per person |
| Edit a sent message (§6.15) | 🟡 inbound edits now APPLY to the message they name; offering the control is UI work |
| Edit a sent message (§6.15) | ✅ inbound edits apply to the message they name; **Edit** in the long-press menu, own messages only, bubble marked `edited` |
| Drafts that survive a relaunch | ✅ sealed in the vault, not SharedPreferences |
| Forwarding (§6.16) | 🟡 `fwd` now reaches the transcript so second-hand words are not drawn as the sender's own; originating needs the UI |
| Forwarding (§6.16) | ✅ `fwd` reaches the transcript and the bubble; **Forward** offers people and groups, and carries no trail back to the source |
| Group message ids and quotes (§6.9) | ✅ transcript stores both; sent and queued copies share one id |
| Mentions (§6.18) | ✅ codec, an `@` picker in the group composer, and ids recorded at pick time rather than re-derived from the text |
| Per-message timers (§6.17) | ✅ combining rule and per-message pruning; composer control in progress |
| Search inside messages | ✅ scanned, never indexed |
| Search inside messages | ✅ scanned, never indexed — no plaintext index at rest |
| Pinned message (§6.19) | 🟡 inbound pins now stored per thread, sealed in the vault; the banner and the control are UI work |
| Pinned message (§6.19) | ✅ inbound pins stored per thread sealed in the vault; **Pin/Unpin** in the menu and a banner above the thread |
| Photos | ✅ send via the system picker, render inline |
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
| Stories, and seen-state | ✅ sent per-contact, expired bytes deleted |
@@ -222,6 +222,36 @@ somebody is grinding attempts. The steps are clamped before the shift, and
---
## Multi-homing (§20.4)
Both rows above were ⚪ and both were **stale**: `AccountKdf.localAccountSalt` is
pinned to the published vector and wired into sign-in for a local relay, and
`MailboxHoming` / `AccountIdentity.publishHomes` have shipped the publish and
read halves for a while. The document was describing a gap that had closed.
🔴 **What was genuinely missing was on the READING side, on BOTH clients.** An
older home record is *genuinely signed* — the account holder published it,
before they added a second relay — so a relay that wants to be somebody's only
mailbox does not have to forge anything: it serves the record it saw first, that
record verifies perfectly, and the sender delivers to one relay believing it is
delivering to all of them.
The relay refuses a lower serial on **write**. Nothing refused one on **read**:
`verifiedRelays` here and `verifyHomes` on iOS both checked the signature and
stopped. Fixed on both (Android this MR, iOS `ios!116`) by remembering the
highest serial seen per address and refusing anything below it.
⚠️ The floor rises only for a record that verified — otherwise a hostile relay
could raise it with a forged `Int.MAX_VALUE` and lock out every later genuine
record, turning the rollback defence into a denial of service against the
account holder.
⚠️ Neither client claims an attack when it refuses. A relay that has genuinely
fallen behind serves an old record for honest reasons, and nothing in a client
can tell that from the other case.
---
## Inbound control envelopes
🔴 **The app never asked the engine whether an envelope belonged in a thread.**
@@ -1622,6 +1622,8 @@ fun ColumnScope.ChatScreen(
avatars: Map<Long, io.flamenet.e2e.Attachment.Ref> = emptyMap(),
/** Ours, so the mention list never offers you yourself. */
myUserId: Long = 0L,
/** Somewhere to forward to (§6.16); groups as well as people. */
groups: List<io.flamenet.client.RelayClient.Group> = emptyList(),
) {
// ⚠️ Keyed on the thread, so switching conversations re-reads rather than
// carrying one person's half-written message into somebody else's composer.
@@ -1741,6 +1743,62 @@ fun ColumnScope.ChatScreen(
// because a timer nobody can see is a timer nobody trusts.
if (chat.peerId != null) ConversationStatusLine(model, chat)
// §6.15/§6.19 — which message a sheet is currently about. Hoisted above the
// list because a row's own `remember` dies when it scrolls out, and an edit
// box that vanished mid-typing because the message scrolled away would be
// the worst possible way to lose a correction.
var editing by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
var forwarding by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
// 🔴 The pinned banner, above the messages: a pin nobody can see is a pin
// nobody uses. Resolved from the id against the lines on screen, so a
// pinned message that was since deleted shows nothing rather than a stale
// copy of words that are gone.
chat.pinnedMessageId?.let { pinnedId ->
chat.lines.firstOrNull { it.id == pinnedId }?.let { pinned ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Radius.small))
.background(Palette.Accent.copy(alpha = 0.10f))
.padding(horizontal = 12.dp, vertical = 8.dp)
.testTag("pinned-banner"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Space.xs),
) {
Text("Pinned", fontSize = 11.sp, color = Palette.SecondaryText)
Text(
pinned.text.take(80),
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
editing?.let { line ->
EditMessageSheet(
line = line,
onDismiss = { editing = null },
onSave = { text -> model.editMessage(line, chat, text); editing = null },
)
}
forwarding?.let { line ->
ForwardSheet(
line = line,
buddies = buddies,
groups = groups,
onDismiss = { forwarding = null },
onPick = { peerId, group ->
model.forwardMessage(line.text, peerId, group)
forwarding = null
},
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
@@ -1822,6 +1880,32 @@ fun ColumnScope.ChatScreen(
onClick = { clipboard.setText(AnnotatedString(line.text)); menuOpen = false },
)
}
// ⚠️ Every control below needs an id to point at, so all
// of them are absent — not disabled — on a message written
// before ids existed. A greyed-out row invites a tap and
// teaches nothing.
if (line.id != null && line.mine && line.text.isNotBlank()) {
DropdownMenuItem(
text = { Text("Edit") },
onClick = { editing = line; menuOpen = false },
modifier = Modifier.testTag("edit-message"),
)
}
if (line.id != null) {
val isPinned = chat.pinnedMessageId == line.id
DropdownMenuItem(
text = { Text(if (isPinned) "Unpin" else "Pin") },
onClick = { model.pinMessage(line, chat, !isPinned); menuOpen = false },
modifier = Modifier.testTag("pin-message"),
)
}
if (line.text.isNotBlank()) {
DropdownMenuItem(
text = { Text("Forward") },
onClick = { forwarding = line; menuOpen = false },
modifier = Modifier.testTag("forward-message"),
)
}
DropdownMenuItem(
text = { Text(if (line.mine) "Unsend" else "Delete for me") },
onClick = { model.deleteMessage(line); menuOpen = false },
@@ -2165,6 +2249,23 @@ private fun Bubble(
modifier = Modifier.padding(horizontal = Kit.bubblePadding, vertical = 2.dp),
) {
Text(time, fontSize = 10.sp, color = Palette.SecondaryText)
// 🔴 **An edit says so (§6.15).** Silently replacing the words is
// worse than the typo: the reader has no way to tell a correction
// from their own misremembering, and no way to know the message
// they replied to is not the message that is there now.
if (line.editedAt != null) {
Text("edited", fontSize = 10.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("edited-marker"))
}
// ⚠️ **THAT it was forwarded, never from whom (§6.16).** Second-hand
// words drawn as the sender's own is the thing this prevents;
// naming the original author would be a different feature and a
// worse one, because it republishes somebody who was never in this
// conversation.
if (line.forwarded) {
Text("Forwarded", fontSize = 10.sp, color = Palette.SecondaryText,
modifier = Modifier.testTag("forwarded-marker"))
}
// 🔴 **A message nobody received must not look like one that was
// received.** That sameness is the whole of what makes a ghost
// message possible; every specific cause behind one is downstream
@@ -3733,3 +3834,119 @@ internal fun memberLabel(
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
): String = buddies.firstOrNull { it.userId == id }
?.let { it.displayName.ifBlank { it.handle } } ?: "user $id"
/**
* Change the wording of a message already sent (§6.15).
*
* ⚠️ **Opens with the current text already in it.** An edit box that starts
* empty is a retype, and somebody correcting one word in a long message would
* rather not. The cursor sits at the end for the same reason.
*
* ⚠️ Saving nothing is refused rather than treated as a delete. Emptying the
* box is the shape of a slip, and "Unsend" is right there in the same menu for
* anybody who meant it.
*/
@Composable
private fun EditMessageSheet(
line: Session.Line,
onDismiss: () -> Unit,
onSave: (String) -> Unit,
) {
var text by remember(line.id) { mutableStateOf(line.text) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit message") },
text = {
Column {
androidx.compose.material3.OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth().testTag("edit-field"),
singleLine = false,
)
Spacer(Modifier.height(Space.xs))
// 🔴 Says what the other side will see. An edit is not a quiet
// repair: the recipient's bubble changes and is marked, and
// somebody about to correct a typo should know that before they
// do it rather than discover it afterwards.
Text(
"Everyone in this conversation sees the new wording, and their "
+ "copy is marked as edited.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
}
},
confirmButton = {
androidx.compose.material3.TextButton(
onClick = { onSave(text) },
enabled = text.isNotBlank() && text != line.text,
modifier = Modifier.testTag("edit-save"),
) { Text("Save") }
},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* Pass a message's words on to another conversation (§6.16).
*
* 🔴 **The list is people and groups, and the message travels without its
* author.** What is sent is the text with `fwd` set — no original thread, no
* name, no quote. A forward that named its source would republish a private
* message's author to somebody who was never in that conversation, which is
* the one thing §6.16 exists to prevent.
*/
@Composable
private fun ForwardSheet(
line: Session.Line,
buddies: List<io.flamenet.client.MessengerApi.Buddy>,
groups: List<io.flamenet.client.RelayClient.Group>,
onDismiss: () -> Unit,
onPick: (Long?, io.flamenet.client.RelayClient.Group?) -> Unit,
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Forward to") },
text = {
Column(modifier = Modifier.testTag("forward-list")) {
Text(
// ⚠️ States the cost plainly. "Forwarded" is all the
// recipient is told, and somebody passing on a message
// should know their own name is what travels with it.
"This sends the words on their own. Whoever receives it sees "
+ "that it was forwarded, and sees it from you.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
Spacer(Modifier.height(Space.sm))
groups.forEach { group ->
Text(
group.name.ifBlank { "Group" },
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(null, group) }
.padding(vertical = 10.dp)
.testTag("forward-group-${group.id}"),
)
}
buddies.forEach { buddy ->
Text(
buddy.displayName.ifBlank { buddy.handle },
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(buddy.userId, null) }
.padding(vertical = 10.dp)
.testTag("forward-to-${buddy.userId}"),
)
}
}
},
confirmButton = {},
dismissButton = {
androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -233,7 +233,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
val chat = current.openChat
if (chat != null) {
ChatScreen(model, chat, current.error, current.buddies, current.avatars,
myUserId = current.userId)
myUserId = current.userId, groups = current.groups)
} else {
// 🔴 **The content area is weighted, and the tab bar is
// not.** These used to be plain siblings, so the tab bar
@@ -247,6 +247,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* number that points at somebody else.
*/
val address: String? = null,
/**
* The message pinned in this conversation (§6.19), or null.
*
* ⚠️ An id, resolved against [lines] at draw time. Holding a copy
* of the message here would go stale the moment it was edited and
* would outlive it being deleted — a banner quoting words that are
* no longer in the thread.
*/
val pinnedMessageId: String? = null,
/**
* What we believe about the other side's identity keys (§19).
*
@@ -712,6 +721,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
disappearSeconds = current.conversations.disappearSeconds(peerId),
replyReminderAt = current.replyReminder(peerId),
scheduled = current.scheduledFor(peerId),
pinnedMessageId = current.pinnedMessage(thread),
),
// Opening the thread is what marks it read. Nothing is sent to
// the relay for this — read state is local, because a read
@@ -750,7 +760,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update {
it.copy(
openChat = Phase.Chat(
thread, card.address, current.history(thread), address = card.address),
thread, card.address, current.history(thread), address = card.address,
pinnedMessageId = current.pinnedMessage(thread)),
unread = it.unread - thread,
error = null,
)
@@ -783,7 +794,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// survive, and showing it unchecked would make this screen a place
// that quietly confirms the attack.
runCatching {
current.relay.homes(fnid)?.let(io.flamenet.client.AccountIdentity::verifiedRelays)
val record = current.relay.homes(fnid) ?: return@runCatching null
// 🔴 **And not older than one already seen.** An old record is
// genuinely signed — published before a second home was added —
// so a relay wanting to be somebody's only mailbox does not
// have to forge anything; it just serves the record it saw
// first. The signature check alone accepts that, which is what
// the serial exists to stop.
val floor = current.vault.homeSerial(fnid)
val relays = io.flamenet.client.AccountIdentity
.verifiedRelays(record, floor = floor)
// ⚠️ Advanced only after the record was ACCEPTED. Recording the
// serial of a record that failed verification would let a
// hostile relay raise the floor with a forgery and lock out
// every later genuine record.
io.flamenet.client.AccountIdentity.verifiedSerial(record)
?.let { if (relays != null) current.vault.saveHomeSerial(fnid, it) }
relays
}.getOrNull()
}
val readable = withContext(Dispatchers.IO) {
@@ -857,7 +884,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val thread = Vault.threadForGroup(group.id)
update {
it.copy(
openChat = Phase.Chat(thread, group.name, current.history(thread), group = group),
openChat = Phase.Chat(thread, group.name, current.history(thread), group = group,
pinnedMessageId = current.pinnedMessage(thread)),
unread = it.unread - thread,
)
}
@@ -2035,6 +2063,74 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* the tap registers immediately and a failed send puts back what was
* there. All this has to do is re-read the thread afterwards.
*/
/**
* Change the wording of a message already sent (§6.15).
*
* ⚠️ Refreshes the open chat from the transcript afterwards, like
* `reactToMessage` — the edit is applied to storage first and the screen
* reads from storage, so without this the correction lands and is invisible
* until the next poll.
*/
fun editMessage(line: Session.Line, chat: Phase.Chat, newText: String) =
viewModelScope.launch {
val current = session ?: return@launch
val mid = line.id ?: return@launch
withContext(Dispatchers.IO) {
runCatching {
current.editMessage(chat.threadId, mid, newText,
peerUserId = chat.peerId, group = chat.group)
}
}
update {
it.copy(openChat = it.openChat?.copy(
lines = current.history(chat.threadId),
pinnedMessageId = current.pinnedMessage(chat.threadId)))
}
}
/** Pin a message, or take the pin down by pinning the empty id (§6.19). */
fun pinMessage(line: Session.Line, chat: Phase.Chat, pin: Boolean) =
viewModelScope.launch {
val current = session ?: return@launch
val mid = line.id ?: return@launch
withContext(Dispatchers.IO) {
runCatching {
current.pinMessage(chat.threadId, if (pin) mid else "",
peerUserId = chat.peerId, group = chat.group)
}
}
update {
it.copy(openChat = it.openChat?.copy(
lines = current.history(chat.threadId),
pinnedMessageId = current.pinnedMessage(chat.threadId)))
}
}
/**
* Pass a message's words on to another conversation (§6.16).
*
* 🔴 Takes the TEXT, not the line. A forward carries the words and nothing
* about where they came from, and a signature that accepted the whole line
* would make it easy to start carrying the author "just for context" —
* which is the one thing §6.16 exists to prevent.
*/
fun forwardMessage(text: String, toPeerUserId: Long?, toGroup: RelayClient.Group?) =
viewModelScope.launch {
val current = session ?: return@launch
withContext(Dispatchers.IO) {
runCatching { current.forwardMessage(text, toPeerUserId, toGroup) }
}
// ⚠️ The forward lands in ANOTHER thread, so the chat on screen is
// not the one that changed. Only the open chat is re-read, which is
// correct: reloading the destination would be reloading a screen
// nobody is looking at.
update {
it.copy(openChat = it.openChat?.let { chat ->
chat.copy(lines = current.history(chat.threadId))
})
}
}
fun reactToMessage(emoji: String, line: Session.Line, chat: Phase.Chat) =
viewModelScope.launch {
val current = session ?: return@launch
@@ -738,18 +738,7 @@ class Session(
if (!applyReaction(threadId, messageId, userId, emoji)) return false
val reference = Content.Reference(messageId, author, "")
val body = Content.reaction(emoji, reference, groupId = group?.id)
val sent = runCatching {
when {
// ⚠️ Pairwise, like every group message: the relay does no
// fan-out, so a group reaction is one send per member.
group != null -> group.memberIds
.filter { it != userId }
.map { conversations.send(it, body).reachedSomeone }
.any { it }
peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone
else -> false
}
}.getOrDefault(false)
val sent = sendToThread(body, peerUserId, group)
if (!sent) {
applyReaction(threadId, messageId, userId, previous)
return false
@@ -757,6 +746,131 @@ class Session(
return true
}
/**
* Fan one control envelope out to a thread, exactly as `react` does.
*
* ⚠️ Pairwise for a group, because the relay does no fan-out — one send per
* member. Factored out of `react` rather than copied into three new
* callers: three copies of "which sends does this thread need" is three
* places for a group to be handled as a 1:1.
*/
private fun sendToThread(
body: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean = runCatching {
when {
group != null -> group.memberIds
.filter { it != userId }
.map { conversations.send(it, body).reachedSomeone }
.any { it }
peerUserId != null -> conversations.send(peerUserId, body).reachedSomeone
else -> false
}
}.getOrDefault(false)
/**
* Change the wording of a message already sent (§6.15).
*
* 🔴 **Only your own, and the check is here rather than only in the UI.** A
* menu that hides the control is a convenience; this is the rule. The
* receiving side enforces the same thing (`applyEdit` refuses an edit from
* anybody but the author), so neither end is trusting the other.
*
* ⚠️ **Applied locally first, then sent, and rolled back if the send
* fails** — the same shape as `react`. Waiting on a round trip to see your
* own correction appear makes it feel broken on a slow connection, and
* leaving a correction that exists only on this phone is worse: you would
* believe you had fixed it.
*/
fun editMessage(
threadId: String,
messageId: String,
newText: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean {
val line = history(threadId).firstOrNull { it.id == messageId } ?: return false
if (!line.mine) return false
if (newText.isBlank()) return false
val previous = line.text
if (!applyEdit(threadId, messageId, userId, newText)) return false
val body = Content.edit(newText, of = messageId, groupId = group?.id)
if (!sendToThread(body, peerUserId, group)) {
// Put the old wording back. `applyEdit` stamps `edited`, so this
// leaves the flag behind — deliberately: the message DID change on
// this device and briefly showed the new words, and pretending
// otherwise would be a second lie on top of the failed send.
applyEdit(threadId, messageId, userId, previous)
return false
}
return true
}
/**
* Pin a message in a thread, or take the pin down with an empty id (§6.19).
*
* ⚠️ **Anyone in the conversation may pin.** A pin is a shared banner, not
* a permission — restricting it to the author would mean the useful message
* in a group is exactly the one nobody but its writer can pin.
*/
fun pinMessage(
threadId: String,
messageId: String,
peerUserId: Long?,
group: RelayClient.Group?,
): Boolean {
val previous = pinnedMessage(threadId)
applyPin(threadId, messageId)
val body = Content.pin(messageId, groupId = group?.id)
if (!sendToThread(body, peerUserId, group)) {
applyPin(threadId, previous.orEmpty())
return false
}
return true
}
/**
* Pass somebody's words on to another conversation (§6.16).
*
* 🔴 **The forward carries the words and NOT where they came from.** No
* author, no original thread, no quote — that is the whole point of §6.16,
* and it is why this sends a plain text envelope with `fwd` set rather than
* a reply that references the original. A forward that named its source
* would republish a private message's author to somebody who was never in
* that conversation.
*
* ⚠️ An attachment is deliberately not carried yet. Re-sending a blob means
* re-uploading it under a new token, and a forward that silently shared the
* ORIGINAL token would hand the new recipient a capability to a file in a
* conversation they are not part of.
*/
fun forwardMessage(
text: String,
toPeerUserId: Long?,
toGroup: RelayClient.Group?,
): Boolean {
if (text.isBlank()) return false
val mid = java.util.UUID.randomUUID().toString()
// ⚠️ Two builders, because a group message carries its gid inside the
// envelope and a 1:1 one has no field for it. `Content.text` takes no
// groupId at all — passing one is a compile error rather than a silent
// drop, which is how this was caught.
val body = if (toGroup != null) {
Content.groupText(text, groupId = toGroup.id, messageId = mid,
forwarded = true)
} else {
Content.text(text, messageId = mid, forwarded = true)
}
if (!sendToThread(body, toPeerUserId, toGroup)) return false
val thread = toGroup?.let { Vault.threadForGroup(it.id) }
?: toPeerUserId?.let { Vault.threadForUser(it) }
?: return true
record(thread, text, mine = true, messageId = mid, forwarded = true,
delivery = Delivery.DELIVERED)
return true
}
private fun enqueue(peerUserId: Long, text: String, at: Long,
thread: String? = null) {
val pending = vault.outbox()
@@ -0,0 +1,139 @@
package io.flamenet.messenger.app
import io.flamenet.e2e.Content
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* What the three new controls actually put on the wire (§6.15/§6.16/§6.19).
*
* 🔴 **The privacy rule for a forward is the one worth testing, because it is
* the one a "helpful" change would break.** §6.16 says a forward carries the
* words and nothing about where they came from. The obvious improvement —
* carrying a reference so the reader can see the original — would republish a
* private message's author to somebody who was never in that conversation. So
* the absence of that field is asserted, not just the presence of the flag.
*
* ⚠️ These check the ENVELOPE. The `Session` methods that build and apply them
* need a vault and a keystore, so their behaviour is covered on device; what is
* checkable here is the shape that crosses to the other app, which is where an
* interop bug would live.
*/
class EditForwardPinWireTest {
private fun parse(json: String): Content = Content.parse(json)!!
// ---- Forward (§6.16) -------------------------------------------------
/**
* 🔴 The whole point of the feature: forwarded, and no trail back.
*/
@Test
fun `a forward carries the words and nothing about their origin`() {
val body = Content.text("passed along", messageId = "mid-new", forwarded = true)
val sent = parse(body)
assertEquals("passed along", sent.text)
assertTrue(sent.isForwarded, "the fwd flag was lost")
// 🔴 No reference to the original, in any form.
assertNull(sent.about, "a forward is carrying a reference to its source")
assertNull(sent.editOf)
assertNull(sent.pins)
assertTrue(sent.isRenderable, "a forward is a message and must be drawn")
}
/**
* ⚠️ A forward gets a NEW id, not the original's. Reusing it would make the
* copy a target for edits and reactions aimed at the original — one of
* which would silently rewrite the other.
*/
@Test
fun `a forward is a new message, not the old one re-sent`() {
val first = parse(Content.text("passed along", messageId = "mid-a", forwarded = true))
val second = parse(Content.text("passed along", messageId = "mid-b", forwarded = true))
assertTrue(first.messageId != second.messageId)
}
/** A forward into a group carries the gid, and still no origin. */
@Test
fun `a group forward carries the group and still no origin`() {
val sent = parse(
Content.groupText("passed along", groupId = "gid-1",
messageId = "mid-new", forwarded = true),
)
assertEquals("gid-1", sent.groupId)
assertTrue(sent.isForwarded)
assertNull(sent.about, "a group forward is carrying a reference to its source")
}
// ---- Edit (§6.15) ----------------------------------------------------
@Test
fun `an edit names the message it replaces and carries the new wording`() {
val sent = parse(Content.edit("the corrected wording", of = "mid-1"))
assertEquals("mid-1", sent.editOf)
assertEquals("the corrected wording", sent.text)
// 🔴 Never drawn as a bubble of its own — the failure that shipped a
// duplicate beside the typo.
assertFalse(sent.isRenderable)
}
@Test
fun `an edit in a group names the group`() {
val sent = parse(Content.edit("fixed", of = "mid-1", groupId = "gid-1"))
assertEquals("gid-1", sent.groupId)
assertEquals("mid-1", sent.editOf)
}
// ---- Pin (§6.19) -----------------------------------------------------
/**
* 🔴 An id and never a copy. A copy would go stale the moment the message
* was edited and would outlive it being deleted — a banner quoting words
* that are no longer in the conversation.
*/
@Test
fun `a pin names an id and carries no copy of the message`() {
val sent = parse(Content.pin("mid-1"))
assertEquals("mid-1", sent.pins)
assertTrue(sent.text.isNullOrEmpty(), "a pin is carrying a copy of the message")
assertFalse(sent.isRenderable)
}
/** Unpinning is the same envelope with an empty id, not a second kind. */
@Test
fun `taking a pin down is the same envelope with an empty id`() {
val sent = parse(Content.pin(""))
assertEquals("", sent.pins)
assertFalse(sent.isRenderable)
}
@Test
fun `a pin in a group names the group`() {
val sent = parse(Content.pin("mid-1", groupId = "gid-1"))
assertEquals("gid-1", sent.groupId)
assertEquals("mid-1", sent.pins)
}
// ---- The three do not collide ----------------------------------------
/**
* ⚠️ Each envelope sets exactly one of the three fields. A message that was
* both an edit and a pin would be applied twice by a receiver that checks
* them in sequence, and the order of those checks is not part of the spec.
*/
@Test
fun `each control sets exactly one of the three fields`() {
val edit = parse(Content.edit("x", of = "mid-1"))
assertTrue(edit.editOf != null && edit.pins == null && !edit.isForwarded)
val pin = parse(Content.pin("mid-1"))
assertTrue(pin.pins != null && pin.editOf == null && !pin.isForwarded)
val forward = parse(Content.text("x", messageId = "mid-2", forwarded = true))
assertTrue(forward.isForwarded && forward.editOf == null && forward.pins == null)
}
}
@@ -120,15 +120,54 @@ object AccountIdentity {
return Ed25519.verify(pub, sig, FNID.homesCanonical(fnid, serial, relays))
}
/** Relays named by a record, but only once it verifies. */
fun verifiedRelays(record: org.json.JSONObject): List<String>? {
/**
* Relays named by a record, but only once it verifies AND is not older than
* one already seen.
*
* 🔴 **A signature alone does not stop a rollback, and that is the whole
* reason the serial exists.** An older record is *genuinely signed*: it was
* published by the account holder, before they added a second home. A relay
* that wants to be somebody's only mailbox does not have to forge anything
* — it serves the record it saw first, that record verifies perfectly, and
* the sender delivers to one relay believing it is delivering to all of
* them.
*
* That is the attack multi-homing exists to survive, and until this both
* clients checked only the signature. The relay refuses a lower serial on
* WRITE; nothing refused one on READ.
*
* ⚠️ [floor] is the highest serial this device has already accepted for
* this address, or null on first sight. Equal is fine — the same record
* re-read is the ordinary case. Only strictly lower is a rollback.
*
* ⚠️ **A refusal here is not proof of an attack**, and the caller must not
* present it as one. A relay that has genuinely fallen behind serves an old
* record for honest reasons; what the client can say is that it has seen a
* newer one, and that it will keep using it.
*/
fun verifiedRelays(
record: org.json.JSONObject,
floor: Int? = null,
): List<String>? {
val fnid = record.optString("fnid").ifEmpty { return null }
val serial = record.optInt("serial", -1)
if (serial < 0) return null
if (floor != null && serial < floor) return null
val array = record.optJSONArray("relays") ?: return null
val relays = (0 until array.length()).map { array.getString(it) }
val sig = record.optString("sig").ifEmpty { return null }
return if (verifyHomes(fnid, serial, relays, sig)) relays else null
}
/** The serial a record carries, once it verifies. Null if it does not. */
fun verifiedSerial(record: org.json.JSONObject): Int? {
val fnid = record.optString("fnid").ifEmpty { return null }
val serial = record.optInt("serial", -1)
if (serial < 0) return null
val array = record.optJSONArray("relays") ?: return null
val relays = (0 until array.length()).map { array.getString(it) }
val sig = record.optString("sig").ifEmpty { return null }
return if (verifyHomes(fnid, serial, relays, sig)) relays else null
return if (verifyHomes(fnid, serial, relays, sig)) serial else null
}
// The module's own helper, so this encodes identically to every other blob
@@ -0,0 +1,142 @@
package io.flamenet.client
import io.flamenet.e2e.Ed25519
import io.flamenet.e2e.FNID
import java.security.SecureRandom
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.json.JSONArray
import org.json.JSONObject
/**
* A home record that is older than one already seen must be refused (§20.4).
*
* 🔴 **A signature alone does not stop a rollback, and both clients checked
* only the signature.** An older home record is *genuinely signed* — the
* account holder published it, before they added a second relay. So a relay
* that wants to be somebody's only mailbox does not have to forge anything: it
* serves the record it saw first, that record verifies perfectly, and the
* sender delivers to one relay believing it is delivering to all of them.
*
* That is precisely the attack multi-homing exists to survive, and the serial
* is what was added to stop it. The relay refuses a lower serial on WRITE;
* until this, nothing refused one on READ — on either platform.
*/
class HomeRecordRollbackTest {
private val seed = ByteArray(32).also { SecureRandom().nextBytes(it) }
private val fnid = FNID.address(Ed25519.fromSeed(seed).publicKey)
/** A record the account holder really did sign. */
private fun record(serial: Int, relays: List<String>): JSONObject {
val sig = Wire.b64(Ed25519.sign(seed, FNID.homesCanonical(fnid, serial, relays)))
return JSONObject()
.put("fnid", fnid)
.put("serial", serial)
.put("relays", JSONArray(relays))
.put("sig", sig)
}
private val old = record(1, listOf("one.example"))
private val current = record(4, listOf("one.example", "two.example"))
// ---- The attack ------------------------------------------------------
/**
* 🔴 The whole finding, in one assertion. Both records verify; only the
* serial separates them.
*/
@Test
fun `an older record is refused once a newer one has been seen`() {
assertNotNull(AccountIdentity.verifiedRelays(old),
"the old record does not verify, so this test proves nothing")
assertNotNull(AccountIdentity.verifiedRelays(current))
assertNull(
AccountIdentity.verifiedRelays(old, floor = 4),
"a relay serving a genuinely-signed OLDER record was believed — " +
"which is exactly how one relay makes itself somebody's only mailbox",
)
}
/** The newer one is still accepted against the same floor. */
@Test
fun `the record at the floor is accepted`() {
assertEquals(
listOf("one.example", "two.example"),
AccountIdentity.verifiedRelays(current, floor = 4),
)
}
/**
* ⚠️ Equal is fine. Re-reading the same record is the ordinary case, and
* refusing it would break every second lookup.
*/
@Test
fun `the same serial twice is not a rollback`() {
assertNotNull(AccountIdentity.verifiedRelays(record(4, listOf("a.example")), floor = 4))
}
/** First sight has no floor and accepts whatever verifies. */
@Test
fun `with no floor any verifying record is accepted`() {
assertNotNull(AccountIdentity.verifiedRelays(old, floor = null))
assertNotNull(AccountIdentity.verifiedRelays(current, floor = null))
}
// ---- The floor cannot be poisoned ------------------------------------
/**
* 🔴 **A serial is only reported for a record that VERIFIES.** Otherwise a
* hostile relay could hand over an unsigned record carrying serial
* 2,000,000,000, have the client raise its floor to it, and lock out every
* genuine record for ever — turning the rollback defence into a denial of
* service against the account holder.
*/
@Test
fun `an unsigned record reports no serial to raise the floor with`() {
val forged = JSONObject()
.put("fnid", fnid)
.put("serial", Int.MAX_VALUE)
.put("relays", JSONArray(listOf("attacker.example")))
.put("sig", Wire.b64(ByteArray(64)))
assertNull(AccountIdentity.verifiedSerial(forged),
"a forged record could raise the floor and lock out every real one")
assertNull(AccountIdentity.verifiedRelays(forged, floor = 1))
}
/** A record signed for a DIFFERENT address does not verify either. */
@Test
fun `a record signed by somebody else is refused`() {
val otherSeed = ByteArray(32).also { SecureRandom().nextBytes(it) }
val relays = listOf("attacker.example")
val sig = Wire.b64(Ed25519.sign(otherSeed, FNID.homesCanonical(fnid, 9, relays)))
val impostor = JSONObject()
.put("fnid", fnid).put("serial", 9)
.put("relays", JSONArray(relays)).put("sig", sig)
assertNull(AccountIdentity.verifiedSerial(impostor))
assertNull(AccountIdentity.verifiedRelays(impostor, floor = null))
}
/**
* ⚠️ The relay list is part of what is signed, so swapping it invalidates
* the record — a relay cannot keep a valid signature and quietly drop the
* other homes out of the list.
*/
@Test
fun `the relay list cannot be edited without breaking the signature`() {
val tampered = record(4, listOf("one.example", "two.example"))
.put("relays", JSONArray(listOf("one.example")))
assertNull(AccountIdentity.verifiedRelays(tampered),
"a relay dropped the other homes and kept a valid signature")
}
/** A negative or absent serial is malformed, not a floor of zero. */
@Test
fun `a malformed serial is refused rather than treated as zero`() {
assertNull(AccountIdentity.verifiedRelays(
JSONObject().put("fnid", fnid).put("relays", JSONArray()).put("sig", "x")))
}
}
@@ -1667,6 +1667,33 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
storage.delete(pinnedName(threadId))
}
// -- Home-record rollback floor (§20.4) ----------------------------------
private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}"
/**
* The highest home-record serial seen for an address, or null.
*
* 🔴 **This is what makes the serial mean anything on the reading side.** An
* older home record is genuinely signed — published by the account holder
* before they added a second relay — so a relay serving it is handing out
* something that verifies. Remembering the highest seen is the only thing
* that turns "this signature is valid" into "this is not a rollback".
*
* ⚠️ Sealed like everything else here: which addresses this device has
* looked up is exactly the metadata the vault exists to keep off a stolen
* phone, and the name is a hash of the address for the same reason.
*/
fun homeSerial(fnid: String): Int? =
readJson(homeSerialName(fnid))?.optInt("serial", -1)?.takeIf { it >= 0 }
/** Remember a serial, never going backwards. */
fun saveHomeSerial(fnid: String, serial: Int) {
val seen = homeSerial(fnid) ?: -1
if (serial <= seen) return
writeJson(homeSerialName(fnid), JSONObject().put("serial", serial))
}
// -- Pinned message (§6.19) ----------------------------------------------
private fun pinnedName(threadId: String) = "pinned-$threadId"