vector-core 0.6.0

Core library for Vector — the single source of truth for all Vector clients, SDKs, and interfaces.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Concord v2 stream-key NIP-42 authentication.
//!
//! Every v2 plane is kind-1059 traffic addressed to a DERIVED per-stream pubkey
//! (control, guestbook, per-channel chat, rekey, dissolved) — never the user's
//! own identity. Relays that gate kind 1059 behind NIP-42 (ditto-relay's default
//! `AUTH_KINDS=4,1059`) require that EVERY `authors` entry in a kind-1059 REQ be
//! an authenticated pubkey on the connection, and reply
//! `CLOSED auth-required: all authors must be authenticated` otherwise. The
//! user's login can't satisfy that — the stream address isn't their pubkey — so
//! an unauthenticated client reads back ZERO events and a join's control-plane
//! verify (or any community fetch) fails closed.
//!
//! The fix (mirroring Armada's `streamAuth`): the client HOLDS the stream secret
//! keys (derived from the `community_root` / channel keys it already stores), so
//! it can NIP-42-authenticate AS each stream by signing an extra kind-22242 AUTH
//! event per stream against the relay's challenge. This module is the registry
//! of stream keys the client currently holds plus the challenge responder; the
//! connection ends up authenticated as the user AND every stream it will query.
//!
//! Signing is local (raw derived keys) — it never touches the account signer /
//! bunker.

use nostr_sdk::prelude::FinalizeEvent;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};

use nostr_sdk::prelude::{Client, ClientAuthentication, ClientMessage, ClientNotification, Keys, RelayUrl, StreamExt};

use super::community::CommunityV2;

/// stream pubkey (x-only bytes) → the derived Keys that authenticate it.
static REGISTRY: LazyLock<Mutex<HashMap<[u8; 32], Keys>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
/// Whether the persistent challenge responder is running this session (idempotent spawn).
static RESPONDER_RUNNING: AtomicBool = AtomicBool::new(false);
/// Each relay's last NIP-42 challenge. A challenge stays valid for the connection
/// lifetime, and a gating relay issues it ONCE (on the first gated REQ) — so keys
/// registered AFTER that frame can only authenticate by replaying the remembered
/// challenge; waiting for a fresh one would wait forever, and one unauthenticated
/// author fails a whole REQ's gate. A stale entry (relay reconnected since) is
/// harmless: the relay ignores it and the next gated REQ re-challenges.
static CHALLENGES: LazyLock<Mutex<HashMap<RelayUrl, String>>> = LazyLock::new(|| Mutex::new(HashMap::new()));

/// Register a batch of stream keys (idempotent). Returns how many were 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
}

/// Register every plane a community currently exposes: control, guestbook, the
/// dissolved plane, and each readable channel's Chat Plane (a keyless private
/// channel has no readable plane yet, so it's skipped — its rekey plane keys up
/// first). Called at join and on every follow so a rotated address is covered.
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());
    }
    // The next-epoch rekey planes we subscribe to (base + each private channel).
    for pk_keys in rekey_plane_keys(c) {
        keys.push(pk_keys);
    }
    register(keys)
}

/// The rekey-plane Keys a community watches (base next-epoch + each private
/// channel's next-epoch), mirroring `realtime::rekey_authors` so an AUTH-gating
/// relay serves the rotation crates too.
///
/// Channel planes fan across the SAME addressing roots `follow_rekeys` queries
/// (current + archived priors, CORD-06 D2 — a removal-forced channel rekey
/// rides the PRIOR root). Registration must be UPFRONT and complete: a gating
/// relay issues its NIP-42 challenge once per connection, so a key registered
/// after the connection authed can never authenticate — a current-root-only
/// registration left the prior-root crate plane CLOSED and wedged the channel
/// at its old epoch while the base advanced.
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()];
    let cid_hex = crate::simd::hex::bytes_to_hex_32(&c.id().0);
    let roots = super::service::channel_rekey_addressing_roots(c.community_root, &cid_hex);
    for ch in &c.channels {
        if ch.private {
            for root in &roots {
                out.push(super::derive::channel_rekey_group_key(root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).keys().clone());
            }
        }
    }
    out
}

/// Record a relay's challenge, returning true when its VALUE changed — a new
/// value means a new connection (NIP-42 challenges live for one connection), so
/// any sub REQ the pool re-applied before this auth was gate-CLOSED and needs a
/// re-send. A re-delivered identical challenge is the same connection: auth is
/// re-sent (idempotent) but no resubscribe is triggered.
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)
}

/// The challenge this relay's connection last issued, if seen.
fn remembered_challenge(relay: &RelayUrl) -> Option<String> {
    CHALLENGES.lock().unwrap_or_else(|e| e.into_inner()).get(relay).cloned()
}

/// Last responder-driven resubscribe per relay, bounding the challenge→auth→
/// resubscribe reaction to one per [`RESUB_COOLDOWN`] window per relay.
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);

/// True (and stamps now) if this relay hasn't been resubscribed within the
/// cooldown window; false while one is still fresh.
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
        }
    }
}

/// Forget every registered stream key (on session swap). The responder task exits
/// on its own when its `SessionGuard` invalidates.
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);
}

/// Whether we hold any stream keys (skip the whole dance when not).
pub fn is_empty() -> bool {
    REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).is_empty()
}

/// Sign a NIP-42 AUTH (kind-22242) event for EVERY registered stream key against
/// `challenge` + `relay`. Local raw-key signing; a malformed key is skipped.
fn sign_all(challenge: &str, relay: &RelayUrl) -> Vec<nostr_sdk::prelude::Event> {
    let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
    reg.values()
        .filter_map(|keys| ClientAuthentication::new(challenge, relay.clone()).finalize(keys).ok())
        .collect()
}

/// Authenticate every registered stream key on `relay` against `challenge`. Each
/// rides a NIP-42 `AUTH` client message (NOT an `EVENT` publish — relays reject a
/// bare kind-22242 event). Best-effort per key. Returns how many were sent.
async fn authenticate_streams(client: &Client, relay: &RelayUrl, challenge: &str) -> usize {
    let events = sign_all(challenge, relay);
    let Ok(Some(r)) = client.relay(relay.clone()).await else {
        return 0;
    };
    let mut sent = 0;
    for ev in events {
        if r.send_msg(ClientMessage::auth(ev)).await.is_ok() {
            sent += 1;
        }
    }
    sent
}

/// Ensure the persistent stream-AUTH responder is running for this session
/// (idempotent). It watches the client's notification stream and, on EVERY relay
/// AUTH challenge, authenticates as all registered stream keys on that relay.
///
/// AUTH-gating relays (Ditto) challenge on the GATED REQ — not on connect — and
/// nostr-sdk retries the REQ after auth, so once this responder is running a
/// normal community fetch/subscribe just works: the REQ triggers the challenge,
/// this answers it with the stream keys, and the retry reads the plane. The
/// user's own login rides nostr-sdk's built-in auto-auth. Exits on session swap.
pub fn ensure_responder(client: &Client) {
    if RESPONDER_RUNNING.swap(true, Ordering::SeqCst) {
        return; // already running this session
    }
    let client = client.clone();
    let session = crate::state::SessionGuard::capture();
    tokio::spawn(async move {
        let mut notifications = client.notifications();
        while let Some(n) = notifications.next().await {
            if !session.is_valid() {
                break;
            }
            if let ClientNotification::Message { relay_url, message } = n {
                if let nostr_sdk::prelude::RelayMessage::Auth { challenge } = *message {
                    let challenge = challenge.into_owned();
                    // Durable capability fact: this relay gates reads behind
                    // NIP-42 (the boot volley routes fallbacks by it). Write
                    // once — a challenge-spamming relay must not drive DB
                    // writes at frame rate on the notification loop.
                    let gate_key = format!(
                        "auth_gate:{}",
                        crate::inbox_relays::normalize_relay_url(relay_url.as_str())
                    );
                    if crate::db::get_sql_setting(gate_key.clone()).ok().flatten().is_none() {
                        let _ = crate::db::set_sql_setting(gate_key, "1".to_string());
                    }
                    let fresh_connection = remember_challenge(&relay_url, &challenge);
                    if !is_empty() {
                        authenticate_streams(&client, &relay_url, &challenge).await;
                        // A NEW challenge value means a new connection — the pool's
                        // re-applied sub REQ raced this auth and got gate-CLOSED, and
                        // the relay won't challenge again. Re-send our subs now that
                        // the streams are authenticated. Cooldown-bounded so a relay
                        // minting endless challenges can't drive a resubscribe loop.
                        if fresh_connection && resub_cooldown_elapsed(&relay_url) {
                            super::realtime::resubscribe_relay(&client, &relay_url).await;
                        }
                    }
                }
            }
        }
        RESPONDER_RUNNING.store(false, Ordering::SeqCst);
    });
}

/// Prepare gated relays for an imminent fetch: make sure the community's stream
/// keys are registered and the responder is live, so the fetch's REQ-triggered
/// challenge is answered. A no-op when no client is connected (offline tests).
pub fn prime(community: &CommunityV2) {
    register_community(community);
    if let Some(client) = crate::state::nostr_client() {
        ensure_responder(&client);
    }
}

/// Prime the connection AUTH on `relays` before a live subscription: a
/// subscription (unlike a fetch) isn't auto-retried after the AUTH gate, so the
/// socket must already be authenticated as EVERY stream the sub's `authors` will
/// name — one unauthenticated key fails the whole REQ. Two passes:
///
/// 1. Replay each relay's REMEMBERED challenge for all registered keys — a gating
///    relay challenges once per connection, so keys registered after that frame
///    (a control fold that revealed new channels) would otherwise never auth.
/// 2. A cheap gated fetch, which on a fresh/reconnected socket triggers the
///    challenge the responder answers (and nostr-sdk retries the fetch after).
///
/// No-op with no registered keys / no relays.
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::prelude::PublicKey> = {
        let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
        reg.keys().filter_map(|pk| nostr_sdk::prelude::PublicKey::from_slice(pk).ok()).collect()
    };
    if authors.is_empty() {
        return;
    }
    let filter = nostr_sdk::prelude::Filter::new()
        .kind(nostr_sdk::prelude::Kind::Custom(super::stream::KIND_WRAP))
        .authors(authors)
        .limit(1);
    // Bounded so a dead relay can't stall the subscription refresh behind it.
    let _ = tokio::time::timeout(std::time::Duration::from_secs(8), client
        .fetch_events(nostr_sdk::prelude::ReqTarget::manual(
            urls.into_iter().map(|u| (u, vec![filter.clone()])),
        ))
        .timeout(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;

    /// A community with one public, one keyed-private, and one KEYLESS-private
    /// channel — the three registration classes.
    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), voice: None, meta_custom: None, meta_extra: Default::default() });
        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() });
        c
    }

    fn registered(pk: &nostr_sdk::prelude::PublicKey) -> bool {
        REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).contains_key(&pk.to_bytes())
    }

    /// The registry covers every plane a member must authenticate AS — and a
    /// keyless private channel (no readable plane yet) is skipped, while its
    /// NEXT-epoch rekey plane (the entry point for its key) is covered.
    #[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()));
        // Public channel: chat plane derives from the community root.
        let public = &c.channels[0];
        let (secret, epoch) = c.channel_secret(public);
        assert!(registered(&derive::channel_group_key(&secret, &public.id, epoch).pk()));
        // Keyed private channel: chat plane derives from its own key + epoch.
        let keyed = &c.channels[1];
        assert!(registered(&derive::channel_group_key(&[7u8; 32], &keyed.id, Epoch(3)).pk()));
        // KEYLESS private channel: no readable plane — deriving from the root
        // would address the PUBLIC plane, so it must NOT be registered.
        let keyless = &c.channels[2];
        assert!(!registered(&derive::channel_group_key(&c.community_root, &keyless.id, Epoch(0)).pk()));
        // Rekey planes: base next-epoch + each PRIVATE channel's next-epoch.
        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()));

        // Idempotent: a second registration adds nothing.
        assert_eq!(register_community(&c), 0);
    }

    /// The PIECE-2 regression: a key registered AFTER the connection's one
    /// challenge was consumed still authenticates, because the challenge is
    /// remembered and `sign_all` covers every CURRENTLY-registered key.
    #[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()]);
        // The connection's single challenge arrives while only `early` exists.
        assert!(remember_challenge(&relay, "challenge-1"), "first sighting is a fresh connection");
        // A control fold reveals a new channel → its plane key registers late.
        let late = Keys::generate();
        register([late.clone()]);
        // The replay path (prime_auth pass 1) must sign for BOTH keys.
        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()));
    }

    /// AUTH events must be NIP-42-shaped: kind 22242, challenge + relay tags,
    /// valid signature by the stream 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::prelude::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");
    }

    /// A NEW challenge value = a new connection (triggers the resubscribe); the
    /// SAME value re-delivered = the same connection (auth only, no resubscribe).
    #[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");
    }

    /// The responder-driven resubscribe is cooldown-bounded per relay, so a
    /// relay minting endless fresh challenges can't drive a resubscribe loop.
    #[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");
    }
}