vector_core/community/v2/
streamauth.rs1use std::collections::HashMap;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::{LazyLock, Mutex};
26
27use nostr_sdk::prelude::{Client, ClientMessage, EventBuilder, Keys, RelayPoolNotification, RelayUrl};
28
29use super::community::CommunityV2;
30
31static REGISTRY: LazyLock<Mutex<HashMap<[u8; 32], Keys>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
33static RESPONDER_RUNNING: AtomicBool = AtomicBool::new(false);
35static CHALLENGES: LazyLock<Mutex<HashMap<RelayUrl, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
42
43pub fn register(keys: impl IntoIterator<Item = Keys>) -> usize {
45 let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
46 let mut added = 0;
47 for k in keys {
48 if reg.insert(k.public_key().to_bytes(), k).is_none() {
49 added += 1;
50 }
51 }
52 added
53}
54
55pub fn register_community(c: &CommunityV2) -> usize {
60 let mut keys: Vec<Keys> = vec![
61 super::derive::control_group_key(&c.community_root, c.id(), c.root_epoch).keys().clone(),
62 super::derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).keys().clone(),
63 super::derive::dissolved_group_key(c.id()).keys().clone(),
64 ];
65 for ch in &c.channels {
66 if ch.private && ch.key.is_none() {
67 continue;
68 }
69 let (secret, epoch) = c.channel_secret(ch);
70 keys.push(super::derive::channel_group_key(&secret, &ch.id, epoch).keys().clone());
71 }
72 for pk_keys in rekey_plane_keys(c) {
74 keys.push(pk_keys);
75 }
76 register(keys)
77}
78
79fn rekey_plane_keys(c: &CommunityV2) -> Vec<Keys> {
83 use crate::community::Epoch;
84 let mut out = vec![super::derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0.saturating_add(1))).keys().clone()];
85 for ch in &c.channels {
86 if ch.private {
87 out.push(super::derive::channel_rekey_group_key(&c.community_root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).keys().clone());
88 }
89 }
90 out
91}
92
93fn remember_challenge(relay: &RelayUrl, challenge: &str) -> bool {
99 CHALLENGES
100 .lock()
101 .unwrap_or_else(|e| e.into_inner())
102 .insert(relay.clone(), challenge.to_string())
103 .as_deref()
104 != Some(challenge)
105}
106
107fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
109 CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
110}
111
112static RESUB_AT: LazyLock<Mutex<HashMap<RelayUrl, std::time::Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
115const RESUB_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
116
117fn resub_cooldown_elapsed(relay: &RelayUrl) -> bool {
120 let mut map = RESUB_AT.lock().unwrap_or_else(|e| e.into_inner());
121 let now = std::time::Instant::now();
122 match map.get(relay) {
123 Some(at) if now.duration_since(*at) < RESUB_COOLDOWN => false,
124 _ => {
125 map.insert(relay.clone(), now);
126 true
127 }
128 }
129}
130
131pub fn clear() {
134 REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).clear();
135 CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).clear();
136 RESUB_AT.lock().unwrap_or_else(|e| e.into_inner()).clear();
137 RESPONDER_RUNNING.store(false, Ordering::SeqCst);
138}
139
140pub fn is_empty() -> bool {
142 REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
143}
144
145fn sign_all(challenge: &str, relay: &RelayUrl) -> Vec<nostr_sdk::Event> {
148 let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
149 reg.values()
150 .filter_map(|keys| EventBuilder::auth(challenge, relay.clone()).sign_with_keys(keys).ok())
151 .collect()
152}
153
154async fn authenticate_streams(client: &Client, relay: &RelayUrl, challenge: &str) -> usize {
158 let events = sign_all(challenge, relay);
159 let Ok(r) = client.pool().relay(relay.clone()).await else {
160 return 0;
161 };
162 let mut sent = 0;
163 for ev in events {
164 if r.send_msg(ClientMessage::auth(ev)).is_ok() {
165 sent += 1;
166 }
167 }
168 sent
169}
170
171pub fn ensure_responder(client: &Client) {
181 if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
182 return; }
184 let client = client.clone();
185 let session = crate::state::SessionGuard::capture();
186 tokio::spawn(async move {
187 let mut notifications = client.notifications();
188 while let Ok(n) = notifications.recv().await {
189 if !session.is_valid() {
190 break;
191 }
192 if let RelayPoolNotification::Message { relay_url, message } = n {
193 if let nostr_sdk::RelayMessage::Auth { challenge } = message {
194 let challenge = challenge.into_owned();
195 let fresh_connection = remember_challenge(&relay_url, &challenge);
196 if !is_empty() {
197 authenticate_streams(&client, &relay_url, &challenge).await;
198 if fresh_connection && resub_cooldown_elapsed(&relay_url) {
204 super::realtime::resubscribe_relay(&client, &relay_url).await;
205 }
206 }
207 }
208 }
209 }
210 RESPONDER_RUNNING.store(false, Ordering::SeqCst);
211 });
212}
213
214pub fn prime(community: &CommunityV2) {
218 register_community(community);
219 if let Some(client) = crate::state::nostr_client() {
220 ensure_responder(&client);
221 }
222}
223
224pub async fn prime_auth(client: &Client, relays: &[String]) {
237 if is_empty() || relays.is_empty() {
238 return;
239 }
240 ensure_responder(client);
241 let urls: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
242 if urls.is_empty() {
243 return;
244 }
245 for url in &urls {
246 if let Some(challenge) = remembered_challenge(url) {
247 authenticate_streams(client, url, &challenge).await;
248 }
249 }
250 let authors: Vec<nostr_sdk::PublicKey> = {
251 let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
252 reg.keys().filter_map(|pk| nostr_sdk::PublicKey::from_slice(pk).ok()).collect()
253 };
254 if authors.is_empty() {
255 return;
256 }
257 let filter = nostr_sdk::Filter::new()
258 .kind(nostr_sdk::Kind::Custom(super::stream::KIND_WRAP))
259 .authors(authors)
260 .limit(1);
261 let _ = tokio::time::timeout(std::time::Duration::from_secs(8), client.fetch_events_from(urls, filter, std::time::Duration::from_secs(6))).await;
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::community::v2::control::{genesis, CommunityMetadata};
269 use crate::community::v2::community::{ChannelV2, CommunityV2};
270 use crate::community::{ChannelId, Epoch};
271 use nostr_sdk::prelude::Keys;
272
273 fn community_with_channel_mix() -> CommunityV2 {
276 let owner = Keys::generate();
277 let g = genesis(&owner, CommunityMetadata { name: "auth-test".into(), ..Default::default() }, 1_000).unwrap();
278 let mut c = CommunityV2::from_genesis(&g, "auth-test", None, vec!["wss://gated.example".into()], 0);
279 c.channels.push(ChannelV2 { id: ChannelId([2u8; 32]), name: "keyed-private".into(), private: true, key: Some([7u8; 32]), epoch: Epoch(3), voice: None, meta_custom: None, meta_extra: Default::default() });
280 c.channels.push(ChannelV2 { id: ChannelId([3u8; 32]), name: "keyless-private".into(), private: true, key: None, epoch: Epoch(0), voice: None, meta_custom: None, meta_extra: Default::default() });
281 c
282 }
283
284 fn registered(pk: &nostr_sdk::prelude::PublicKey) -> bool {
285 REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).contains_key(&pk.to_bytes())
286 }
287
288 #[test]
292 fn register_community_covers_planes_and_skips_keyless() {
293 let c = community_with_channel_mix();
294 let added = register_community(&c);
295 assert!(added >= 6, "control+guestbook+dissolved+public chat+keyed chat+rekeys = at least 6 new keys, got {added}");
296
297 use super::super::derive;
298 assert!(registered(&derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
299 assert!(registered(&derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
300 assert!(registered(&derive::dissolved_group_key(c.id()).pk()));
301 let public = &c.channels[0];
303 let (secret, epoch) = c.channel_secret(public);
304 assert!(registered(&derive::channel_group_key(&secret, &public.id, epoch).pk()));
305 let keyed = &c.channels[1];
307 assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
308 let keyless = &c.channels[2];
311 assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
312 assert!(registered(&derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0 + 1)).pk()));
314 assert!(registered(&derive::channel_rekey_group_key(&c.community_root, &keyed.id, Epoch(4)).pk()));
315
316 assert_eq!(register_community(&c), 0);
318 }
319
320 #[test]
324 fn late_registered_keys_sign_against_the_remembered_challenge() {
325 let relay = RelayUrl::parse("wss://late-keys.example").unwrap();
326 let early = Keys::generate();
327 register([early.clone()]);
328 assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
330 let late = Keys::generate();
332 register([late.clone()]);
333 let challenge = remembered_challenge(&relay).expect("challenge was remembered");
335 let events = sign_all(&challenge, &relay);
336 let signers: Vec<_> = events.iter().map(|e| e.pubkey).collect();
337 assert!(signers.contains(&early.public_key()));
338 assert!(signers.contains(&late.public_key()));
339 }
340
341 #[test]
344 fn signed_auth_events_are_nip42_shaped() {
345 let relay = RelayUrl::parse("wss://shape.example").unwrap();
346 let key = Keys::generate();
347 register([key.clone()]);
348 let events = sign_all("shape-challenge", &relay);
349 let ev = events.iter().find(|e| e.pubkey == key.public_key()).expect("signed by the registered key");
350 assert_eq!(ev.kind, nostr_sdk::Kind::Authentication);
351 assert!(ev.verify().is_ok(), "signature + id must verify");
352 let tag_values: Vec<String> = ev.tags.iter().filter_map(|t| t.content().map(String::from)).collect();
353 assert!(tag_values.iter().any(|v| v == "shape-challenge"), "carries the challenge tag");
354 }
355
356 #[test]
359 fn challenge_value_change_detects_a_new_connection() {
360 let relay = RelayUrl::parse("wss://conn-detect.example").unwrap();
361 assert!(remember_challenge(&relay, "c1"), "first sighting");
362 assert!(!remember_challenge(&relay, "c1"), "same value = same connection");
363 assert!(remember_challenge(&relay, "c2"), "new value = reconnected");
364 assert_eq!(remembered_challenge(&relay).as_deref(), Some("c2"), "memory holds the newest");
365 }
366
367 #[test]
370 fn resubscribe_cooldown_bounds_the_reaction() {
371 let relay = RelayUrl::parse("wss://cooldown.example").unwrap();
372 assert!(resub_cooldown_elapsed(&relay), "first trigger passes");
373 assert!(!resub_cooldown_elapsed(&relay), "immediate repeat is suppressed");
374 let other = RelayUrl::parse("wss://cooldown-other.example").unwrap();
375 assert!(resub_cooldown_elapsed(&other), "cooldown is per-relay");
376 }
377}