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 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
31/// stream pubkey (x-only bytes) → the derived Keys that authenticate it.
32static REGISTRY: LazyLock<Mutex<HashMap<[u8; 32], Keys>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
33/// Whether the persistent challenge responder is running this session (idempotent spawn).
34static RESPONDER_RUNNING: AtomicBool = AtomicBool::new(false);
35/// Each relay's last NIP-42 challenge. A challenge stays valid for the connection
36/// lifetime, and a gating relay issues it ONCE (on the first gated REQ) — so keys
37/// registered AFTER that frame can only authenticate by replaying the remembered
38/// challenge; waiting for a fresh one would wait forever, and one unauthenticated
39/// author fails a whole REQ's gate. A stale entry (relay reconnected since) is
40/// harmless: the relay ignores it and the next gated REQ re-challenges.
41static CHALLENGES: LazyLock<Mutex<HashMap<RelayUrl, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
42
43/// Register a batch of stream keys (idempotent). Returns how many were NEW.
44pub 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
55/// Register every plane a community currently exposes: control, guestbook, the
56/// dissolved plane, and each readable channel's Chat Plane (a keyless private
57/// channel has no readable plane yet, so it's skipped — its rekey plane keys up
58/// first). Called at join and on every follow so a rotated address is covered.
59pub 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    // The next-epoch rekey planes we subscribe to (base + each private channel).
73    for pk_keys in rekey_plane_keys(c) {
74        keys.push(pk_keys);
75    }
76    register(keys)
77}
78
79/// The rekey-plane Keys a community watches (base next-epoch + each private
80/// channel's next-epoch), mirroring `realtime::rekey_authors` so an AUTH-gating
81/// relay serves the rotation crates too.
82///
83/// Channel planes fan across the SAME addressing roots `follow_rekeys` queries
84/// (current + archived priors, CORD-06 D2 — a removal-forced channel rekey
85/// rides the PRIOR root). Registration must be UPFRONT and complete: a gating
86/// relay issues its NIP-42 challenge once per connection, so a key registered
87/// after the connection authed can never authenticate — a current-root-only
88/// registration left the prior-root crate plane CLOSED and wedged the channel
89/// at its old epoch while the base advanced.
90fn 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
105/// Record a relay's challenge, returning true when its VALUE changed — a new
106/// value means a new connection (NIP-42 challenges live for one connection), so
107/// any sub REQ the pool re-applied before this auth was gate-CLOSED and needs a
108/// re-send. A re-delivered identical challenge is the same connection: auth is
109/// re-sent (idempotent) but no resubscribe is triggered.
110fn 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
119/// The challenge this relay's connection last issued, if seen.
120fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
121    CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
122}
123
124/// Last responder-driven resubscribe per relay, bounding the challenge→auth→
125/// resubscribe reaction to one per [`RESUB_COOLDOWN`] window per relay.
126static 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
129/// True (and stamps now) if this relay hasn't been resubscribed within the
130/// cooldown window; false while one is still fresh.
131fn 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
143/// Forget every registered stream key (on session swap). The responder task exits
144/// on its own when its `SessionGuard` invalidates.
145pub 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
152/// Whether we hold any stream keys (skip the whole dance when not).
153pub fn is_empty() -> bool {
154    REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
155}
156
157/// Sign a NIP-42 AUTH (kind-22242) event for EVERY registered stream key against
158/// `challenge` + `relay`. Local raw-key signing; a malformed key is skipped.
159fn 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
166/// Authenticate every registered stream key on `relay` against `challenge`. Each
167/// rides a NIP-42 `AUTH` client message (NOT an `EVENT` publish — relays reject a
168/// bare kind-22242 event). Best-effort per key. Returns how many were sent.
169async 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
183/// Ensure the persistent stream-AUTH responder is running for this session
184/// (idempotent). It watches the client's notification stream and, on EVERY relay
185/// AUTH challenge, authenticates as all registered stream keys on that relay.
186///
187/// AUTH-gating relays (Ditto) challenge on the GATED REQ — not on connect — and
188/// nostr-sdk retries the REQ after auth, so once this responder is running a
189/// normal community fetch/subscribe just works: the REQ triggers the challenge,
190/// this answers it with the stream keys, and the retry reads the plane. The
191/// user's own login rides nostr-sdk's built-in auto-auth. Exits on session swap.
192pub fn ensure_responder(client: &Client) {
193    if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
194        return; // already running this session
195    }
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                        // A NEW challenge value means a new connection — the pool's
211                        // re-applied sub REQ raced this auth and got gate-CLOSED, and
212                        // the relay won't challenge again. Re-send our subs now that
213                        // the streams are authenticated. Cooldown-bounded so a relay
214                        // minting endless challenges can't drive a resubscribe loop.
215                        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
226/// Prepare gated relays for an imminent fetch: make sure the community's stream
227/// keys are registered and the responder is live, so the fetch's REQ-triggered
228/// challenge is answered. A no-op when no client is connected (offline tests).
229pub fn prime(community: &CommunityV2) {
230    register_community(community);
231    if let Some(client) = crate::state::nostr_client() {
232        ensure_responder(&client);
233    }
234}
235
236/// Prime the connection AUTH on `relays` before a live subscription: a
237/// subscription (unlike a fetch) isn't auto-retried after the AUTH gate, so the
238/// socket must already be authenticated as EVERY stream the sub's `authors` will
239/// name — one unauthenticated key fails the whole REQ. Two passes:
240///
241/// 1. Replay each relay's REMEMBERED challenge for all registered keys — a gating
242///    relay challenges once per connection, so keys registered after that frame
243///    (a control fold that revealed new channels) would otherwise never auth.
244/// 2. A cheap gated fetch, which on a fresh/reconnected socket triggers the
245///    challenge the responder answers (and nostr-sdk retries the fetch after).
246///
247/// No-op with no registered keys / no relays.
248pub 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    // Bounded so a dead relay can't stall the subscription refresh behind it.
274    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    /// A community with one public, one keyed-private, and one KEYLESS-private
286    /// channel — the three registration classes.
287    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    /// The registry covers every plane a member must authenticate AS — and a
301    /// keyless private channel (no readable plane yet) is skipped, while its
302    /// NEXT-epoch rekey plane (the entry point for its key) is covered.
303    #[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        // Public channel: chat plane derives from the community root.
314        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        // Keyed private channel: chat plane derives from its own key + epoch.
318        let keyed = &c.channels[1];
319        assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
320        // KEYLESS private channel: no readable plane — deriving from the root
321        // would address the PUBLIC plane, so it must NOT be registered.
322        let keyless = &c.channels[2];
323        assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
324        // Rekey planes: base next-epoch + each PRIVATE channel's next-epoch.
325        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        // Idempotent: a second registration adds nothing.
329        assert_eq!(register_community(&c), 0);
330    }
331
332    /// The PIECE-2 regression: a key registered AFTER the connection's one
333    /// challenge was consumed still authenticates, because the challenge is
334    /// remembered and `sign_all` covers every CURRENTLY-registered key.
335    #[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        // The connection's single challenge arrives while only `early` exists.
341        assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
342        // A control fold reveals a new channel → its plane key registers late.
343        let late = Keys::generate();
344        register([late.clone()]);
345        // The replay path (prime_auth pass 1) must sign for BOTH keys.
346        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    /// AUTH events must be NIP-42-shaped: kind 22242, challenge + relay tags,
354    /// valid signature by the stream key.
355    #[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    /// A NEW challenge value = a new connection (triggers the resubscribe); the
369    /// SAME value re-delivered = the same connection (auth only, no resubscribe).
370    #[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    /// The responder-driven resubscribe is cooldown-bounded per relay, so a
380    /// relay minting endless fresh challenges can't drive a resubscribe loop.
381    #[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}