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.
82fn 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
93/// Record a relay's challenge, returning true when its VALUE changed — a new
94/// value means a new connection (NIP-42 challenges live for one connection), so
95/// any sub REQ the pool re-applied before this auth was gate-CLOSED and needs a
96/// re-send. A re-delivered identical challenge is the same connection: auth is
97/// re-sent (idempotent) but no resubscribe is triggered.
98fn 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
107/// The challenge this relay's connection last issued, if seen.
108fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
109    CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
110}
111
112/// Last responder-driven resubscribe per relay, bounding the challenge→auth→
113/// resubscribe reaction to one per [`RESUB_COOLDOWN`] window per relay.
114static 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
117/// True (and stamps now) if this relay hasn't been resubscribed within the
118/// cooldown window; false while one is still fresh.
119fn 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
131/// Forget every registered stream key (on session swap). The responder task exits
132/// on its own when its `SessionGuard` invalidates.
133pub 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
140/// Whether we hold any stream keys (skip the whole dance when not).
141pub fn is_empty() -> bool {
142    REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
143}
144
145/// Sign a NIP-42 AUTH (kind-22242) event for EVERY registered stream key against
146/// `challenge` + `relay`. Local raw-key signing; a malformed key is skipped.
147fn 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
154/// Authenticate every registered stream key on `relay` against `challenge`. Each
155/// rides a NIP-42 `AUTH` client message (NOT an `EVENT` publish — relays reject a
156/// bare kind-22242 event). Best-effort per key. Returns how many were sent.
157async 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
171/// Ensure the persistent stream-AUTH responder is running for this session
172/// (idempotent). It watches the client's notification stream and, on EVERY relay
173/// AUTH challenge, authenticates as all registered stream keys on that relay.
174///
175/// AUTH-gating relays (Ditto) challenge on the GATED REQ — not on connect — and
176/// nostr-sdk retries the REQ after auth, so once this responder is running a
177/// normal community fetch/subscribe just works: the REQ triggers the challenge,
178/// this answers it with the stream keys, and the retry reads the plane. The
179/// user's own login rides nostr-sdk's built-in auto-auth. Exits on session swap.
180pub fn ensure_responder(client: &Client) {
181    if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
182        return; // already running this session
183    }
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                        // A NEW challenge value means a new connection — the pool's
199                        // re-applied sub REQ raced this auth and got gate-CLOSED, and
200                        // the relay won't challenge again. Re-send our subs now that
201                        // the streams are authenticated. Cooldown-bounded so a relay
202                        // minting endless challenges can't drive a resubscribe loop.
203                        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
214/// Prepare gated relays for an imminent fetch: make sure the community's stream
215/// keys are registered and the responder is live, so the fetch's REQ-triggered
216/// challenge is answered. A no-op when no client is connected (offline tests).
217pub fn prime(community: &CommunityV2) {
218    register_community(community);
219    if let Some(client) = crate::state::nostr_client() {
220        ensure_responder(&client);
221    }
222}
223
224/// Prime the connection AUTH on `relays` before a live subscription: a
225/// subscription (unlike a fetch) isn't auto-retried after the AUTH gate, so the
226/// socket must already be authenticated as EVERY stream the sub's `authors` will
227/// name — one unauthenticated key fails the whole REQ. Two passes:
228///
229/// 1. Replay each relay's REMEMBERED challenge for all registered keys — a gating
230///    relay challenges once per connection, so keys registered after that frame
231///    (a control fold that revealed new channels) would otherwise never auth.
232/// 2. A cheap gated fetch, which on a fresh/reconnected socket triggers the
233///    challenge the responder answers (and nostr-sdk retries the fetch after).
234///
235/// No-op with no registered keys / no relays.
236pub 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    // Bounded so a dead relay can't stall the subscription refresh behind it.
262    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    /// A community with one public, one keyed-private, and one KEYLESS-private
274    /// channel — the three registration classes.
275    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) });
280        c.channels.push(ChannelV2 { id: ChannelId([3u8; 32]), name: "keyless-private".into(), private: true, key: None, epoch: Epoch(0) });
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    /// The registry covers every plane a member must authenticate AS — and a
289    /// keyless private channel (no readable plane yet) is skipped, while its
290    /// NEXT-epoch rekey plane (the entry point for its key) is covered.
291    #[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        // Public channel: chat plane derives from the community root.
302        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        // Keyed private channel: chat plane derives from its own key + epoch.
306        let keyed = &c.channels[1];
307        assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
308        // KEYLESS private channel: no readable plane — deriving from the root
309        // would address the PUBLIC plane, so it must NOT be registered.
310        let keyless = &c.channels[2];
311        assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
312        // Rekey planes: base next-epoch + each PRIVATE channel's next-epoch.
313        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        // Idempotent: a second registration adds nothing.
317        assert_eq!(register_community(&c), 0);
318    }
319
320    /// The PIECE-2 regression: a key registered AFTER the connection's one
321    /// challenge was consumed still authenticates, because the challenge is
322    /// remembered and `sign_all` covers every CURRENTLY-registered key.
323    #[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        // The connection's single challenge arrives while only `early` exists.
329        assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
330        // A control fold reveals a new channel → its plane key registers late.
331        let late = Keys::generate();
332        register([late.clone()]);
333        // The replay path (prime_auth pass 1) must sign for BOTH keys.
334        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    /// AUTH events must be NIP-42-shaped: kind 22242, challenge + relay tags,
342    /// valid signature by the stream key.
343    #[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    /// A NEW challenge value = a new connection (triggers the resubscribe); the
357    /// SAME value re-delivered = the same connection (auth only, no resubscribe).
358    #[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    /// The responder-driven resubscribe is cooldown-bounded per relay, so a
368    /// relay minting endless fresh challenges can't drive a resubscribe loop.
369    #[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}