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