mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
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:
committed by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
7962d46ae9
commit
8d042031ce
@@ -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();
|
||||
|
||||
@@ -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), "")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user