commit 350bf9d13a9b5f0eb1abec90acefd1151d6b9505
parent 3e0d1de6407e9d28f9eb2fbd0c1aa5b65ab35686
Author: triesap <tyson@radroots.org>
Date: Tue, 16 Jun 2026 00:24:19 -0700
runtime: harden HLL privacy eligibility
- Add a suppressible HLL accumulator for count scans.
- Suppress HLL for private hidden unknown redacted limited and multi-filter counts.
- Preserve public single-filter HLL behavior and exact count semantics.
- Prove disabled negentropy does not validate or screen storage filters.
Diffstat:
1 file changed, 223 insertions(+), 12 deletions(-)
diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs
@@ -296,6 +296,54 @@ impl BaseRelayCountEventsReport {
}
}
+struct BaseRelayCountHll {
+ offset: Option<usize>,
+ hll: Option<PocketHll8>,
+ suppressed: bool,
+}
+
+impl BaseRelayCountHll {
+ fn new(filters: &[PocketOwnedFilter]) -> Result<Self, BaseRelayError> {
+ let offset = BaseRelay::count_hll_offset(filters)?;
+ Ok(Self {
+ offset,
+ hll: offset.map(|_| PocketHll8::new()),
+ suppressed: false,
+ })
+ }
+
+ fn suppress(&mut self) {
+ if self.offset.is_some() {
+ self.suppressed = true;
+ }
+ }
+
+ fn observe(
+ &mut self,
+ groups: Option<&GroupServiceHandle>,
+ event: &PocketEvent,
+ ) -> Result<(), BaseRelayError> {
+ let Some(offset) = self.offset else {
+ return Ok(());
+ };
+ if BaseRelay::event_suppresses_count_hll(groups, event)? {
+ self.suppressed = true;
+ return Ok(());
+ }
+ if let Some(hll) = &mut self.hll {
+ hll.add_element(event.pubkey().as_bytes(), offset)
+ .map_err(|error| BaseRelayError::error(error.to_string()))?;
+ }
+ Ok(())
+ }
+
+ fn into_hex(self) -> Option<String> {
+ (!self.suppressed)
+ .then(|| self.hll.map(|value| value.to_hex_string()))
+ .flatten()
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BaseRelayFilterLimitMode {
ApplyDefaultLimit,
@@ -1656,8 +1704,7 @@ impl BaseRelay {
let mut group_read_denied = false;
let mut query_metrics = BaseRelayQueryMetrics::default();
let count_query = query.exact_count();
- let hll_offset = Self::count_hll_offset(filters)?;
- let mut hll = hll_offset.map(|_| PocketHll8::new());
+ let mut hll = BaseRelayCountHll::new(filters)?;
for filter in filters {
let report = Self::query_filter_events_report_with_services(
store,
@@ -1669,21 +1716,19 @@ impl BaseRelay {
BaseRelayFilterLimitMode::PreserveCountLimitless,
)?;
group_read_denied |= report.group_read_denied;
+ if report.group_read_denied {
+ hll.suppress();
+ }
query_metrics = query_metrics.add(report.query_metrics);
for event in report.events {
let event: &PocketEvent = &event;
- if let (Some(hll), Some(offset)) = (&mut hll, hll_offset) {
- hll.add_element(event.pubkey().as_bytes(), offset)
- .map_err(|error| BaseRelayError::error(error.to_string()))?;
- }
+ hll.observe(groups, event)?;
seen.insert(event.id());
}
}
let count = u64::try_from(seen.len())
.map_err(|_| BaseRelayError::error("visible event count overflow"))?;
- let hll = (!group_read_denied)
- .then(|| hll.map(|value| value.to_hex_string()))
- .flatten();
+ let hll = hll.into_hex();
Ok(BaseRelayCountEventsReport::new(
count,
hll,
@@ -1701,6 +1746,26 @@ impl BaseRelay {
.map_err(|error| BaseRelayError::error(error.to_string()))
}
+ fn event_suppresses_count_hll(
+ groups: Option<&GroupServiceHandle>,
+ event: &PocketEvent,
+ ) -> Result<bool, BaseRelayError> {
+ let Some(groups) = groups else {
+ return Ok(false);
+ };
+ let class = classify_group_event(event, groups.limits()).map_err(BaseRelayError::from)?;
+ let Some(group_id) = class.group_id() else {
+ return Ok(false);
+ };
+ let projection = groups.projection();
+ let Some(group) = projection.group(group_id) else {
+ return Ok(true);
+ };
+ Ok(projection.tombstone(group_id).is_some()
+ || group.metadata().private()
+ || group.metadata().hidden())
+ }
+
fn query_filter_events_report_with_services(
store: &PocketStoreHandle,
groups: Option<&GroupServiceHandle>,
@@ -2092,6 +2157,80 @@ mod tests {
}
#[test]
+ fn base_relay_disabled_negentropy_does_not_validate_or_screen_filter() {
+ let owner = signer(7).public_key().clone();
+ let owner_auth = authenticated_state(7);
+ let mut auth =
+ BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state");
+ let mut relay = test_relay_with_groups(
+ "base-relay-negentropy-disabled-no-screen",
+ 4,
+ &enabled_groups_for_owner(&owner),
+ );
+ let private_create = signed_private_group_create_event(7, "PrivateNegentropy");
+ assert_accepted(
+ relay
+ .handle_event_with_auth(private_create.clone(), &owner_auth)
+ .expect("private create"),
+ &private_create,
+ );
+ let private_event = signed_event_at(
+ 7,
+ 1,
+ vec![h("PrivateNegentropy")],
+ "private negentropy",
+ 1_714_124_434,
+ );
+ assert_accepted(
+ relay
+ .handle_event_with_auth(private_event.clone(), &owner_auth)
+ .expect("private event"),
+ &private_event,
+ );
+ let subscription_id = SubscriptionId::new("neg-noscreen").expect("sub");
+ let filter = filter_from_value(&serde_json::json!({
+ "kinds": [1],
+ "#h": ["PrivateNegentropy"],
+ "limit": 501
+ }))
+ .expect("filter");
+
+ assert_eq!(
+ relay
+ .handle_client_message(
+ ClientMessage::NegOpen {
+ subscription_id: subscription_id.clone(),
+ filter,
+ message: "00".to_owned()
+ },
+ &mut auth,
+ UnixTimestamp::new(100)
+ )
+ .expect("neg open"),
+ vec![RelayMessage::NegErr {
+ subscription_id: subscription_id.clone(),
+ message: NEGENTROPY_DISABLED_MESSAGE.to_owned()
+ }]
+ );
+ assert_eq!(
+ relay
+ .handle_client_message(
+ ClientMessage::NegMsg {
+ subscription_id: subscription_id.clone(),
+ message: "should-not-touch-storage".to_owned()
+ },
+ &mut auth,
+ UnixTimestamp::new(101)
+ )
+ .expect("neg msg"),
+ vec![RelayMessage::NegErr {
+ subscription_id,
+ message: NEGENTROPY_DISABLED_MESSAGE.to_owned()
+ }]
+ );
+ }
+
+ #[test]
fn base_relay_fetches_events_by_store_offset() {
let relay = test_relay("base-relay-offset-lookup", 4);
let event = signed_public_event(7, 1, Vec::new(), "offset");
@@ -2661,7 +2800,7 @@ mod tests {
}
#[test]
- fn base_relay_count_hll_omits_for_noneligible_and_redacted_counts() {
+ fn base_relay_count_hll_omits_for_private_hidden_unknown_limited_multi_and_redacted_counts() {
let owner = signer(7).public_key().clone();
let owner_auth = authenticated_state(7);
let unauth = BaseAuthState::new("wss://relay.radroots.test", 60, 600).expect("auth state");
@@ -2685,7 +2824,7 @@ mod tests {
let private = signed_event_at(
7,
7,
- vec![h("PrivateHll"), target_tag],
+ vec![h("PrivateHll"), target_tag.clone()],
"private reaction",
1_714_124_434,
);
@@ -2695,6 +2834,78 @@ mod tests {
.expect("private reaction"),
&private,
);
+ let hidden_create =
+ signed_group_create_event_with_tags(7, "HiddenHll", vec![hidden()], 1_714_124_435);
+ assert_accepted(
+ relay
+ .handle_event_with_auth(hidden_create.clone(), &owner_auth)
+ .expect("hidden create"),
+ &hidden_create,
+ );
+ let hidden = signed_event_at(
+ 7,
+ 7,
+ vec![h("HiddenHll"), target_tag.clone()],
+ "hidden reaction",
+ 1_714_124_436,
+ );
+ assert_accepted(
+ relay
+ .handle_event_with_auth(hidden.clone(), &owner_auth)
+ .expect("hidden reaction"),
+ &hidden,
+ );
+ let unknown = signed_event_at(
+ 7,
+ 7,
+ vec![h("UnknownHll"), target_tag.clone()],
+ "unknown reaction",
+ 1_714_124_437,
+ );
+ relay
+ .store
+ .store_event(&tangle_event_to_pocket(&unknown).expect("unknown pocket"))
+ .expect("store unknown");
+
+ let authorized_private = relay
+ .handle_count_with_auth_protocol(
+ SubscriptionId::new("count-hll-authorized-private").expect("sub"),
+ vec![
+ filter_from_value(&serde_json::json!({"kinds":[7],"#e":[target.clone()]}))
+ .expect("filter"),
+ ],
+ &owner_auth,
+ )
+ .expect("authorized private count");
+ assert!(matches!(
+ authorized_private,
+ RelayMessage::Count {
+ count: 3,
+ hll: None,
+ ..
+ }
+ ));
+
+ let multi_filter = relay
+ .handle_count_with_auth_protocol(
+ SubscriptionId::new("count-hll-multi-filter").expect("sub"),
+ vec![
+ filter_from_value(&serde_json::json!({"kinds":[7],"#e":[target.clone()]}))
+ .expect("filter"),
+ filter_from_value(&serde_json::json!({"kinds":[7],"#e":["c".repeat(64)]}))
+ .expect("filter"),
+ ],
+ &owner_auth,
+ )
+ .expect("multi count");
+ assert!(matches!(
+ multi_filter,
+ RelayMessage::Count {
+ count: 3,
+ hll: None,
+ ..
+ }
+ ));
let limited = relay
.handle_count_with_auth_protocol(
@@ -2711,7 +2922,7 @@ mod tests {
assert!(matches!(
limited,
RelayMessage::Count {
- count: 2,
+ count: 3,
hll: None,
..
}