fix(marmot): inner events are unsigned rumors per MIP-03

MIP-03 ("Application Messages" → Security Requirements) is explicit:

  > "Inner events MUST remain unsigned (no `sig` field)
  >  This ensures leaked events cannot be published to public relays."

Authentication comes from (a) MLS framing — the sender's LeafNode
credential signs the outer MLS Application message — and (b) the
mandatory check in MarmotInboundProcessor.processPrivateMessage that
the inner event's `pubkey` equals the MLS sender's credential identity.
The Nostr Schnorr signature is redundant, and leaving it populated
means a leaked plaintext can be replayed as a valid public kind:9.

whitenoise-rs is spec-compliant (builds via `UnsignedEvent::new()` +
`ensure_id()`, never calls sign). Amethyst was the non-conforming side
on both directions:

1. Send path (`MarmotManager.buildTextMessage`,
   `AccountViewModel.sendMarmotGroupMediaMessage`) called
   `signer.sign(template)`, producing a signed rumor that shipped a
   valid Schnorr signature through the encrypted channel. Switch both
   to `RumorAssembler.assembleRumor` — same id derivation (SHA-256
   over canonicalized [0, pubkey, createdAt, kind, tags, content]),
   but `sig = ""`.

2. Restore path (`Account.kt` on startup reload of persisted inner
   events) called `cache.justConsume(innerEvent, null, false)`, which
   routed through `consumeRegularEvent` → `justVerify` → `event.verify()`
   → FAIL on empty sig → Note registered with `event = null`, message
   never rendered. Pass `wasVerified = true`, matching what the live
   receive path already does after the previous commit (573c5c2b).

Existing on-disk persisted messages from older signed-rumor builds
still load — `wasVerified=true` skips sig verify entirely, so both
legacy signed and spec-correct unsigned rumors deserialize cleanly.
This commit is contained in:
Claude
2026-04-22 20:14:07 +00:00
parent 573c5c2b06
commit 5a18dc41c8
3 changed files with 34 additions and 11 deletions
@@ -3036,7 +3036,11 @@ class Account(
val innerEvent =
com.vitorpamplona.quartz.nip01Core.core.Event
.fromJson(json)
val isNew = cache.justConsume(innerEvent, null, false)
// MIP-03 inner events are unsigned rumors
// (empty sig); pass wasVerified=true so
// LocalCache skips the Nostr secp256k1 check
// that would silently reject them.
val isNew = cache.justConsume(innerEvent, null, true)
val innerNote = cache.getOrCreateNote(innerEvent.id)
if (isNew) {
innerNote.event = innerEvent
@@ -1477,7 +1477,16 @@ class AccountViewModel(
alt(caption)
}
}
val innerEvent = account.signer.sign(template)
// MIP-03: inner events MUST remain unsigned (no `sig`) so a leaked
// plaintext can't be replayed as a valid public kind:9. Authorship
// is authenticated by the MLS sender's LeafNode + the pubkey↔
// credential-identity equality check on the receive side.
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip01Core.core.Event>(
account.signer.pubKey,
template,
)
val relays = marmotGroupRelays(nostrGroupId)
account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays)
}