mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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()
|
||||
|
||||
+5
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user