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> {
91 use crate::community::Epoch;
92 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()];
93 let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
94 let roots = super::service::channel_rekey_addressing_roots(c.community_root, &cid_hex);
95 for ch in &c.channels {
96 if ch.private {
97 for root in &roots {
98 out.push(super::derive::channel_rekey_group_key(root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).keys().clone());
99 }
100 }
101 }
102 out
103}
104
105fn remember_challenge(relay: &RelayUrl, challenge: &str) -> bool {
111 CHALLENGES
112 .lock()
113 .unwrap_or_else(|e| e.into_inner())
114 .insert(relay.clone(), challenge.to_string())
115 .as_deref()
116 != Some(challenge)
117}
118
119fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
121 CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
122}
123
124static RESUB_AT: LazyLock<Mutex<HashMap<RelayUrl, std::time::Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
127const RESUB_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
128
129fn resub_cooldown_elapsed(relay: &RelayUrl) -> bool {
132 let mut map = RESUB_AT.lock().unwrap_or_else(|e| e.into_inner());
133 let now = std::time::Instant::now();
134 match map.get(relay) {
135 Some(at) if now.duration_since(*at) < RESUB_COOLDOWN => false,
136 _ => {
137 map.insert(relay.clone(), now);
138 true
139 }
140 }
141}
142
143pub fn clear() {
146 REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).clear();
147 CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).clear();
148 RESUB_AT.lock().unwrap_or_else(|e| e.into_inner()).clear();
149 RESPONDER_RUNNING.store(false, Ordering::SeqCst);
150}
151
152pub fn is_empty() -> bool {
154 REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
155}
156
157fn sign_all(challenge: &str, relay: &RelayUrl) -> Vec<nostr_sdk::Event> {
160 let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
161 reg.values()
162 .filter_map(|keys| EventBuilder::auth(challenge, relay.clone()).sign_with_keys(keys).ok())
163 .collect()
164}
165
166async fn authenticate_streams(client: &Client, relay: &RelayUrl, challenge: &str) -> usize {
170 let events = sign_all(challenge, relay);
171 let Ok(r) = client.pool().relay(relay.clone()).await else {
172 return 0;
173 };
174 let mut sent = 0;
175 for ev in events {
176 if r.send_msg(ClientMessage::auth(ev)).is_ok() {
177 sent += 1;
178 }
179 }
180 sent
181}
182
183pub fn ensure_responder(client: &Client) {
193 if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
194 return; }
196 let client = client.clone();
197 let session = crate::state::SessionGuard::capture();
198 tokio::spawn(async move {
199 let mut notifications = client.notifications();
200 while let Ok(n) = notifications.recv().await {
201 if !session.is_valid() {
202 break;
203 }
204 if let RelayPoolNotification::Message { relay_url, message } = n {
205 if let nostr_sdk::RelayMessage::Auth { challenge } = message {
206 let challenge = challenge.into_owned();
207 let fresh_connection = remember_challenge(&relay_url, &challenge);
208 if !is_empty() {
209 authenticate_streams(&client, &relay_url, &challenge).await;
210 if fresh_connection && resub_cooldown_elapsed(&relay_url) {
216 super::realtime::resubscribe_relay(&client, &relay_url).await;
217 }
218 }
219 }
220 }
221 }
222 RESPONDER_RUNNING.store(false, Ordering::SeqCst);
223 });
224}
225
226pub fn prime(community: &CommunityV2) {
230 register_community(community);
231 if let Some(client) = crate::state::nostr_client() {
232 ensure_responder(&client);
233 }
234}
235
236pub async fn prime_auth(client: &Client, relays: &[String]) {
249 if is_empty() || relays.is_empty() {
250 return;
251 }
252 ensure_responder(client);
253 let urls: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
254 if urls.is_empty() {
255 return;
256 }
257 for url in &urls {
258 if let Some(challenge) = remembered_challenge(url) {
259 authenticate_streams(client, url, &challenge).await;
260 }
261 }
262 let authors: Vec<nostr_sdk::PublicKey> = {
263 let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
264 reg.keys().filter_map(|pk| nostr_sdk::PublicKey::from_slice(pk).ok()).collect()
265 };
266 if authors.is_empty() {
267 return;
268 }
269 let filter = nostr_sdk::Filter::new()
270 .kind(nostr_sdk::Kind::Custom(super::stream::KIND_WRAP))
271 .authors(authors)
272 .limit(1);
273 let _ = tokio::time::timeout(std::time::Duration::from_secs(8), client.fetch_events_from(urls, filter, std::time::Duration::from_secs(6))).await;
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::community::v2::control::{genesis, CommunityMetadata};
281 use crate::community::v2::community::{ChannelV2, CommunityV2};
282 use crate::community::{ChannelId, Epoch};
283 use nostr_sdk::prelude::Keys;
284
285 fn community_with_channel_mix() -> CommunityV2 {
288 let owner = Keys::generate();
289 let g = genesis(&owner, CommunityMetadata { name: "auth-test".into(), ..Default::default() }, 1_000).unwrap();
290 let mut c = CommunityV2::from_genesis(&g, "auth-test", None, vec!["wss://gated.example".into()], 0);
291 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() });
292 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() });
293 c
294 }
295
296 fn registered(pk: &nostr_sdk::prelude::PublicKey) -> bool {
297 REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).contains_key(&pk.to_bytes())
298 }
299
300 #[test]
304 fn register_community_covers_planes_and_skips_keyless() {
305 let c = community_with_channel_mix();
306 let added = register_community(&c);
307 assert!(added >= 6, "control+guestbook+dissolved+public chat+keyed chat+rekeys = at least 6 new keys, got {added}");
308
309 use super::super::derive;
310 assert!(registered(&derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
311 assert!(registered(&derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
312 assert!(registered(&derive::dissolved_group_key(c.id()).pk()));
313 let public = &c.channels[0];
315 let (secret, epoch) = c.channel_secret(public);
316 assert!(registered(&derive::channel_group_key(&secret, &public.id, epoch).pk()));
317 let keyed = &c.channels[1];
319 assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
320 let keyless = &c.channels[2];
323 assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
324 assert!(registered(&derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0 + 1)).pk()));
326 assert!(registered(&derive::channel_rekey_group_key(&c.community_root, &keyed.id, Epoch(4)).pk()));
327
328 assert_eq!(register_community(&c), 0);
330 }
331
332 #[test]
336 fn late_registered_keys_sign_against_the_remembered_challenge() {
337 let relay = RelayUrl::parse("wss://late-keys.example").unwrap();
338 let early = Keys::generate();
339 register([early.clone()]);
340 assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
342 let late = Keys::generate();
344 register([late.clone()]);
345 let challenge = remembered_challenge(&relay).expect("challenge was remembered");
347 let events = sign_all(&challenge, &relay);
348 let signers: Vec<_> = events.iter().map(|e| e.pubkey).collect();
349 assert!(signers.contains(&early.public_key()));
350 assert!(signers.contains(&late.public_key()));
351 }
352
353 #[test]
356 fn signed_auth_events_are_nip42_shaped() {
357 let relay = RelayUrl::parse("wss://shape.example").unwrap();
358 let key = Keys::generate();
359 register([key.clone()]);
360 let events = sign_all("shape-challenge", &relay);
361 let ev = events.iter().find(|e| e.pubkey == key.public_key()).expect("signed by the registered key");
362 assert_eq!(ev.kind, nostr_sdk::Kind::Authentication);
363 assert!(ev.verify().is_ok(), "signature + id must verify");
364 let tag_values: Vec<String> = ev.tags.iter().filter_map(|t| t.content().map(String::from)).collect();
365 assert!(tag_values.iter().any(|v| v == "shape-challenge"), "carries the challenge tag");
366 }
367
368 #[test]
371 fn challenge_value_change_detects_a_new_connection() {
372 let relay = RelayUrl::parse("wss://conn-detect.example").unwrap();
373 assert!(remember_challenge(&relay, "c1"), "first sighting");
374 assert!(!remember_challenge(&relay, "c1"), "same value = same connection");
375 assert!(remember_challenge(&relay, "c2"), "new value = reconnected");
376 assert_eq!(remembered_challenge(&relay).as_deref(), Some("c2"), "memory holds the newest");
377 }
378
379 #[test]
382 fn resubscribe_cooldown_bounds_the_reaction() {
383 let relay = RelayUrl::parse("wss://cooldown.example").unwrap();
384 assert!(resub_cooldown_elapsed(&relay), "first trigger passes");
385 assert!(!resub_cooldown_elapsed(&relay), "immediate repeat is suppressed");
386 let other = RelayUrl::parse("wss://cooldown-other.example").unwrap();
387 assert!(resub_cooldown_elapsed(&other), "cooldown is per-relay");
388 }
389}