fix: close audit findings — report leak, model-level rumor guards, hide share/bookmarks on private notes

Merge-readiness audit follow-ups:

- Account.report(note): reporting a private rumor now reports the AUTHOR
  (p-tag only) instead of publishing a kind-1984 that e-tags the private
  rumor id onto public relays (the one confirmed leak)
- Defense-in-depth guards at the model layer so the invariant no longer
  relies on UI gating alone: RepostAction.repost returns null / throws
  for empty-sig targets (covers Account.boost, createBoostEvent, and the
  desktop call path), ReactionAction.reactTo (simple overload) throws,
  and Account.broadcast no-ops for unsigned non-wrapped events — without
  the guard it would disclose the rumor JSON to relays even though they
  reject the signature
- Hide remaining actions that can't work on private rumors, per review:
  share buttons (action row, both note menus) and all bookmark/playlist/
  emoji-list rows (their lists reference an id other devices can't
  resolve; public lists would also leak it)
- ZapCustomDialog: remember(accountViewModel, baseNote) so the
  preselected zap type can't go stale on lazy-list slot reuse

Broadcast of the gift wrap itself (like DMs do via WrappedEvent.host)
needs host tracking for non-WrappedEvent rumor kinds in quartz — left
as a follow-up; the broadcast row stays hidden for kind-1 rumors.

https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
This commit is contained in:
Claude
2026-06-11 20:53:54 +00:00
parent 7985377a38
commit dd9ee0b5c6
7 changed files with 72 additions and 39 deletions
@@ -945,7 +945,15 @@ class Account(
note: Note,
type: ReportType,
content: String = "",
) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
) {
if (note.isPrivateRumor()) {
// A kind-1984 e-tagging the rumor would leak the private id onto
// public relays. Report the author instead (p-tag only).
note.author?.let { report(it, type, content) }
} else {
sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer))
}
}
suspend fun report(
user: User,
@@ -1320,6 +1328,12 @@ class Account(
suspend fun broadcast(note: Note) {
note.event?.let { noteEvent ->
if (noteEvent !is WrappedEvent && noteEvent.sig.isEmpty()) {
// Unsealed rumor without a host wrap (e.g. a kind-1 private
// reply): publishing it would disclose the private content to
// relays even though they reject the missing signature.
return
}
if (noteEvent is WrappedEvent && noteEvent.host != null) {
// download the event and send it.
noteEvent.host?.let { host ->
@@ -406,6 +406,9 @@ fun CardBody(
) {
onWantsToEditDraft()
}
} else if (note.isPrivateRumor()) {
// No external share link for private rumors: nobody can
// resolve the id from relays and sharing it leaks the id.
} else {
NoteQuickActionItem(
icon = MaterialSymbols.Share,
@@ -316,10 +316,12 @@ private fun InnerReactionRow(
}
ReactionRowAction.Share -> {
ShareReaction(
note = baseNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
)
if (!isPrivateRumor) {
ShareReaction(
note = baseNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
)
}
}
ReactionRowAction.Pay -> {
@@ -162,7 +162,7 @@ fun ZapCustomDialog(
}
var selectedZapType by
remember(accountViewModel) {
remember(accountViewModel, baseNote) {
val default = accountViewModel.defaultZapType()
mutableStateOf(
if (isPrivateTarget && default != LnZapEvent.ZapType.NONZAP) {
@@ -226,17 +226,19 @@ fun NoteDropDownMenu(
onDismiss()
}
}
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note))
putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link))
}
val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
actContext.startActivity(shareIntent)
onDismiss()
if (!isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note))
putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link))
}
val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
actContext.startActivity(shareIntent)
onDismiss()
}
}
}
@@ -307,6 +309,12 @@ fun NoteDropDownMenu(
// Showing both at once is noisy and makes "bookmark" feel like the catch-all when
// it really isn't for these kinds.
when {
isPrivateRumor -> {
// No bookmark/playlist/emoji-list rows for private rumors:
// those lists reference the note by id, which other devices
// can't resolve from relays and public lists would leak.
}
note.event is MusicTrackEvent && note is AddressableNote -> {
// Music tracks (kind 36787) belong in playlists (kind 34139). The
// sheet behind this nav lets the user toggle membership across all of
@@ -335,19 +343,15 @@ fun NoteDropDownMenu(
}
else -> {
if (!isPrivateRumor) {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
}
// Private bookmarks are stored inside the list's encrypted
// content, so a private rumor's id stays off public relays.
if (state.isPrivateBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
@@ -359,17 +363,15 @@ fun NoteDropDownMenu(
onDismiss()
}
}
if (!isPrivateRumor) {
if (state.isPublicBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
if (state.isPublicBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
}
}
@@ -47,6 +47,11 @@ object RepostAction {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot repost: signer is not writeable")
}
if (eventHint.event.sig.isEmpty()) {
// Unsealed private rumor: a public kind-6/16 would e-tag the
// private rumor id onto public relays.
throw IllegalStateException("Cannot repost a private rumor")
}
// Use NIP-18 RepostEvent (kind 6) for text notes (kind 1)
// Use GenericRepostEvent (kind 16) for all other kinds
@@ -76,6 +81,7 @@ object RepostAction {
): Event? {
// All validation in commons
if (!signer.isWriteable()) return null
if (note.isPrivateRumor()) return null
if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null
val hint = note.toEventHint<Event>() ?: return null
@@ -57,6 +57,12 @@ object ReactionAction {
if (!signer.isWriteable()) {
throw IllegalStateException("Cannot react: signer is not writeable")
}
if (eventHint.event.sig.isEmpty()) {
// Unsealed private rumor: a public kind-7 would e-tag the private
// rumor id onto public relays. Use reactToWithGroupSupport, which
// gift-wraps reactions for empty-sig targets.
throw IllegalStateException("Cannot react publicly to a private rumor")
}
// Handle custom emoji reactions (format: ":emoji_name:")
val template =