fix(buzz): only the author's own kind-40003 edit may rewrite a message

The Buzz edit overlay applied the newest kind-40003 by created_at regardless
of who signed it, so a 40003 signed by anyone — targeting someone else's
message — would rewrite that message in every reader's UI. The send side
already gates Edit to your own messages, but the apply side re-checked
nothing and effectively trusted the relay to reject cross-author edits.

Enforce author-only on apply, matching feed (1010) and Concord (3302) edits:
observeBuzzEdit now resolves through LocalCache.findLatestBuzzEditForNote,
which keeps only edits whose author is the original message's author (newest
by created_at — Buzz's own last-write-wins rule otherwise). There is no Buzz
feature that edits another user's message; moderation is delete/hide.

Added a test: a verified forged edit by a different author lands in the store
but never overrides the message, while the real author's later edit does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HdLnAa4Pa1pFV9FTYVTB6
This commit is contained in:
Claude
2026-07-24 17:54:23 +00:00
parent 98d8f88c0a
commit 8dcee46cdf
3 changed files with 49 additions and 8 deletions
@@ -3270,6 +3270,19 @@ object LocalCache : ILocalCache, ICacheProvider {
fun cachedModificationEventsForNote(note: Note): List<Note> = findLatestModificationForNote(note)
/**
* The kind-40003 Buzz edit currently overlaying [note], or null when unedited. Like every other
* edit kind, only the ORIGINAL message author's edits count — the send side already gates Edit to
* your own messages, and the relay is not trusted to reject a cross-author edit, so a 40003 signed
* by anyone else can never rewrite your message. The newest by created_at wins (Buzz's rule).
*/
fun findLatestBuzzEditForNote(note: Note): Note? {
val authorHex = note.author?.pubkeyHex ?: return null
return note.edits
.filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex }
.maxByOrNull { it.createdAt() ?: 0L }
}
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" }
notes.cleanUp()
@@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -60,7 +61,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -69,19 +69,16 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
* arrive. Returns null when the message is unedited.
*
* Each edit is anchored on the message it edits ([Note.edits], where [LocalCache] consumes it),
* so it is held for as long as its message and read straight off the note — no channel-keyed
* side store. Buzz keeps the newest by `created_at` regardless of author (its own last-write-wins
* rule); [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold.
* so it is held for as long as its message and read straight off the note — no channel-keyed side
* store. [LocalCache.findLatestBuzzEditForNote] applies only the original author's newest edit;
* [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold.
*/
@Composable
fun observeBuzzEdit(note: Note): Note? {
val latest by
produceState<Note?>(initialValue = null, note.idHex) {
note.flow().edits.stateFlow.collect {
value =
note.edits
.filter { it.event is StreamMessageEditEvent }
.maxByOrNull { it.createdAt() ?: 0L }
value = LocalCache.findLatestBuzzEditForNote(note)
}
}
return latest
@@ -39,6 +39,7 @@ import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -177,6 +178,36 @@ class BuzzWorkspaceChannelTest {
assertEquals("edited offline", newest?.event?.content)
}
@Test
fun aForgedEditByAnotherAuthorNeverOverridesTheMessage() =
runBlocking {
val channelId = newChannelId()
val original = streamMessage(channelId, "the truth") // authored by `signer`
LocalCache.checkDeletionAndConsume(original, buzzRelay, false)
// Mallory publishes a well-formed, VERIFIED 40003 targeting someone else's message.
val mallory = NostrSignerInternal(KeyPair())
val forged =
mallory.sign(
StreamMessageEditEvent.build(channelId, original.id, "lies", createdAt = original.createdAt + 100),
)
LocalCache.checkDeletionAndConsume(forged, buzzRelay, false)
// The forged edit still lands in the store (it is a valid signed event)…
val target = LocalCache.getNoteIfExists(original.id)!!
assertTrue("the forged edit is stored", target.edits.any { it.idHex == forged.id })
// …but the overlay only applies the ORIGINAL author's edits, so it is ignored.
assertNull(
"an edit by a different author must never override the message",
LocalCache.findLatestBuzzEditForNote(target),
)
// The real author's own later edit does apply.
val real = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 200))
LocalCache.checkDeletionAndConsume(real, buzzRelay, false)
assertEquals("the fix", LocalCache.findLatestBuzzEditForNote(target)?.event?.content)
}
@Test
fun pruningAMessageReleasesItsEdits() =
runBlocking {