Completes the inbound half. A member now files one of three things when a
kind:1059 arrives as an application payload, and which one depends only on
whether their key opens it.
The carve-out first. MarmotInboundManager rejects any inner event whose
pubkey is not the MLS sender's credential identity -- MIP-03, and the check
that stops a member minting events attributed to somebody else. A gift wrap
is keyed to a throwaway key by construction and names nobody, so it cannot
satisfy a check about its author; kind:1059 is now exempt.
The check is not weakened, it is relocated. What replaces it is
`seal.pubKey == senderIdentity` on a seal whose signature verifies -- a
signature bound to an MLS leaf, rather than a plaintext field compared to
one. It is strictly harder to forge: the attack it stops is a member
re-wrapping a seal they were legitimately sent and passing it off to a
third party as its author's, and that fails here because the MLS frame
says who actually sent this one.
senderIdentity also stops being optional. It was previously only compared;
now every sender-derived field reads from it, because the payload carries
no author at all. A leaf with no identity is an error rather than a
mismatch.
Attribution is resolved in NostrDao and handed to fromGroupEventResult,
rather than added to GroupEventResult.ApplicationMessage where it belongs.
quartz is a binary dependency here (com.vitorpamplona.quartz:quartz:1.14.0)
and the local checkout is a reference copy, not a build input, so the
result type cannot gain a field without publishing a fork. NostrDao holds
the group, the leaf index is already on the result, and an application
message advances no epoch, so the tree has not moved by the time it reads
it. Same value, no fork.
The three outcomes:
The recipient opens the wrap and gets the words. The rumor is stored as its
own MarmotInnerEvent keyed on the rumor's id -- the id the sender queued --
so both sides of the conversation hold one message under one identity. The
wrap keeps its own row as the wire artifact.
A bystander gets a line with no content. That is the feature working: the
group is meant to see that a private message was sent and to whom, and
nothing else.
The sender, on a re-sync, is indistinguishable from a bystander, because
the wrap's key was discarded and we cannot open our own message. Left
unguarded this files an empty placeholder over the row sendChatMessage
wrote -- which is the only copy of those words anywhere. Hence the early
return on senderIdentity == us, mirroring the guard
NostrDao.persistInboundChatMessage already carries on the NIP-17 path.
A failed validation drops the message and logs rather than throwing. The
caller is inside storeNostrEvent's transaction, and a forged direct message
should cost its own line, not the whole event.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>