tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit bfaa2cc638ef740b28ab42fb246a2a792e4805ba
parent 5868a5a46979d1c7acd588a8c760b3dfde88067e
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 18:58:57 -0700

runtime: validate auth through Pocket events

- add Pocket event identity signature and protected-tag helpers
- authenticate NIP-42 AUTH events without protocol conversion
- rate limit Pocket AUTH attempts and failures directly
- cover Pocket signature protected tag and auth edge cases

Diffstat:
Mcrates/tangle_bench/src/lib.rs | 17+++++++++--------
Mcrates/tangle_crypto/src/lib.rs | 29+++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/lib.rs | 1+
Acrates/tangle_runtime/src/pocket_event_validation.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/relay/auth.rs | 214++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle_runtime/src/relay/core.rs | 42+++++++++++++++++++++++++++++++++++++++++-
Mcrates/tangle_runtime/src/runtime.rs | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
7 files changed, 582 insertions(+), 42 deletions(-)

diff --git a/crates/tangle_bench/src/lib.rs b/crates/tangle_bench/src/lib.rs @@ -9,8 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use tangle_groups::{KIND_GROUP_ADMINS, KIND_GROUP_MEMBERS, KIND_GROUP_METADATA, MemberStatus}; use tangle_protocol::{ - ClientMessage, Event, Filter, RelayMessage, SubscriptionId, UnixTimestamp, event_to_value, - filter_from_value, + Event, Filter, RelayMessage, SubscriptionId, UnixTimestamp, event_to_value, filter_from_value, }; use tangle_runtime::{ config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}, @@ -20,7 +19,9 @@ use tangle_runtime::{ }, runtime::{TangleRuntime, TangleRuntimeHandle}, }; -use tangle_store_pocket::{PocketQueryConfig, PocketStoreConfig, PocketSyncPolicy}; +use tangle_store_pocket::{ + PocketQueryConfig, PocketStoreConfig, PocketSyncPolicy, parse_pocket_filter_json, +}; use tangle_test_support::{ FixtureKey, TANGLE_V2_RELAY_URL, tangle_v2_auth_event, tangle_v2_event, tangle_v2_group_config, tangle_v2_group_create_event, tangle_v2_group_event, tangle_v2_put_user_event, tangle_v2_tag, @@ -1408,12 +1409,12 @@ async fn runtime_count_resource_control_probe() -> Result<CountResourceControlPr for (name, filter) in cases { let sample = Instant::now(); let subscription_id = subscription(name)?; + let pocket_filter = parse_pocket_filter_json(filter.to_string().as_bytes()) + .map_err(|error| error.to_string())?; let replies = handle - .handle_client_message( - ClientMessage::Count { - subscription_id: subscription_id.clone(), - filters: vec![filter_from_value(&filter)?], - }, + .handle_count_pocket( + subscription_id.clone(), + vec![pocket_filter], &mut auth, UnixTimestamp::new(1_714_700_000), ) diff --git a/crates/tangle_crypto/src/lib.rs b/crates/tangle_crypto/src/lib.rs @@ -60,6 +60,35 @@ pub fn verify_event_signature(event: &Event) -> Result<(), String> { .map_err(|_| "event signature verification failed".to_owned()) } +pub fn compute_event_id_hex_from_canonical_json(canonical: &str) -> String { + let digest = Sha256::digest(canonical.as_bytes()); + lower_hex(&digest) +} + +pub fn verify_event_signature_bytes( + canonical: &str, + event_id: &[u8; 32], + pubkey: &[u8; 32], + signature: &[u8; 64], +) -> Result<(), String> { + let expected_id = Sha256::digest(canonical.as_bytes()); + let expected_id_bytes: &[u8] = expected_id.as_ref(); + if expected_id_bytes != event_id { + return Err(format!( + "event id mismatch: expected {}, got {}", + lower_hex(&expected_id), + lower_hex(event_id) + )); + } + let verifying_key = VerifyingKey::from_bytes(pubkey) + .map_err(|_| "event public key is not a valid secp256k1 x-only key".to_owned())?; + let signature = Signature::try_from(signature.as_slice()) + .map_err(|_| "event signature is not a valid schnorr signature".to_owned())?; + verifying_key + .verify(event_id, &signature) + .map_err(|_| "event signature verification failed".to_owned()) +} + pub struct RelaySigner { signing_key: SigningKey, public_key: PublicKeyHex, diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -9,6 +9,7 @@ pub mod logging; pub mod nip11; pub mod ops; pub(crate) mod pocket_conversion; +pub(crate) mod pocket_event_validation; pub mod rate_limits; pub mod relay; pub mod runtime; diff --git a/crates/tangle_runtime/src/pocket_event_validation.rs b/crates/tangle_runtime/src/pocket_event_validation.rs @@ -0,0 +1,198 @@ +#![forbid(unsafe_code)] + +use crate::errors::BaseRelayError; +use std::str; +use tangle_protocol::{EventId, Kind, PublicKeyHex, UnixTimestamp}; +use tangle_store_pocket::PocketEvent; + +pub(crate) fn pocket_event_id(event: &PocketEvent) -> Result<EventId, BaseRelayError> { + EventId::new(&event.id().as_hex_string()).map_err(BaseRelayError::error) +} + +pub(crate) fn pocket_event_pubkey(event: &PocketEvent) -> Result<PublicKeyHex, BaseRelayError> { + PublicKeyHex::new(&event.pubkey().as_hex_string()).map_err(BaseRelayError::error) +} + +pub(crate) fn pocket_event_kind(event: &PocketEvent) -> Result<Kind, BaseRelayError> { + Kind::new(u64::from(event.kind().as_u16())).map_err(BaseRelayError::error) +} + +pub(crate) fn pocket_event_created_at(event: &PocketEvent) -> UnixTimestamp { + UnixTimestamp::new(event.created_at().as_u64()) +} + +pub(crate) fn validate_pocket_event_shape( + event: &PocketEvent, + max_event_tags: usize, + max_content_length: usize, +) -> Result<(), BaseRelayError> { + let tags = event.tags().map_err(|error| { + BaseRelayError::invalid(format!("malformed Pocket event tags: {error}")) + })?; + if tags.count() > max_event_tags { + return Err(BaseRelayError::invalid(format!( + "event tag count exceeds runtime max_event_tags {max_event_tags}" + ))); + } + if event.content().len() > max_content_length { + return Err(BaseRelayError::invalid(format!( + "event content length exceeds runtime max_content_length {max_content_length}" + ))); + } + Ok(()) +} + +pub(crate) fn is_pocket_nip70_protected_event(event: &PocketEvent) -> Result<bool, BaseRelayError> { + let tags = event.tags().map_err(|error| { + BaseRelayError::invalid(format!("malformed Pocket event tags: {error}")) + })?; + for tag in tags.iter() { + if tag + .into_iter() + .next() + .map(|value| value == b"-") + .unwrap_or(false) + { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn verify_pocket_event_signature(event: &PocketEvent) -> Result<(), BaseRelayError> { + let canonical = pocket_canonical_event_json(event)?; + tangle_crypto::verify_event_signature_bytes( + &canonical, + &event.id().into_inner(), + event.pubkey().as_bytes(), + &event.sig().into_inner(), + ) + .map_err(BaseRelayError::invalid) +} + +pub(crate) fn pocket_canonical_event_json(event: &PocketEvent) -> Result<String, BaseRelayError> { + let tags = event + .tags() + .map_err(|error| BaseRelayError::invalid(format!("malformed Pocket event tags: {error}")))? + .iter() + .map(|tag| { + tag.map(|value| { + str::from_utf8(value) + .map(|value| serde_json::Value::String(value.to_owned())) + .map_err(|error| BaseRelayError::invalid(error.to_string())) + }) + .collect::<Result<Vec<_>, _>>() + .map(serde_json::Value::Array) + }) + .collect::<Result<Vec<_>, _>>()?; + let content = str::from_utf8(event.content()) + .map_err(|error| BaseRelayError::invalid(error.to_string()))?; + Ok(serde_json::json!([ + 0, + event.pubkey().as_hex_string(), + event.created_at().as_u64(), + u32::from(event.kind().as_u16()), + tags, + content + ]) + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::{ + is_pocket_nip70_protected_event, pocket_canonical_event_json, pocket_event_id, + pocket_event_pubkey, validate_pocket_event_shape, verify_pocket_event_signature, + }; + use crate::pocket_conversion::tangle_event_to_pocket; + use tangle_protocol::{Event, EventId, SignatureHex, Tag, event_to_value}; + use tangle_store_pocket::parse_pocket_event_json; + use tangle_test_support::{FixtureKey, tangle_v2_event}; + + #[test] + fn pocket_event_validation_verifies_valid_and_invalid_signatures() { + let event = tangle_v2_event(FixtureKey::Member, 1_714_124_433, 1, Vec::new(), "hello") + .expect("event"); + let pocket = tangle_event_to_pocket(&event).expect("pocket"); + + assert_eq!(verify_pocket_event_signature(&pocket), Ok(())); + + let wrong_signature = Event::new( + event.id().clone(), + event.unsigned().clone(), + SignatureHex::new(&"0".repeat(128)).expect("sig"), + ); + let wrong_pocket = tangle_event_to_pocket(&wrong_signature).expect("wrong pocket"); + assert_eq!( + verify_pocket_event_signature(&wrong_pocket) + .expect_err("signature") + .prefixed_message(), + "invalid: event signature verification failed" + ); + + let wrong_id = Event::new( + EventId::new(&"0".repeat(64)).expect("id"), + event.unsigned().clone(), + event.sig().clone(), + ); + let wrong_id_pocket = tangle_event_to_pocket(&wrong_id).expect("wrong id pocket"); + assert!( + verify_pocket_event_signature(&wrong_id_pocket) + .expect_err("id") + .prefixed_message() + .starts_with("invalid: event id mismatch:") + ); + } + + #[test] + fn pocket_event_validation_detects_protected_tags_and_shape_limits() { + let event = tangle_v2_event( + FixtureKey::Member, + 1_714_124_433, + 1, + vec![Tag::from_parts("-", &[]).expect("protected")], + "hello", + ) + .expect("event"); + let pocket = tangle_event_to_pocket(&event).expect("pocket"); + + assert!(is_pocket_nip70_protected_event(&pocket).expect("protected")); + assert_eq!(validate_pocket_event_shape(&pocket, 1, 5), Ok(())); + assert_eq!( + validate_pocket_event_shape(&pocket, 0, 5) + .expect_err("tags") + .prefixed_message(), + "invalid: event tag count exceeds runtime max_event_tags 0" + ); + assert_eq!( + validate_pocket_event_shape(&pocket, 1, 4) + .expect_err("content") + .prefixed_message(), + "invalid: event content length exceeds runtime max_content_length 4" + ); + } + + #[test] + fn pocket_event_validation_preserves_protocol_canonical_json() { + let event = tangle_v2_event( + FixtureKey::Member, + 1_714_124_433, + 1, + vec![Tag::from_parts("t", &["market"]).expect("t")], + "hello", + ) + .expect("event"); + let raw = event_to_value(&event).to_string(); + let pocket = parse_pocket_event_json(raw.as_bytes()).expect("pocket"); + + assert_eq!(pocket_event_id(&pocket).expect("id"), event.id().clone()); + assert_eq!( + pocket_event_pubkey(&pocket).expect("pubkey"), + event.unsigned().pubkey().clone() + ); + assert_eq!( + pocket_canonical_event_json(&pocket).expect("canonical"), + event.unsigned().canonical_json() + ); + } +} diff --git a/crates/tangle_runtime/src/relay/auth.rs b/crates/tangle_runtime/src/relay/auth.rs @@ -1,9 +1,17 @@ #![forbid(unsafe_code)] -use crate::errors::BaseRelayError; +use crate::{ + errors::BaseRelayError, + pocket_event_validation::{ + pocket_event_created_at, pocket_event_kind, pocket_event_pubkey, + verify_pocket_event_signature, + }, +}; use std::collections::BTreeSet; +use std::str; use tangle_crypto::verify_event_signature; use tangle_protocol::{Event, PublicKeyHex, RelayMessage, UnixTimestamp}; +use tangle_store_pocket::PocketEvent; pub fn generate_auth_challenge() -> Result<String, BaseRelayError> { let mut bytes = [0_u8; 32]; @@ -115,6 +123,54 @@ impl BaseAuthState { Ok(pubkey) } + pub(crate) fn authenticate_pocket( + &mut self, + event: &PocketEvent, + now: UnixTimestamp, + ) -> Result<PublicKeyHex, BaseRelayError> { + verify_pocket_event_signature(event)?; + let auth = parse_base_relay_pocket_auth_event(event) + .map_err(BaseRelayError::invalid)? + .ok_or_else(|| BaseRelayError::invalid("AUTH message must contain kind 22242"))?; + let challenge = self + .challenge + .as_ref() + .ok_or_else(|| BaseRelayError::auth_required("auth challenge is missing"))?; + if auth.relay() != self.relay_url { + return Err(BaseRelayError::auth_required( + "auth relay does not match canonical relay URL", + )); + } + if auth.challenge() != challenge.value { + return Err(BaseRelayError::auth_required( + "auth challenge does not match", + )); + } + if now.as_u64() + > challenge + .issued_at + .as_u64() + .saturating_add(self.challenge_ttl_seconds) + { + return Err(BaseRelayError::auth_required("auth challenge expired")); + } + if auth + .created_at() + .as_u64() + .saturating_add(self.created_at_skew_seconds) + < now.as_u64() + || auth.created_at().as_u64() + > now.as_u64().saturating_add(self.created_at_skew_seconds) + { + return Err(BaseRelayError::auth_required( + "auth event created_at is outside configured skew", + )); + } + let pubkey = auth.pubkey().clone(); + self.authenticated_pubkeys.insert(pubkey.clone()); + Ok(pubkey) + } + pub fn authenticated_pubkeys(&self) -> &BTreeSet<PublicKeyHex> { &self.authenticated_pubkeys } @@ -172,6 +228,32 @@ fn parse_base_relay_auth_event(event: &Event) -> Result<Option<BaseRelayAuthEven })) } +fn parse_base_relay_pocket_auth_event( + event: &PocketEvent, +) -> Result<Option<BaseRelayAuthEvent>, String> { + if pocket_event_kind(event) + .map_err(|error| error.message().to_owned())? + .as_u32() + != 22_242 + { + return Ok(None); + } + let relay = required_single_pocket_tag_value(event, "relay")?; + let challenge = required_single_pocket_tag_value(event, "challenge")?; + if relay.is_empty() { + return Err("relay auth relay tag must not be empty".to_owned()); + } + if challenge.is_empty() { + return Err("relay auth challenge tag must not be empty".to_owned()); + } + Ok(Some(BaseRelayAuthEvent { + pubkey: pocket_event_pubkey(event).map_err(|error| error.message().to_owned())?, + created_at: pocket_event_created_at(event), + relay, + challenge, + })) +} + fn required_single_tag_value(event: &Event, name: &str) -> Result<String, String> { let mut matches = event .unsigned() @@ -190,6 +272,31 @@ fn required_single_tag_value(event: &Event, name: &str) -> Result<String, String .ok_or_else(|| format!("tag `{name}` must include a value")) } +fn required_single_pocket_tag_value(event: &PocketEvent, name: &str) -> Result<String, String> { + let tags = event + .tags() + .map_err(|error| format!("malformed Pocket event tags: {error}"))?; + let mut matched = None; + for mut tag in tags.iter() { + let Some(tag_name) = tag.next() else { + continue; + }; + let tag_name = str::from_utf8(tag_name).map_err(|error| error.to_string())?; + if tag_name != name { + continue; + } + if matched.is_some() { + return Err(format!("tag `{name}` must not be repeated")); + } + let value = tag + .next() + .ok_or_else(|| format!("tag `{name}` must include a value")) + .and_then(|value| str::from_utf8(value).map_err(|error| error.to_string()))?; + matched = Some(value.to_owned()); + } + matched.ok_or_else(|| format!("tag `{name}` is required")) +} + fn lower_hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(bytes.len() * 2); @@ -203,6 +310,7 @@ fn lower_hex(bytes: &[u8]) -> String { #[cfg(test)] mod tests { use super::{BaseAuthState, generate_auth_challenge}; + use crate::pocket_conversion::tangle_event_to_pocket; use tangle_crypto::RelaySigner; use tangle_protocol::{Event, EventId, Kind, RelayMessage, Tag, UnixTimestamp, UnsignedEvent}; @@ -479,6 +587,110 @@ mod tests { } #[test] + fn auth_state_authenticates_pocket_events_without_protocol_conversion() { + let mut auth = BaseAuthState::new("wss://relay.radroots.test", 20, 10).expect("auth state"); + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"); + let owner = signed_auth_event(7, "challenge-a", 105); + let admin = signed_auth_event(8, "challenge-a", 106); + let owner_pocket = tangle_event_to_pocket(&owner).expect("owner pocket"); + let admin_pocket = tangle_event_to_pocket(&admin).expect("admin pocket"); + + let owner_pubkey = auth + .authenticate_pocket(&owner_pocket, UnixTimestamp::new(105)) + .expect("owner"); + let admin_pubkey = auth + .authenticate_pocket(&admin_pocket, UnixTimestamp::new(106)) + .expect("admin"); + + assert_ne!(owner_pubkey, admin_pubkey); + assert!(auth.authenticated_pubkeys().contains(&owner_pubkey)); + assert!(auth.authenticated_pubkeys().contains(&admin_pubkey)); + assert_eq!(auth.authenticated_pubkeys().len(), 2); + } + + #[test] + fn auth_state_rejects_invalid_pocket_auth_events_with_existing_semantics() { + let mut auth = BaseAuthState::new("wss://relay.radroots.test", 20, 10).expect("auth state"); + auth.issue_challenge("challenge-a", UnixTimestamp::new(100)) + .expect("challenge"); + let owner = signed_auth_event(7, "challenge-a", 105); + let admin = signed_auth_event(8, "challenge-a", 106); + + let wrong_id = tangle_event_to_pocket(&Event::new( + EventId::new(&"0".repeat(EventId::HEX_LENGTH)).expect("id"), + owner.unsigned().clone(), + owner.sig().clone(), + )) + .expect("wrong id pocket"); + assert!( + auth.authenticate_pocket(&wrong_id, UnixTimestamp::new(105)) + .expect_err("id") + .prefixed_message() + .starts_with("invalid: event id mismatch:") + ); + + let wrong_signature = tangle_event_to_pocket(&Event::new( + owner.id().clone(), + owner.unsigned().clone(), + admin.sig().clone(), + )) + .expect("wrong signature pocket"); + assert_eq!( + auth.authenticate_pocket(&wrong_signature, UnixTimestamp::new(105)) + .expect_err("signature") + .prefixed_message(), + "invalid: event signature verification failed" + ); + + for (event, now, expected) in [ + ( + signed_event(9, 1, auth_tags("challenge-a"), 105), + 105, + "invalid: AUTH message must contain kind 22242", + ), + ( + signed_event( + 9, + 22_242, + auth_tags_for("wss://other.radroots.test", "challenge-a"), + 105, + ), + 105, + "auth-required: auth relay does not match canonical relay URL", + ), + ( + signed_auth_event(9, "wrong", 105), + 105, + "auth-required: auth challenge does not match", + ), + ( + signed_auth_event(9, "challenge-a", 121), + 121, + "auth-required: auth challenge expired", + ), + ( + signed_auth_event(9, "challenge-a", 94), + 105, + "auth-required: auth event created_at is outside configured skew", + ), + ( + signed_auth_event(9, "challenge-a", 116), + 105, + "auth-required: auth event created_at is outside configured skew", + ), + ] { + let pocket = tangle_event_to_pocket(&event).expect("pocket"); + assert_eq!( + auth.authenticate_pocket(&pocket, UnixTimestamp::new(now)) + .expect_err("invalid") + .prefixed_message(), + expected + ); + } + } + + #[test] fn generated_auth_challenge_is_lowercase_hex_nonce() { let first = generate_auth_challenge().expect("first"); let second = generate_auth_challenge().expect("second"); diff --git a/crates/tangle_runtime/src/relay/core.rs b/crates/tangle_runtime/src/relay/core.rs @@ -8,6 +8,9 @@ use crate::pocket_conversion::{ pocket_event_id, pocket_event_to_tangle, pocket_pubkey, tangle_event_to_pocket, tangle_filter_to_pocket, }; +use crate::pocket_event_validation::{ + pocket_event_id as pocket_runtime_event_id, validate_pocket_event_shape, +}; use crate::relay::{ auth::BaseAuthState, live::{CloseResult, LiveSubscriptionSet}, @@ -23,7 +26,8 @@ use tangle_groups::{ }; use tangle_protocol::{ClientMessage, Event, Filter, RelayMessage, SubscriptionId, UnixTimestamp}; use tangle_store_pocket::{ - PocketHll8, PocketQueryConfig, PocketScreenResult, PocketStoreConfig, PocketStoreHandle, + PocketEvent, PocketHll8, PocketQueryConfig, PocketScreenResult, PocketStoreConfig, + PocketStoreHandle, }; pub(crate) const NEGENTROPY_DISABLED_MESSAGE: &str = "blocked: Negentropy sync is disabled"; @@ -425,6 +429,10 @@ impl BaseRelayLimits { Ok(()) } + pub(crate) fn validate_pocket_event(&self, event: &PocketEvent) -> Result<(), BaseRelayError> { + validate_pocket_event_shape(event, self.max_event_tags, self.max_content_length) + } + pub fn validate_subscription_id( &self, subscription_id: &SubscriptionId, @@ -718,6 +726,38 @@ impl BaseRelay { }) } + pub(crate) fn handle_pocket_auth_with_limits( + limits: BaseRelayLimits, + event: &PocketEvent, + auth: &mut BaseAuthState, + now: UnixTimestamp, + ) -> Vec<RelayMessage> { + let event_id = + pocket_runtime_event_id(event).expect("Pocket event id is valid hex by construction"); + if let Err(error) = limits.validate_pocket_event(event) { + return vec![RelayMessage::Ok { + event_id, + accepted: false, + message: error.prefixed_message(), + }]; + } + auth.authenticate_pocket(event, now) + .map(|_| { + vec![RelayMessage::Ok { + event_id: event_id.clone(), + accepted: true, + message: String::new(), + }] + }) + .unwrap_or_else(|error| { + vec![RelayMessage::Ok { + event_id, + accepted: false, + message: error.prefixed_message(), + }] + }) + } + pub fn handle_event(&self, event: Event) -> Result<RelayMessage, BaseRelayError> { self.handle_event_with_group_auth(event, &GroupAuthContext::unauthenticated()) .map(BaseRelayEventWrite::into_message) diff --git a/crates/tangle_runtime/src/runtime.rs b/crates/tangle_runtime/src/runtime.rs @@ -9,6 +9,9 @@ use crate::{ logging, ops::{BaseRelayReadinessHandle, BaseRelayReadinessState}, pocket_conversion::{pocket_event_to_tangle, pocket_filter_to_tangle}, + pocket_event_validation::{ + is_pocket_nip70_protected_event, pocket_event_id, pocket_event_pubkey, + }, rate_limits::{ TangleQueryRateLimitConfig, TangleRateLimitDecision, TangleRateLimitKey, TangleRateLimitQueryClass, TangleRateLimitRule, TangleRateLimitScope, TangleRateLimiter, @@ -41,7 +44,7 @@ use tangle_groups::{ use tangle_protocol::{ Event, EventId, Filter, Kind, PublicKeyHex, RelayMessage, SubscriptionId, UnixTimestamp, }; -use tangle_store_pocket::PocketStoreHandle; +use tangle_store_pocket::{PocketEvent, PocketOwnedFilter, PocketStoreHandle}; use tokio::sync::watch; pub struct TangleRuntime { @@ -407,57 +410,54 @@ impl TangleRuntimeShared { }) } - fn rate_limit_auth_attempt( + fn rate_limit_auth_attempt_pocket( &self, - event: &Event, + event: &PocketEvent, context: TangleClientRateLimitContext, now: UnixTimestamp, - ) -> Option<RelayMessage> { + ) -> Result<Option<RelayMessage>, BaseRelayError> { let rules = self.config.rate_limits().auth(); if let Some(peer_ip) = context.peer_ip - && let Some(message) = self.rate_limit_ok( + && let Some(message) = self.rate_limit_ok_pocket( event, TangleRateLimitKey::ip(TangleRateLimitScope::Auth, peer_ip), rules.per_ip(), "auth ip", now, - ) + )? { - return Some(message); + return Ok(Some(message)); } - self.rate_limit_ok( + self.rate_limit_ok_pocket( event, - TangleRateLimitKey::pubkey( - TangleRateLimitScope::Auth, - event.unsigned().pubkey().clone(), - ), + TangleRateLimitKey::pubkey(TangleRateLimitScope::Auth, pocket_event_pubkey(event)?), rules.per_pubkey(), "auth pubkey", now, ) } - fn rate_limit_auth_failure( + fn rate_limit_auth_failure_pocket( &self, - event: &Event, + event: &PocketEvent, context: TangleClientRateLimitContext, now: UnixTimestamp, - ) -> Option<RelayMessage> { + ) -> Result<Option<RelayMessage>, BaseRelayError> { let rules = self.config.rate_limits().auth(); if let Some(peer_ip) = context.peer_ip - && let Some(message) = self.rate_limit_ok( + && let Some(message) = self.rate_limit_ok_pocket( event, TangleRateLimitKey::auth_failure(Some(peer_ip), None), rules.failures_per_ip(), "auth failure ip", now, - ) + )? { - return Some(message); + return Ok(Some(message)); } - self.rate_limit_ok( + self.rate_limit_ok_pocket( event, - TangleRateLimitKey::auth_failure(None, Some(event.unsigned().pubkey().clone())), + TangleRateLimitKey::auth_failure(None, Some(pocket_event_pubkey(event)?)), rules.failures(), "auth failure", now, @@ -789,6 +789,31 @@ impl TangleRuntimeShared { } } } + + fn rate_limit_ok_pocket( + &self, + event: &PocketEvent, + key: TangleRateLimitKey, + rule: TangleRateLimitRule, + label: &'static str, + now: UnixTimestamp, + ) -> Result<Option<RelayMessage>, BaseRelayError> { + Ok(match self.rate_limiter.record(key, rule, now) { + TangleRateLimitDecision::Allowed { .. } => None, + TangleRateLimitDecision::Rejected { reset_at } => { + self.metrics.record_rate_limit_rejection(); + logging::log_rate_limit_rejected(label, "event", reset_at); + Some(RelayMessage::Ok { + event_id: pocket_event_id(event)?, + accepted: false, + message: BaseRelayError::rate_limited(format!( + "{label} rate limit exceeded until {reset_at}" + )) + .prefixed_message(), + }) + } + }) + } } #[derive(Clone)] @@ -815,6 +840,26 @@ impl TangleRuntimeHandle { self.inner.config.auth_state() } + pub async fn handle_count_pocket( + &self, + subscription_id: SubscriptionId, + filters: Vec<PocketOwnedFilter>, + auth: &mut BaseAuthState, + now: UnixTimestamp, + ) -> Result<Vec<RelayMessage>, BaseRelayError> { + self.handle_client_message_with_rate_limit_context( + RuntimeClientMessage::Count { + subscription_id, + filters, + search_present: false, + }, + auth, + TangleClientRateLimitContext::default(), + now, + ) + .await + } + #[cfg(test)] pub(crate) async fn handle_client_message( &self, @@ -876,6 +921,14 @@ impl TangleRuntimeHandle { match message { RuntimeClientMessage::Event(pocket_event) => { let event = pocket_event_to_tangle(&pocket_event)?; + debug_assert_eq!( + is_pocket_nip70_protected_event(&pocket_event)?, + event + .unsigned() + .tags() + .iter() + .any(|tag| tag.name().as_str() == "-") + ); let started_at = Instant::now(); let event_id = event.id().clone(); let is_group_event = self.inner.is_group_event(&event); @@ -1023,36 +1076,42 @@ impl TangleRuntimeHandle { Ok(vec![report.into_message()]) } RuntimeClientMessage::Auth(pocket_event) => { - let event = pocket_event_to_tangle(&pocket_event)?; - if let Err(error) = self.inner.limits.base_relay_limits().validate_event(&event) { + let event_id = pocket_event_id(&pocket_event)?; + if let Err(error) = self + .inner + .limits + .base_relay_limits() + .validate_pocket_event(&pocket_event) + { self.inner.metrics.record_auth_failure(); return Ok(vec![RelayMessage::Ok { - event_id: event.id().clone(), + event_id, accepted: false, message: error.prefixed_message(), }]); } - if let Some(message) = - self.inner - .rate_limit_auth_attempt(&event, rate_limit_context, now) - { + if let Some(message) = self.inner.rate_limit_auth_attempt_pocket( + &pocket_event, + rate_limit_context, + now, + )? { self.inner.metrics.record_auth_failure(); return Ok(vec![message]); } - let event_for_failure = event.clone(); - let replies = BaseRelay::handle_auth_with_limits( + let event_for_failure = pocket_event.clone(); + let replies = BaseRelay::handle_pocket_auth_with_limits( self.inner.limits.base_relay_limits(), - event, + &pocket_event, auth, now, ); if auth_response_failed(&replies) { self.inner.metrics.record_auth_failure(); - if let Some(message) = self.inner.rate_limit_auth_failure( + if let Some(message) = self.inner.rate_limit_auth_failure_pocket( &event_for_failure, rate_limit_context, now, - ) { + )? { return Ok(vec![message]); } } else {