use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};
use nostr_sdk::prelude::{Client, ClientMessage, EventBuilder, Keys, RelayPoolNotification, RelayUrl};
use super::community::CommunityV2;
static REGISTRY: LazyLock<Mutex<HashMap<[u8; 32], Keys>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
static RESPONDER_RUNNING: AtomicBool = AtomicBool::new(false);
static CHALLENGES: LazyLock<Mutex<HashMap<RelayUrl, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn register(keys: impl IntoIterator<Item = Keys>) -> usize {
let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
let mut added = 0;
for k in keys {
if reg.insert(k.public_key().to_bytes(), k).is_none() {
added += 1;
}
}
added
}
pub fn register_community(c: &CommunityV2) -> usize {
let mut keys: Vec<Keys> = vec![
super::derive::control_group_key(&c.community_root, c.id(), c.root_epoch).keys().clone(),
super::derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).keys().clone(),
super::derive::dissolved_group_key(c.id()).keys().clone(),
];
for ch in &c.channels {
if ch.private && ch.key.is_none() {
continue;
}
let (secret, epoch) = c.channel_secret(ch);
keys.push(super::derive::channel_group_key(&secret, &ch.id, epoch).keys().clone());
}
for pk_keys in rekey_plane_keys(c) {
keys.push(pk_keys);
}
register(keys)
}
fn rekey_plane_keys(c: &CommunityV2) -> Vec<Keys> {
use crate::community::Epoch;
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()];
for ch in &c.channels {
if ch.private {
out.push(super::derive::channel_rekey_group_key(&c.community_root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).keys().clone());
}
}
out
}
fn remember_challenge(relay: &RelayUrl, challenge: &str) -> bool {
CHALLENGES
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(relay.clone(), challenge.to_string())
.as_deref()
!= Some(challenge)
}
fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
}
static RESUB_AT: LazyLock<Mutex<HashMap<RelayUrl, std::time::Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
const RESUB_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
fn resub_cooldown_elapsed(relay: &RelayUrl) -> bool {
let mut map = RESUB_AT.lock().unwrap_or_else(|e| e.into_inner());
let now = std::time::Instant::now();
match map.get(relay) {
Some(at) if now.duration_since(*at) < RESUB_COOLDOWN => false,
_ => {
map.insert(relay.clone(), now);
true
}
}
}
pub fn clear() {
REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).clear();
CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).clear();
RESUB_AT.lock().unwrap_or_else(|e| e.into_inner()).clear();
RESPONDER_RUNNING.store(false, Ordering::SeqCst);
}
pub fn is_empty() -> bool {
REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
}
fn sign_all(challenge: &str, relay: &RelayUrl) -> Vec<nostr_sdk::Event> {
let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
reg.values()
.filter_map(|keys| EventBuilder::auth(challenge, relay.clone()).sign_with_keys(keys).ok())
.collect()
}
async fn authenticate_streams(client: &Client, relay: &RelayUrl, challenge: &str) -> usize {
let events = sign_all(challenge, relay);
let Ok(r) = client.pool().relay(relay.clone()).await else {
return 0;
};
let mut sent = 0;
for ev in events {
if r.send_msg(ClientMessage::auth(ev)).is_ok() {
sent += 1;
}
}
sent
}
pub fn ensure_responder(client: &Client) {
if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
return; }
let client = client.clone();
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
let mut notifications = client.notifications();
while let Ok(n) = notifications.recv().await {
if !session.is_valid() {
break;
}
if let RelayPoolNotification::Message { relay_url, message } = n {
if let nostr_sdk::RelayMessage::Auth { challenge } = message {
let challenge = challenge.into_owned();
let fresh_connection = remember_challenge(&relay_url, &challenge);
if !is_empty() {
authenticate_streams(&client, &relay_url, &challenge).await;
if fresh_connection && resub_cooldown_elapsed(&relay_url) {
super::realtime::resubscribe_relay(&client, &relay_url).await;
}
}
}
}
}
RESPONDER_RUNNING.store(false, Ordering::SeqCst);
});
}
pub fn prime(community: &CommunityV2) {
register_community(community);
if let Some(client) = crate::state::nostr_client() {
ensure_responder(&client);
}
}
pub async fn prime_auth(client: &Client, relays: &[String]) {
if is_empty() || relays.is_empty() {
return;
}
ensure_responder(client);
let urls: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
if urls.is_empty() {
return;
}
for url in &urls {
if let Some(challenge) = remembered_challenge(url) {
authenticate_streams(client, url, &challenge).await;
}
}
let authors: Vec<nostr_sdk::PublicKey> = {
let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
reg.keys().filter_map(|pk| nostr_sdk::PublicKey::from_slice(pk).ok()).collect()
};
if authors.is_empty() {
return;
}
let filter = nostr_sdk::Filter::new()
.kind(nostr_sdk::Kind::Custom(super::stream::KIND_WRAP))
.authors(authors)
.limit(1);
let _ = tokio::time::timeout(std::time::Duration::from_secs(8), client.fetch_events_from(urls, filter, std::time::Duration::from_secs(6))).await;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::community::v2::control::{genesis, CommunityMetadata};
use crate::community::v2::community::{ChannelV2, CommunityV2};
use crate::community::{ChannelId, Epoch};
use nostr_sdk::prelude::Keys;
fn community_with_channel_mix() -> CommunityV2 {
let owner = Keys::generate();
let g = genesis(&owner, CommunityMetadata { name: "auth-test".into(), ..Default::default() }, 1_000).unwrap();
let mut c = CommunityV2::from_genesis(&g, "auth-test", None, vec!["wss://gated.example".into()], 0);
c.channels.push(ChannelV2 { id: ChannelId([2u8; 32]), name: "keyed-private".into(), private: true, key: Some([7u8; 32]), epoch: Epoch(3) });
c.channels.push(ChannelV2 { id: ChannelId([3u8; 32]), name: "keyless-private".into(), private: true, key: None, epoch: Epoch(0) });
c
}
fn registered(pk: &nostr_sdk::prelude::PublicKey) -> bool {
REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).contains_key(&pk.to_bytes())
}
#[test]
fn register_community_covers_planes_and_skips_keyless() {
let c = community_with_channel_mix();
let added = register_community(&c);
assert!(added >= 6, "control+guestbook+dissolved+public chat+keyed chat+rekeys = at least 6 new keys, got {added}");
use super::super::derive;
assert!(registered(&derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
assert!(registered(&derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
assert!(registered(&derive::dissolved_group_key(c.id()).pk()));
let public = &c.channels[0];
let (secret, epoch) = c.channel_secret(public);
assert!(registered(&derive::channel_group_key(&secret, &public.id, epoch).pk()));
let keyed = &c.channels[1];
assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
let keyless = &c.channels[2];
assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
assert!(registered(&derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0 + 1)).pk()));
assert!(registered(&derive::channel_rekey_group_key(&c.community_root, &keyed.id, Epoch(4)).pk()));
assert_eq!(register_community(&c), 0);
}
#[test]
fn late_registered_keys_sign_against_the_remembered_challenge() {
let relay = RelayUrl::parse("wss://late-keys.example").unwrap();
let early = Keys::generate();
register([early.clone()]);
assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
let late = Keys::generate();
register([late.clone()]);
let challenge = remembered_challenge(&relay).expect("challenge was remembered");
let events = sign_all(&challenge, &relay);
let signers: Vec<_> = events.iter().map(|e| e.pubkey).collect();
assert!(signers.contains(&early.public_key()));
assert!(signers.contains(&late.public_key()));
}
#[test]
fn signed_auth_events_are_nip42_shaped() {
let relay = RelayUrl::parse("wss://shape.example").unwrap();
let key = Keys::generate();
register([key.clone()]);
let events = sign_all("shape-challenge", &relay);
let ev = events.iter().find(|e| e.pubkey == key.public_key()).expect("signed by the registered key");
assert_eq!(ev.kind, nostr_sdk::Kind::Authentication);
assert!(ev.verify().is_ok(), "signature + id must verify");
let tag_values: Vec<String> = ev.tags.iter().filter_map(|t| t.content().map(String::from)).collect();
assert!(tag_values.iter().any(|v| v == "shape-challenge"), "carries the challenge tag");
}
#[test]
fn challenge_value_change_detects_a_new_connection() {
let relay = RelayUrl::parse("wss://conn-detect.example").unwrap();
assert!(remember_challenge(&relay, "c1"), "first sighting");
assert!(!remember_challenge(&relay, "c1"), "same value = same connection");
assert!(remember_challenge(&relay, "c2"), "new value = reconnected");
assert_eq!(remembered_challenge(&relay).as_deref(), Some("c2"), "memory holds the newest");
}
#[test]
fn resubscribe_cooldown_bounds_the_reaction() {
let relay = RelayUrl::parse("wss://cooldown.example").unwrap();
assert!(resub_cooldown_elapsed(&relay), "first trigger passes");
assert!(!resub_cooldown_elapsed(&relay), "immediate repeat is suppressed");
let other = RelayUrl::parse("wss://cooldown-other.example").unwrap();
assert!(resub_cooldown_elapsed(&other), "cooldown is per-relay");
}
}