diff --git a/PARITY.md b/PARITY.md index 970fdfa..74a0e5c 100644 --- a/PARITY.md +++ b/PARITY.md @@ -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.** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 543469c..81cd221 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -1622,6 +1622,8 @@ fun ColumnScope.ChatScreen( avatars: Map = 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 = 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(null) } + var forwarding by remember(chat.threadId) { mutableStateOf(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, ): 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, + groups: List, + 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") } + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index 07188dc..b0e1488 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 21c4535..85c5b4e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 3309158..04b964f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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() diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt new file mode 100644 index 0000000..62ec02e --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/EditForwardPinWireTest.kt @@ -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) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt index e87aef4..dc3c310 100644 --- a/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt +++ b/client/src/main/kotlin/io/flamenet/client/AccountIdentity.kt @@ -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? { + /** + * 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? { + 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 diff --git a/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt new file mode 100644 index 0000000..e3cbacf --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/HomeRecordRollbackTest.kt @@ -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): 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"))) + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index e61e5cc..2afd894 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -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"