fix: enforce E2E ciphertext across all channel-message kinds and key D1 on the latch

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-06-25 17:37:04 -04:00
committed by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 7962d46ae9
commit 8d042031ce
3 changed files with 136 additions and 20 deletions

View File

@@ -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();

View File

@@ -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), "")

View File

@@ -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<moka::sync::Cache<Uuid, String>>,
/// 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<moka::sync::Cache<Uuid, bool>>,
/// 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<bool, buzz_db::DbError> {
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.