Skip to main content

vector_core/community/v2/
streamauth.rs

1//! Concord v2 stream-key NIP-42 authentication.
2//!
3//! Every v2 plane is kind-1059 traffic addressed to a DERIVED per-stream pubkey
4//! (control, guestbook, per-channel chat, rekey, dissolved) — never the user's
5//! own identity. Relays that gate kind 1059 behind NIP-42 (ditto-relay's default
6//! `AUTH_KINDS=4,1059`) require that EVERY `authors` entry in a kind-1059 REQ be
7//! an authenticated pubkey on the connection, and reply
8//! `CLOSED auth-required: all authors must be authenticated` otherwise. The
9//! user's login can't satisfy that — the stream address isn't their pubkey — so
10//! an unauthenticated client reads back ZERO events and a join's control-plane
11//! verify (or any community fetch) fails closed.
12//!
13//! The fix (mirroring Armada's `streamAuth`): the client HOLDS the stream secret
14//! keys (derived from the `community_root` / channel keys it already stores), so
15//! it can NIP-42-authenticate AS each stream by signing an extra kind-22242 AUTH
16//! event per stream against the relay's challenge. This module is the registry
17//! of stream keys the client currently holds plus the challenge responder; the
18//! connection ends up authenticated as the user AND every stream it will query.
19//!
20//! Signing is local (raw derived keys) — it never touches the account signer /
21//! bunker.
22
23use nostr_sdk::prelude::FinalizeEvent;
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{LazyLock, Mutex};
27
28use nostr_sdk::prelude::{Client, ClientAuthentication, ClientMessage, ClientNotification, Keys, RelayUrl, StreamExt};
29
30use super::community::CommunityV2;
31
32/// stream pubkey (x-only bytes) → the derived Keys that authenticate it.
33static REGISTRY: LazyLock<Mutex<HashMap<[u8; 32], Keys>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
34/// Whether the persistent challenge responder is running this session (idempotent spawn).
35static RESPONDER_RUNNING: AtomicBool = AtomicBool::new(false);
36/// Each relay's last NIP-42 challenge. A challenge stays valid for the connection
37/// lifetime, and a gating relay issues it ONCE (on the first gated REQ) — so keys
38/// registered AFTER that frame can only authenticate by replaying the remembered
39/// challenge; waiting for a fresh one would wait forever, and one unauthenticated
40/// author fails a whole REQ's gate. A stale entry (relay reconnected since) is
41/// harmless: the relay ignores it and the next gated REQ re-challenges.
42static CHALLENGES: LazyLock<Mutex<HashMap<RelayUrl, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
43
44/// Register a batch of stream keys (idempotent). Returns how many were NEW.
45pub fn register(keys: impl IntoIterator<Item = Keys>) -> usize {
46    let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
47    let mut added = 0;
48    for k in keys {
49        if reg.insert(k.public_key().to_bytes(), k).is_none() {
50            added += 1;
51        }
52    }
53    added
54}
55
56/// Register every plane a community currently exposes: control, guestbook, the
57/// dissolved plane, and each readable channel's Chat Plane (a keyless private
58/// channel has no readable plane yet, so it's skipped — its rekey plane keys up
59/// first). Called at join and on every follow so a rotated address is covered.
60pub fn register_community(c: &CommunityV2) -> usize {
61    let mut keys: Vec<Keys> = vec![
62        super::derive::control_group_key(&c.community_root, c.id(), c.root_epoch).keys().clone(),
63        super::derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).keys().clone(),
64        super::derive::dissolved_group_key(c.id()).keys().clone(),
65    ];
66    for ch in &c.channels {
67        if ch.private && ch.key.is_none() {
68            continue;
69        }
70        let (secret, epoch) = c.channel_secret(ch);
71        keys.push(super::derive::channel_group_key(&secret, &ch.id, epoch).keys().clone());
72    }
73    // The next-epoch rekey planes we subscribe to (base + each private channel).
74    for pk_keys in rekey_plane_keys(c) {
75        keys.push(pk_keys);
76    }
77    register(keys)
78}
79
80/// The rekey-plane Keys a community watches (base next-epoch + each private
81/// channel's next-epoch), mirroring `realtime::rekey_authors` so an AUTH-gating
82/// relay serves the rotation crates too.
83///
84/// Channel planes fan across the SAME addressing roots `follow_rekeys` queries
85/// (current + archived priors, CORD-06 D2 — a removal-forced channel rekey
86/// rides the PRIOR root). Registration must be UPFRONT and complete: a gating
87/// relay issues its NIP-42 challenge once per connection, so a key registered
88/// after the connection authed can never authenticate — a current-root-only
89/// registration left the prior-root crate plane CLOSED and wedged the channel
90/// at its old epoch while the base advanced.
91fn rekey_plane_keys(c: &CommunityV2) -> Vec<Keys> {
92    use crate::community::Epoch;
93    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()];
94    let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
95    let roots = super::service::channel_rekey_addressing_roots(c.community_root, &cid_hex);
96    for ch in &c.channels {
97        if ch.private {
98            for root in &roots {
99                out.push(super::derive::channel_rekey_group_key(root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).keys().clone());
100            }
101        }
102    }
103    out
104}
105
106/// Record a relay's challenge, returning true when its VALUE changed — a new
107/// value means a new connection (NIP-42 challenges live for one connection), so
108/// any sub REQ the pool re-applied before this auth was gate-CLOSED and needs a
109/// re-send. A re-delivered identical challenge is the same connection: auth is
110/// re-sent (idempotent) but no resubscribe is triggered.
111fn remember_challenge(relay: &RelayUrl, challenge: &str) -> bool {
112    CHALLENGES
113        .lock()
114        .unwrap_or_else(|e| e.into_inner())
115        .insert(relay.clone(), challenge.to_string())
116        .as_deref()
117        != Some(challenge)
118}
119
120/// The challenge this relay's connection last issued, if seen.
121fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
122    CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
123}
124
125/// Last responder-driven resubscribe per relay, bounding the challenge→auth→
126/// resubscribe reaction to one per [`RESUB_COOLDOWN`] window per relay.
127static RESUB_AT: LazyLock<Mutex<HashMap<RelayUrl, std::time::Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
128const RESUB_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
129
130/// True (and stamps now) if this relay hasn't been resubscribed within the
131/// cooldown window; false while one is still fresh.
132fn resub_cooldown_elapsed(relay: &RelayUrl) -> bool {
133    let mut map = RESUB_AT.lock().unwrap_or_else(|e| e.into_inner());
134    let now = std::time::Instant::now();
135    match map.get(relay) {
136        Some(at) if now.duration_since(*at) < RESUB_COOLDOWN => false,
137        _ => {
138            map.insert(relay.clone(), now);
139            true
140        }
141    }
142}
143
144/// Forget every registered stream key (on session swap). The responder task exits
145/// on its own when its `SessionGuard` invalidates.
146pub fn clear() {
147    REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).clear();
148    CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).clear();
149    RESUB_AT.lock().unwrap_or_else(|e| e.into_inner()).clear();
150    RESPONDER_RUNNING.store(false, Ordering::SeqCst);
151}
152
153/// Whether we hold any stream keys (skip the whole dance when not).
154pub fn is_empty() -> bool {
155    REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
156}
157
158/// Sign a NIP-42 AUTH (kind-22242) event for EVERY registered stream key against
159/// `challenge` + `relay`. Local raw-key signing; a malformed key is skipped.
160fn sign_all(challenge: &str, relay: &RelayUrl) -> Vec<nostr_sdk::prelude::Event> {
161    let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
162    reg.values()
163        .filter_map(|keys| ClientAuthentication::new(challenge, relay.clone()).finalize(keys).ok())
164        .collect()
165}
166
167/// Authenticate every registered stream key on `relay` against `challenge`. Each
168/// rides a NIP-42 `AUTH` client message (NOT an `EVENT` publish — relays reject a
169/// bare kind-22242 event). Best-effort per key. Returns how many were sent.
170async fn authenticate_streams(client: &Client, relay: &RelayUrl, challenge: &str) -> usize {
171    let events = sign_all(challenge, relay);
172    let Ok(Some(r)) = client.relay(relay.clone()).await else {
173        return 0;
174    };
175    let mut sent = 0;
176    for ev in events {
177        if r.send_msg(ClientMessage::auth(ev)).await.is_ok() {
178            sent += 1;
179        }
180    }
181    sent
182}
183
184/// Ensure the persistent stream-AUTH responder is running for this session
185/// (idempotent). It watches the client's notification stream and, on EVERY relay
186/// AUTH challenge, authenticates as all registered stream keys on that relay.
187///
188/// AUTH-gating relays (Ditto) challenge on the GATED REQ — not on connect — and
189/// nostr-sdk retries the REQ after auth, so once this responder is running a
190/// normal community fetch/subscribe just works: the REQ triggers the challenge,
191/// this answers it with the stream keys, and the retry reads the plane. The
192/// user's own login rides nostr-sdk's built-in auto-auth. Exits on session swap.
193pub fn ensure_responder(client: &Client) {
194    if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
195        return; // already running this session
196    }
197    let client = client.clone();
198    let session = crate::state::SessionGuard::capture();
199    tokio::spawn(async move {
200        let mut notifications = client.notifications();
201        while let Some(n) = notifications.next().await {
202            if !session.is_valid() {
203                break;
204            }
205            if let ClientNotification::Message { relay_url, message } = n {
206                if let nostr_sdk::prelude::RelayMessage::Auth { challenge } = *message {
207                    let challenge = challenge.into_owned();
208                    // Durable capability fact: this relay gates reads behind
209                    // NIP-42 (the boot volley routes fallbacks by it). Write
210                    // once — a challenge-spamming relay must not drive DB
211                    // writes at frame rate on the notification loop.
212                    let gate_key = format!(
213                        "auth_gate:{}",
214                        crate::inbox_relays::normalize_relay_url(relay_url.as_str())
215                    );
216                    if crate::db::get_sql_setting(gate_key.clone()).ok().flatten().is_none() {
217                        let _ = crate::db::set_sql_setting(gate_key, "1".to_string());
218                    }
219                    let fresh_connection = remember_challenge(&relay_url, &challenge);
220                    if !is_empty() {
221                        authenticate_streams(&client, &relay_url, &challenge).await;
222                        // A NEW challenge value means a new connection — the pool's
223                        // re-applied sub REQ raced this auth and got gate-CLOSED, and
224                        // the relay won't challenge again. Re-send our subs now that
225                        // the streams are authenticated. Cooldown-bounded so a relay
226                        // minting endless challenges can't drive a resubscribe loop.
227                        if fresh_connection && resub_cooldown_elapsed(&relay_url) {
228                            super::realtime::resubscribe_relay(&client, &relay_url).await;
229                        }
230                    }
231                }
232            }
233        }
234        RESPONDER_RUNNING.store(false, Ordering::SeqCst);
235    });
236}
237
238/// Prepare gated relays for an imminent fetch: make sure the community's stream
239/// keys are registered and the responder is live, so the fetch's REQ-triggered
240/// challenge is answered. A no-op when no client is connected (offline tests).
241pub fn prime(community: &CommunityV2) {
242    register_community(community);
243    if let Some(client) = crate::state::nostr_client() {
244        ensure_responder(&client);
245    }
246}
247
248/// Prime the connection AUTH on `relays` before a live subscription: a
249/// subscription (unlike a fetch) isn't auto-retried after the AUTH gate, so the
250/// socket must already be authenticated as EVERY stream the sub's `authors` will
251/// name — one unauthenticated key fails the whole REQ. Two passes:
252///
253/// 1. Replay each relay's REMEMBERED challenge for all registered keys — a gating
254///    relay challenges once per connection, so keys registered after that frame
255///    (a control fold that revealed new channels) would otherwise never auth.
256/// 2. A cheap gated fetch, which on a fresh/reconnected socket triggers the
257///    challenge the responder answers (and nostr-sdk retries the fetch after).
258///
259/// No-op with no registered keys / no relays.
260pub async fn prime_auth(client: &Client, relays: &[String]) {
261    if is_empty() || relays.is_empty() {
262        return;
263    }
264    ensure_responder(client);
265    let urls: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
266    if urls.is_empty() {
267        return;
268    }
269    for url in &urls {
270        if let Some(challenge) = remembered_challenge(url) {
271            authenticate_streams(client, url, &challenge).await;
272        }
273    }
274    let authors: Vec<nostr_sdk::prelude::PublicKey> = {
275        let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
276        reg.keys().filter_map(|pk| nostr_sdk::prelude::PublicKey::from_slice(pk).ok()).collect()
277    };
278    if authors.is_empty() {
279        return;
280    }
281    let filter = nostr_sdk::prelude::Filter::new()
282        .kind(nostr_sdk::prelude::Kind::Custom(super::stream::KIND_WRAP))
283        .authors(authors)
284        .limit(1);
285    // Bounded so a dead relay can't stall the subscription refresh behind it.
286    let _ = tokio::time::timeout(std::time::Duration::from_secs(8), client
287        .fetch_events(nostr_sdk::prelude::ReqTarget::manual(
288            urls.into_iter().map(|u| (u, vec![filter.clone()])),
289        ))
290        .timeout(std::time::Duration::from_secs(6))).await;
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::community::v2::control::{genesis, CommunityMetadata};
297    use crate::community::v2::community::{ChannelV2, CommunityV2};
298    use crate::community::{ChannelId, Epoch};
299    use nostr_sdk::prelude::Keys;
300
301    /// A community with one public, one keyed-private, and one KEYLESS-private
302    /// channel — the three registration classes.
303    fn community_with_channel_mix() -> CommunityV2 {
304        let owner = Keys::generate();
305        let g = genesis(&owner, CommunityMetadata { name: "auth-test".into(), ..Default::default() }, 1_000).unwrap();
306        let mut c = CommunityV2::from_genesis(&g, "auth-test", None, vec!["wss://gated.example".into()], 0);
307        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() });
308        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() });
309        c
310    }
311
312    fn registered(pk: &nostr_sdk::prelude::PublicKey) -> bool {
313        REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).contains_key(&pk.to_bytes())
314    }
315
316    /// The registry covers every plane a member must authenticate AS — and a
317    /// keyless private channel (no readable plane yet) is skipped, while its
318    /// NEXT-epoch rekey plane (the entry point for its key) is covered.
319    #[test]
320    fn register_community_covers_planes_and_skips_keyless() {
321        let c = community_with_channel_mix();
322        let added = register_community(&c);
323        assert!(added >= 6, "control+guestbook+dissolved+public chat+keyed chat+rekeys = at least 6 new keys, got {added}");
324
325        use super::super::derive;
326        assert!(registered(&derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
327        assert!(registered(&derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk()));
328        assert!(registered(&derive::dissolved_group_key(c.id()).pk()));
329        // Public channel: chat plane derives from the community root.
330        let public = &c.channels[0];
331        let (secret, epoch) = c.channel_secret(public);
332        assert!(registered(&derive::channel_group_key(&secret, &public.id, epoch).pk()));
333        // Keyed private channel: chat plane derives from its own key + epoch.
334        let keyed = &c.channels[1];
335        assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
336        // KEYLESS private channel: no readable plane — deriving from the root
337        // would address the PUBLIC plane, so it must NOT be registered.
338        let keyless = &c.channels[2];
339        assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
340        // Rekey planes: base next-epoch + each PRIVATE channel's next-epoch.
341        assert!(registered(&derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0 + 1)).pk()));
342        assert!(registered(&derive::channel_rekey_group_key(&c.community_root, &keyed.id, Epoch(4)).pk()));
343
344        // Idempotent: a second registration adds nothing.
345        assert_eq!(register_community(&c), 0);
346    }
347
348    /// The PIECE-2 regression: a key registered AFTER the connection's one
349    /// challenge was consumed still authenticates, because the challenge is
350    /// remembered and `sign_all` covers every CURRENTLY-registered key.
351    #[test]
352    fn late_registered_keys_sign_against_the_remembered_challenge() {
353        let relay = RelayUrl::parse("wss://late-keys.example").unwrap();
354        let early = Keys::generate();
355        register([early.clone()]);
356        // The connection's single challenge arrives while only `early` exists.
357        assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
358        // A control fold reveals a new channel → its plane key registers late.
359        let late = Keys::generate();
360        register([late.clone()]);
361        // The replay path (prime_auth pass 1) must sign for BOTH keys.
362        let challenge = remembered_challenge(&relay).expect("challenge was remembered");
363        let events = sign_all(&challenge, &relay);
364        let signers: Vec<_> = events.iter().map(|e| e.pubkey).collect();
365        assert!(signers.contains(&early.public_key()));
366        assert!(signers.contains(&late.public_key()));
367    }
368
369    /// AUTH events must be NIP-42-shaped: kind 22242, challenge + relay tags,
370    /// valid signature by the stream key.
371    #[test]
372    fn signed_auth_events_are_nip42_shaped() {
373        let relay = RelayUrl::parse("wss://shape.example").unwrap();
374        let key = Keys::generate();
375        register([key.clone()]);
376        let events = sign_all("shape-challenge", &relay);
377        let ev = events.iter().find(|e| e.pubkey == key.public_key()).expect("signed by the registered key");
378        assert_eq!(ev.kind, nostr_sdk::prelude::Kind::Authentication);
379        assert!(ev.verify().is_ok(), "signature + id must verify");
380        let tag_values: Vec<String> = ev.tags.iter().filter_map(|t| t.content().map(String::from)).collect();
381        assert!(tag_values.iter().any(|v| v == "shape-challenge"), "carries the challenge tag");
382    }
383
384    /// A NEW challenge value = a new connection (triggers the resubscribe); the
385    /// SAME value re-delivered = the same connection (auth only, no resubscribe).
386    #[test]
387    fn challenge_value_change_detects_a_new_connection() {
388        let relay = RelayUrl::parse("wss://conn-detect.example").unwrap();
389        assert!(remember_challenge(&relay, "c1"), "first sighting");
390        assert!(!remember_challenge(&relay, "c1"), "same value = same connection");
391        assert!(remember_challenge(&relay, "c2"), "new value = reconnected");
392        assert_eq!(remembered_challenge(&relay).as_deref(), Some("c2"), "memory holds the newest");
393    }
394
395    /// The responder-driven resubscribe is cooldown-bounded per relay, so a
396    /// relay minting endless fresh challenges can't drive a resubscribe loop.
397    #[test]
398    fn resubscribe_cooldown_bounds_the_reaction() {
399        let relay = RelayUrl::parse("wss://cooldown.example").unwrap();
400        assert!(resub_cooldown_elapsed(&relay), "first trigger passes");
401        assert!(!resub_cooldown_elapsed(&relay), "immediate repeat is suppressed");
402        let other = RelayUrl::parse("wss://cooldown-other.example").unwrap();
403        assert!(resub_cooldown_elapsed(&other), "cooldown is per-relay");
404    }
405}