diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index e187676875..bb3cfd1d3f 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -284,19 +284,22 @@ pub(crate) async fn dispatch_persistent_event( ); } - // Channel visibility gates content-reading side effects (D1). Private and - // DM channels are end-to-end encrypted — their kind:9 content is ciphertext, - // so indexing it (`index.rs` stores raw content) would leak garbage into - // Typesense, and workflow rules matching on content cannot fire. The - // existing skips are kind-keyed (gift wraps only); E2E kind:9 messages are - // NOT gift wraps, so we add a visibility-keyed skip at the decision site. - // Fail closed: if visibility can't be determined, treat as private so we - // never index a possibly-encrypted channel's content. - let channel_is_private = match stored_event.channel_id { - Some(channel_id) => match state.channel_visibility_cached(channel_id).await { - Ok(v) => v == "private", + // Encryption latch gates content-reading side effects (D1). A latched + // channel's kind:9 content is NIP-44 ciphertext, so indexing it (`index.rs` + // stores raw content) would store unreadable bytes into Typesense, and + // workflow rules matching on content cannot fire. The encryption boundary + // is the latch (`encryption_activated_at`), NOT channel visibility: a + // `private` group channel has no group key in Phase 1 and is plaintext, so + // keying this skip on visibility would silently drop access-controlled + // search/workflows for private-group members (search is already + // membership-gated at query time, so indexing their plaintext was never a + // leak). Fail closed: if the latch state can't be determined, treat as + // encrypted so we never index a possibly-ciphertext channel's content. + let channel_is_encrypted = match stored_event.channel_id { + Some(channel_id) => match state.channel_is_encrypted_cached(channel_id).await { + Ok(encrypted) => encrypted, Err(e) => { - warn!(%channel_id, "index/workflow skip: visibility lookup failed, treating as private: {e}"); + warn!(%channel_id, "index/workflow skip: latch lookup failed, treating as encrypted: {e}"); true } }, @@ -306,11 +309,12 @@ pub(crate) async fn dispatch_persistent_event( // Skip search indexing for NIP-17 gift wraps (ciphertext), NIP-DV // visibility snapshots (per-viewer private hide state, owner-gated reads), // author-only kinds (ciphertext not useful in search, defense in depth), - // and any private/DM channel (E2E content — see channel_is_private above). + // and any latched E2E channel (ciphertext content — see channel_is_encrypted + // above). if kind_u32 != KIND_GIFT_WRAP && kind_u32 != buzz_core::kind::KIND_DM_VISIBILITY && !AUTHOR_ONLY_KINDS.contains(&kind_u32) - && !channel_is_private + && !channel_is_encrypted && state .search_index_tx .try_send(stored_event.clone()) @@ -352,7 +356,7 @@ pub(crate) async fn dispatch_persistent_event( && !buzz_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP - && !channel_is_private + && !channel_is_encrypted { let workflow_engine = Arc::clone(&state.workflow_engine); let workflow_event = stored_event.clone(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 942810bc51..e8b8cb4ce1 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -408,6 +408,24 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { ) } +/// Channel-message kinds that carry a free-text display body, mirroring the +/// thread-reply set the CLI treats as channel messages (`messages.rs`). In a +/// latched E2E DM these bodies MUST be NIP-44 ciphertext (rule 15c) — gating +/// kind:9 alone left the edit (40003), v2 (40002), diff (40008), and forum +/// comment (45003) paths able to land plaintext into a latched channel, the +/// exact leak the latch exists to prevent. None of these are global-only +/// kinds, so each carries an `h` tag and resolves to a channel at ingest. +pub(crate) fn is_e2e_enforced_content_kind(kind: u32) -> bool { + matches!( + kind, + KIND_STREAM_MESSAGE + | KIND_STREAM_MESSAGE_V2 + | KIND_STREAM_MESSAGE_EDIT + | KIND_STREAM_MESSAGE_DIFF + | KIND_FORUM_COMMENT + ) +} + /// Check channel membership: member OR open-visibility channel. /// /// Returns `Ok(())` if allowed, `Err(reason)` if denied. @@ -1451,10 +1469,18 @@ pub async fn ingest_event( // ── 15c. E2E DM ciphertext enforcement (D2) ────────────────────────── // A DM channel with the encryption latch set (`encryption_activated_at`) // is end-to-end encrypted. The relay cannot decrypt, so it enforces the - // boundary syntactically: in a latched channel, EVERY incoming kind:9 MUST - // be NIP-44 v2 ciphertext, or it is rejected fail-visible — never silently - // stored as plaintext. We use the strong validator, not the length-only - // check, or a long plaintext message in range would pass. + // boundary syntactically: in a latched channel, EVERY incoming channel + // message that carries a free-text display body (see + // `is_e2e_enforced_content_kind`) MUST be NIP-44 v2 ciphertext, or it is + // rejected fail-visible — never silently stored as plaintext. We use the + // strong validator, not the length-only check, or a long plaintext message + // in range would pass. + // + // The gate spans the full channel-message-kind set, not kind:9 alone: + // edits (40003), v2 messages (40002), diffs (40008), and forum comments + // (45003) all carry a display body and an `h` tag, so a plaintext one would + // otherwise land in a latched channel and defeat the latch — the exact leak + // this guard prevents. // // Enforcement is latch-PRESENCE only — it deliberately does NOT compare the // event's `created_at` against the latch timestamp. A `created_at >= latch` @@ -1472,7 +1498,7 @@ pub async fn ingest_event( // the exact leak this guard prevents. A `ChannelNotFound` is different: the // channel cannot have a latch, and the event is rejected downstream at // insert, so it falls through here to keep that existing not-found behavior. - if kind_u32 == KIND_STREAM_MESSAGE { + if is_e2e_enforced_content_kind(kind_u32) { if let Some(ch_id) = channel_id { match state.db.get_channel(ch_id).await { Ok(channel) => { @@ -2243,6 +2269,49 @@ mod tests { assert!(validate_diff_event(&event).is_err()); } + // ── 15c gate: content-bearing channel-message kinds ────────────────── + + #[test] + fn e2e_gate_covers_all_channel_message_kinds() { + // The latch-enforcement gate must span every channel-message kind that + // carries a free-text display body — not kind:9 alone. A plaintext edit + // (40003), v2 message (40002), diff (40008), or forum comment (45003) + // into a latched DM is the exact leak the latch exists to prevent. + for kind in [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_DIFF, + KIND_FORUM_COMMENT, + ] { + assert!( + is_e2e_enforced_content_kind(kind), + "kind {kind} carries a display body and must be E2E-enforced in a latched channel" + ); + } + } + + #[test] + fn e2e_gate_excludes_uncovered_kinds() { + // Reactions, deletions, and forum votes carry no free-text display body, + // and profiles are global-only — none can leak readable content into a + // latched channel, so gating them would only reject legitimate events. + // Forum posts (45001) carry a body but sit outside the channel-message + // set the gate mirrors; they are deliberately out of this fix's scope. + for kind in [ + KIND_REACTION, + KIND_DELETION, + KIND_FORUM_VOTE, + KIND_PROFILE, + KIND_FORUM_POST, + ] { + assert!( + !is_e2e_enforced_content_kind(kind), + "kind {kind} is outside the gated channel-message set" + ); + } + } + fn make_dummy_event() -> Event { let keys = nostr::Keys::generate(); nostr::EventBuilder::new(nostr::Kind::Custom(9), "") diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 1a45948d6a..b3ab3fa602 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -225,6 +225,11 @@ pub struct AppState { /// Per-channel visibility string, used to gate the private-channel fan-out /// access check so open channels stay zero-cost. Invalidated on a flip. pub channel_visibility_cache: Arc>, + /// Per-channel E2E-encryption state (`encryption_activated_at.is_some()`), + /// used to gate content-reading side effects (D1: search index + workflow + /// match). The latch is write-once at DM creation and never moves, so both + /// `true` and `false` are safe to cache for the TTL. + pub channel_encryption_cache: Arc>, /// Bounded channel for search indexing — prevents OOM if Typesense is slow/down. /// Capacity 1000: at ~1KB/event that's ~1MB of backlog before we start dropping. @@ -394,6 +399,14 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(10)) .build(), ), + // Longer TTL than visibility: the latch is write-once at creation + // and never moves, so a cached value can never go stale. + channel_encryption_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(std::time::Duration::from_secs(300)) + .build(), + ), search_index_tx, audit_tx, media_storage: Arc::new(media_storage), @@ -508,6 +521,7 @@ impl AppState { self.membership_cache.invalidate_all(); self.accessible_channels_cache.invalidate_all(); self.channel_visibility_cache.invalidate_all(); + self.channel_encryption_cache.invalidate_all(); } /// Fire-and-forget publish of a cache-key drop to all other pods. Failures @@ -580,6 +594,35 @@ impl AppState { } Ok(visibility) } + + /// Whether a channel's E2E-encryption latch is set, with a 5-minute cache. + /// + /// Gates content-reading side effects (D1): a latched channel's message + /// content is NIP-44 ciphertext, so search-indexing it stores unreadable + /// bytes and content-matching workflow rules cannot fire. The latch — not + /// channel visibility — is the encryption boundary: a `private` group + /// channel is plaintext (it has no group key in Phase 1), so keying this on + /// the latch keeps access-controlled search/workflows working for private + /// group members while still skipping genuinely-encrypted DMs. + /// + /// Fails open to `false` is NOT acceptable here, so callers handle the + /// `Err` themselves (D1 treats a lookup failure as encrypted = skip). + pub async fn channel_is_encrypted_cached( + &self, + channel_id: Uuid, + ) -> Result { + if let Some(cached) = self.channel_encryption_cache.get(&channel_id) { + return Ok(cached); + } + let encrypted = self + .db + .get_channel(channel_id) + .await? + .encryption_activated_at + .is_some(); + self.channel_encryption_cache.insert(channel_id, encrypted); + Ok(encrypted) + } } /// Handle for graceful audit worker shutdown.