Skip to main content

offline_protocol/protocol/
nostr_publication.rs

1//! Nostr key-package publication slots and peer key-package resolution.
2//!
3//! Cold first contact over Nostr — reaching a peer known only by username, with
4//! no prior key-package exchange over some other transport — needs the peer's
5//! key package to be *fetchable* rather than pushed. This module owns the
6//! publishing half of that: it keeps a small set of single-use MLS key packages
7//! standing in addressable relay records, replaces each as it is consumed, and
8//! feeds the answers to outbound resolution queries back into the ordinary
9//! key-package handler.
10//!
11//! # Why slots, rather than one record
12//!
13//! An MLS key package's init key is consumed by the first peer who uses it, so
14//! a single replaceable record would mean the second stranger to fetch it
15//! builds a Welcome that can never be processed. Each slot therefore holds its
16//! own package under its own `d` tag, and
17//! [`MlsManager::key_package_by_id`](offline_protocol_mls::MlsManager::key_package_by_id)
18//! reporting a package missing is the consumption signal that refills it.
19//!
20//! Consumption is *local*: an init key leaves OpenMLS provider storage only
21//! when this node processes a Welcome built against it. A stranger can drive it
22//! only by actually establishing sessions with us, and each one they burn is
23//! refilled on the next tick — so the exposure is a narrow window in which cold
24//! contact degrades, not a way to disable it.
25
26use super::{internal_prefixes, storage_keys, OfflineProtocol};
27use crate::events::SecurityWarningCode;
28use crate::{Error, Result};
29use offline_protocol_core::{Message, MessagePriority};
30use offline_protocol_transport::constants::NOSTR_KEY_PACKAGE_SLOTS;
31use offline_protocol_transport::TransportType;
32use serde::{Deserialize, Serialize};
33use std::collections::HashSet;
34use std::time::{Duration, Instant};
35use tracing::{debug, warn};
36
37/// How often the publication slots are re-scanned. See
38/// [`OfflineProtocol::nostr_slot_refresh_due`] for what the interval trades.
39const NOSTR_SLOT_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
40
41/// Ceiling on the republish backoff after consecutive publication failures,
42/// and the quiet period after which a slot's failure streak resets.
43///
44/// Republication is otherwise unconditional on every refresh, so a relay that
45/// rejects the kind outright — or rate-limits a fresh pubkey bursting the whole
46/// slot set at once — would be retried once per slot per refresh forever, never
47/// converging and never backing off. Every other retry ladder in this engine is
48/// bounded; this one was not.
49const NOSTR_PUBLICATION_MAX_BACKOFF: Duration = Duration::from_secs(1800);
50
51/// Minimum gap between [`SecurityWarningCode::NostrKeyPackageSlotExhausted`]
52/// emissions.
53///
54/// The condition it reports (an MLS or storage error refilling a slot) is
55/// exactly the kind that persists, and without suppression it would emit once
56/// per failing slot per refresh — five events a minute, indefinitely, for a
57/// single stuck cause. Matches the suppression the unauthorized-membership
58/// report uses for the same reason.
59const NOSTR_SLOT_WARNING_SUPPRESS_INTERVAL: Duration = Duration::from_secs(300);
60
61/// One publication slot's persisted state.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub(crate) struct NostrPublicationSlot {
64    /// The addressable event's `d` tag: stable and random, so republishing
65    /// replaces this slot's record rather than adding another, and so the set
66    /// of tags reveals no ordering or count convention.
67    pub(crate) slot_id: String,
68    /// The MLS key package currently standing in this slot.
69    pub(crate) package_id: String,
70}
71
72/// A slot's publication-failure state, driving the republish backoff.
73///
74/// Memory-only: it exists to stop hammering a relay within one process, and a
75/// launch that starts over costs one prompt retry per slot.
76#[derive(Debug, Clone)]
77pub(crate) struct PublicationBackoff {
78    /// Consecutive failures, which set the delay.
79    pub(crate) failures: u32,
80    /// When the most recent failure was reported.
81    pub(crate) last_failure: Instant,
82    /// Earliest this slot may be republished.
83    pub(crate) retry_at: Instant,
84}
85
86impl OfflineProtocol {
87    /// Brings the published key-package slots back up to strength.
88    ///
89    /// Runs on the process tick. Three things can put a slot out of date, and
90    /// all three are handled the same way — mint a fresh package, publish it
91    /// under the slot's existing `d` tag:
92    ///
93    /// 1. the slot has never existed (first run, or the persisted map was lost);
94    /// 2. its package was consumed by a Welcome we processed;
95    /// 3. its package expired.
96    ///
97    /// Every slot is also republished once per process, because an addressable
98    /// record lives on the relays rather than here: a relay that dropped it, or
99    /// one added to the configuration since, would otherwise hold nothing.
100    /// Republication under the same slot id is idempotent — it replaces.
101    pub(crate) fn refresh_nostr_key_package_slots(&mut self) {
102        if !self.transport_manager.nostr_cold_contact_active() {
103            return;
104        }
105        if self.mls_manager.is_none() {
106            return;
107        }
108        if !self.nostr_slot_refresh_due() {
109            return;
110        }
111
112        let now = Instant::now();
113
114        // A slot is marked published when its record is *queued*, which is the
115        // only point this layer hears about — so a record that then failed to
116        // reach a relay would leave the slot looking healthy forever while the
117        // relays hold nothing. The transport reports those back here. Drained
118        // after the throttle check on purpose: draining is destructive, and a
119        // throttled tick that dropped the reports would lose them outright.
120        for slot_id in self.transport_manager.take_failed_nostr_publications() {
121            self.nostr_published_slots.remove(&slot_id);
122            self.note_publication_failure(&slot_id, now);
123        }
124
125        let mut changed = false;
126        let mut failed_slots = 0usize;
127        for index in 0..NOSTR_KEY_PACKAGE_SLOTS {
128            match self.refresh_one_slot(index, now) {
129                Ok(true) => changed = true,
130                Ok(false) => {}
131                Err(e) => {
132                    warn!(
133                        slot_index = index,
134                        error = %e,
135                        "Failed to refresh a Nostr key-package publication slot"
136                    );
137                    failed_slots += 1;
138                }
139            }
140        }
141
142        // The one failure the publication design must not absorb quietly: a
143        // slot left standing with a consumed package makes every stranger who
144        // fetches it build an unprocessable Welcome, and nothing else in the
145        // system would ever say so. Reported once for the pass rather than once
146        // per slot, and suppressed between passes — the causes here (MLS or
147        // storage errors) persist, and five events a minute forever would bury
148        // the signal in its own repetition.
149        if failed_slots > 0 {
150            self.report_nostr_slot_refresh_failure(failed_slots, now);
151        }
152
153        self.prune_nostr_publication_backoff();
154
155        if changed {
156            self.persist_nostr_publication_slots();
157        }
158    }
159
160    /// Emits the slot-exhaustion warning, at most once per suppression window.
161    fn report_nostr_slot_refresh_failure(&mut self, failed_slots: usize, now: Instant) {
162        if let Some(last) = self.last_nostr_slot_warning {
163            if now.duration_since(last) < NOSTR_SLOT_WARNING_SUPPRESS_INTERVAL {
164                return;
165            }
166        }
167        self.last_nostr_slot_warning = Some(now);
168        self.emit_security_warning(
169            &self.local_id.clone(),
170            SecurityWarningCode::NostrKeyPackageSlotExhausted,
171            format!(
172                "could not refill {} Nostr key-package publication slot(s); \
173                 cold contact over Nostr is degraded until it succeeds",
174                failed_slots
175            ),
176        );
177    }
178
179    /// Records a failed publication and pushes the slot's next attempt out.
180    ///
181    /// The delay doubles from [`NOSTR_SLOT_REFRESH_INTERVAL`] with each
182    /// consecutive failure and stops at [`NOSTR_PUBLICATION_MAX_BACKOFF`], so a
183    /// relay that rejects the kind is retried on a schedule that converges
184    /// instead of once per slot per minute forever. A slot quiet for longer
185    /// than the ceiling starts its streak over, so a genuine one-off failure
186    /// long after a bad patch still retries promptly.
187    fn note_publication_failure(&mut self, slot_id: &str, now: Instant) {
188        let entry = self
189            .nostr_publication_backoff
190            .entry(slot_id.to_string())
191            .or_insert(PublicationBackoff {
192                failures: 0,
193                last_failure: now,
194                retry_at: now,
195            });
196
197        if now.duration_since(entry.last_failure) > NOSTR_PUBLICATION_MAX_BACKOFF {
198            entry.failures = 1;
199        } else {
200            entry.failures = entry.failures.saturating_add(1);
201        }
202        entry.last_failure = now;
203
204        // The *first* failure retries on the next refresh: a relay hiccup or a
205        // socket dropped mid-flight should not cost a window. Only a slot that
206        // keeps failing climbs the ladder. Shift capped well below `u32`'s
207        // width; the ceiling clamps the result long before it matters.
208        let delay = if entry.failures <= 1 {
209            Duration::ZERO
210        } else {
211            let shift = (entry.failures - 2).min(16);
212            NOSTR_SLOT_REFRESH_INTERVAL
213                .saturating_mul(1u32 << shift)
214                .min(NOSTR_PUBLICATION_MAX_BACKOFF)
215        };
216        entry.retry_at = now + delay;
217    }
218
219    /// Whether `slot_id` is still waiting out a publication backoff.
220    fn publication_backoff_active(&self, slot_id: &str, now: Instant) -> bool {
221        self.nostr_publication_backoff
222            .get(slot_id)
223            .is_some_and(|backoff| now < backoff.retry_at)
224    }
225
226    /// Drops backoff state for slots that no longer exist.
227    ///
228    /// The keys are our own slot ids, of which there are at most
229    /// `NOSTR_KEY_PACKAGE_SLOTS` — but a lost slot map mints fresh ones, and
230    /// the abandoned entries would otherwise linger for the life of the
231    /// process.
232    fn prune_nostr_publication_backoff(&mut self) {
233        if self.nostr_publication_backoff.is_empty() {
234            return;
235        }
236        let live: HashSet<String> = self
237            .nostr_publication_slots
238            .iter()
239            .map(|slot| slot.slot_id.clone())
240            .collect();
241        self.nostr_publication_backoff
242            .retain(|slot_id, _| live.contains(slot_id));
243    }
244
245    /// Whether enough time has passed to re-scan the slots.
246    ///
247    /// Throttled for the same reason session reconciliation is: checking a slot
248    /// means asking the MLS layer whether its key package is still there, and
249    /// that is a storage read plus a TLS deserialize, validate, and provider
250    /// lookup — five of them per pass. On the raw tick that is a steady trickle
251    /// of Keychain/Keystore traffic to answer a question whose answer changes
252    /// only when a Welcome arrives.
253    ///
254    /// The interval bounds how long a consumed slot can stand stale, which is
255    /// the only thing being traded: a stranger who fetches the stale record in
256    /// that window builds a Welcome we cannot process, and retries once their
257    /// own send ladder brings them back around.
258    fn nostr_slot_refresh_due(&mut self) -> bool {
259        let now = Instant::now();
260        if let Some(last) = self.last_nostr_slot_refresh {
261            if now.duration_since(last) < NOSTR_SLOT_REFRESH_INTERVAL {
262                return false;
263            }
264        }
265        self.last_nostr_slot_refresh = Some(now);
266        true
267    }
268
269    /// Refreshes slot `index`, returning whether the persisted map changed.
270    fn refresh_one_slot(&mut self, index: usize, now: Instant) -> Result<bool> {
271        // The map is dense: position `i` always holds slot `i`. An earlier slot
272        // that failed this pass leaves a gap, and filling it out of order would
273        // put this slot's record at the wrong position — harmless today, since
274        // slots are identified by `slot_id` and never by index, but it makes the
275        // index mean two different things. Leave the gap for the next pass.
276        if index > self.nostr_publication_slots.len() {
277            return Ok(false);
278        }
279
280        let existing = self.nostr_publication_slots.get(index).cloned();
281
282        // Checked before the MLS read and before any mint: a slot whose
283        // publication keeps failing should cost nothing at all while it waits.
284        if let Some(slot) = existing.as_ref() {
285            if self.publication_backoff_active(&slot.slot_id, now) {
286                return Ok(false);
287            }
288        }
289
290        let live_package = match existing.as_ref() {
291            Some(slot) => self.load_publication_package(&slot.package_id)?,
292            None => None,
293        };
294
295        // A live package that has already been published this process is
296        // nothing to do — the common case, every tick, for every slot.
297        if let (Some(slot), Some(_)) = (existing.as_ref(), live_package.as_ref()) {
298            if self.nostr_published_slots.contains(&slot.slot_id) {
299                return Ok(false);
300            }
301        }
302
303        let slot_id = match existing.as_ref() {
304            Some(slot) => slot.slot_id.clone(),
305            None => new_slot_id(),
306        };
307
308        // `live_package` is `Some` only when `existing` is, so a live package
309        // never adds a slot to the map — it is the republish-this-process case.
310        let (bundle, map_changed) = match live_package {
311            Some(bundle) => (bundle, false),
312            None => {
313                let bundle = {
314                    let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
315                    let manager = mls
316                        .read()
317                        .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
318                    manager.generate_publication_key_package()?
319                };
320                (bundle, true)
321            }
322        };
323
324        let queued = match self.build_published_key_package_message(&bundle) {
325            Ok(message) => self
326                .transport_manager
327                .publish_nostr_key_package(&slot_id, &message),
328            Err(e) => Err(e),
329        };
330
331        if let Err(e) = queued {
332            // A package minted for a record that was never built or queued is
333            // reserved, so the push path will never hand it out, and no slot
334            // references it — nothing would reclaim it before its lifetime runs
335            // out. Left alone, a persistently failing build would strand fresh
336            // provider key material on every refresh.
337            if map_changed {
338                self.delete_publication_package(&bundle.package_id);
339            }
340            return Err(e);
341        }
342
343        self.nostr_published_slots.insert(slot_id.clone());
344
345        if map_changed {
346            let record = NostrPublicationSlot {
347                slot_id,
348                package_id: bundle.package_id,
349            };
350            if index < self.nostr_publication_slots.len() {
351                self.nostr_publication_slots[index] = record;
352            } else {
353                self.nostr_publication_slots.push(record);
354            }
355        }
356
357        Ok(map_changed)
358    }
359
360    /// Loads the key package standing in a slot, or `None` if it is gone.
361    ///
362    /// `None` covers consumed *and* expired: the MLS loader prunes both, and
363    /// the slot's response to either is identical.
364    fn load_publication_package(
365        &self,
366        package_id: &str,
367    ) -> Result<Option<offline_protocol_mls::KeyPackageBundle>> {
368        let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
369        let manager = mls
370            .read()
371            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
372        Ok(manager.key_package_by_id(package_id)?)
373    }
374
375    /// Drops a publication package that never made it into a queued record.
376    fn delete_publication_package(&self, package_id: &str) {
377        let Some(mls) = self.mls_manager.as_ref() else {
378            return;
379        };
380        let Ok(manager) = mls.read() else {
381            return;
382        };
383        if let Err(e) = manager.delete_key_package(package_id) {
384            warn!(
385                package_id = %package_id,
386                error = %e,
387                "Failed to reclaim an unpublished Nostr key package"
388            );
389        }
390    }
391
392    /// Builds the signed protocol message a published record carries.
393    ///
394    /// Self-addressed: a published record has no recipient in advance, which is
395    /// the point of it. The address is inside the Ed25519 signature, so it is
396    /// also not something a fetcher may rewrite — which is why the resolution
397    /// path feeds the message to the internal handler directly instead of
398    /// through the receive loop, whose relay decision keys on the recipient
399    /// being us. See [`Self::handle_resolved_key_package`].
400    fn build_published_key_package_message(
401        &mut self,
402        bundle: &offline_protocol_mls::KeyPackageBundle,
403    ) -> Result<Message> {
404        let payload = self.build_key_package_payload(bundle, false);
405        let serialized =
406            serde_json::to_string(&payload).map_err(|e| Error::Serialization(e.to_string()))?;
407        let content = format!("{}{}", internal_prefixes::KEY_PACKAGE, serialized);
408
409        let self_id = self.local_id.clone();
410        let mut message =
411            self.create_message(&self_id, content, Some(MessagePriority::Low), None)?;
412        // Nothing ACKs a published record: it is not delivered to anyone, it is
413        // left somewhere to be found. An outbox entry and a retry ladder behind
414        // it would retransmit against a peer that does not exist.
415        message.requires_ack = false;
416        // Signed under the older payload, on purpose, and this is the one
417        // place in the engine that says so.
418        //
419        // A published record has no known verifier: it is left on a relay for
420        // any stranger to fetch, so there is no peer whose advertised
421        // capability could pick the payload. Signing the freshness-bound one
422        // would make the record unverifiable to every install that has not
423        // upgraded, which for cold contact means the record does not work at
424        // all — its whole purpose is being readable by someone this node has
425        // never met.
426        //
427        // It is not the hole it looks like. The record carries
428        // `session_reset: false` (see the call above), so it holds no
429        // directive worth replaying, and re-delivering it can do nothing but
430        // re-offer a key package that is still within its own validity. What
431        // bounds *that* is the key package's lifetime, which is issue 396's
432        // subject rather than this one's.
433        let mls = self.mls_manager.as_ref().ok_or(Error::MlsNotInitialized)?;
434        let manager = mls
435            .read()
436            .map_err(|_| Error::Other("MLS lock poisoned".to_string()))?;
437        Self::sign_control_message_with(&mut message, &manager, false)?;
438        drop(manager);
439        Ok(message)
440    }
441
442    /// Handles a key-package record returned by a resolution query.
443    ///
444    /// Deliberately narrow: this channel answers exactly one question, so
445    /// anything that is not a key package is dropped rather than dispatched.
446    /// The records come from a public routing tag that anyone may publish to,
447    /// and a query returns whatever the relay holds there — without this gate,
448    /// a squatter could deliver any internal control frame through a path that
449    /// never went past the receive loop's dedup and block checks.
450    ///
451    /// What survives the gate is *not* trusted here either. It goes through
452    /// `process_internal_message_via`, so the Ed25519 signature and the
453    /// sender-address derivation decide whose key package it is: a record
454    /// planted at the queried peer's tag by somebody else is signed by *that*
455    /// party's key, which derives to *that* party's address, so it registers
456    /// under their identity — never under the peer we asked about. A record
457    /// signed by nobody is refused outright.
458    pub fn handle_resolved_key_package(&mut self, data: &[u8]) -> Result<()> {
459        let transport = self
460            .transport_manager
461            .get_transport(TransportType::Nostr)
462            .ok_or_else(|| Error::Other("Nostr transport not installed".to_string()))?;
463        let message = transport
464            .deserialize_message(data)
465            .map_err(|e| Error::Serialization(e.to_string()))?;
466
467        if !message.content.starts_with(internal_prefixes::KEY_PACKAGE) {
468            debug!(
469                sender = %message.sender,
470                "Discarding a non-key-package record returned by a Nostr resolution query"
471            );
472            return Ok(());
473        }
474
475        let sender = message.sender.as_str().to_string();
476        if self.is_user_blocked(&sender) {
477            debug!(sender = %sender, "Discarding a published key package from a blocked user");
478            return Ok(());
479        }
480
481        self.process_internal_message_via(&message, Some(TransportType::Nostr));
482        Ok(())
483    }
484}
485
486#[cfg(test)]
487impl OfflineProtocol {
488    /// The current publication slot map.
489    pub(crate) fn nostr_publication_slots_for_test(&self) -> Vec<NostrPublicationSlot> {
490        self.nostr_publication_slots.clone()
491    }
492
493    /// Drives the slot-exhaustion report directly, standing in for a refresh
494    /// pass whose slots failed — which needs a broken MLS or storage layer to
495    /// reach naturally.
496    pub(crate) fn report_nostr_slot_refresh_failure_for_test(&mut self, failed_slots: usize) {
497        self.report_nostr_slot_refresh_failure(failed_slots, Instant::now());
498    }
499
500    /// Lets the next tick actually re-scan.
501    ///
502    /// Every test that ticks twice must call this, or the throttle makes the
503    /// second pass a no-op — which for an idempotency test looks exactly like
504    /// the property under test holding.
505    pub(crate) fn reset_nostr_slot_throttle_for_test(&mut self) {
506        self.last_nostr_slot_refresh = None;
507    }
508
509    /// Points slot `index` at `package_id` and marks it unpublished, standing
510    /// in for "a Welcome consumed this slot's key package" without needing a
511    /// real session handshake. Returns the slot's stable id.
512    pub(crate) fn force_nostr_slot_package_for_test(
513        &mut self,
514        index: usize,
515        package_id: &str,
516    ) -> String {
517        let slot_id = self.nostr_publication_slots[index].slot_id.clone();
518        self.nostr_publication_slots[index].package_id = package_id.to_string();
519        slot_id
520    }
521}
522
523/// Mints a stable, random addressable-slot id.
524///
525/// Random rather than an index, following the same reasoning Marmot gives for
526/// its publication-slot ids: a `d` tag of `0`..`4` would advertise both the
527/// convention and the slot count to anyone who fetches one record, and would
528/// make two installs' records trivially alignable.
529fn new_slot_id() -> String {
530    uuid::Uuid::new_v4().simple().to_string()
531}
532
533impl OfflineProtocol {
534    /// Persists the publication slot map.
535    pub(crate) fn persist_nostr_publication_slots(&mut self) {
536        let Some(storage) = self.protocol_state_storage.clone() else {
537            return;
538        };
539        let bytes = match serde_json::to_vec(&self.nostr_publication_slots) {
540            Ok(bytes) => bytes,
541            Err(e) => {
542                warn!(error = %e, "Failed to serialize Nostr publication slots");
543                return;
544            }
545        };
546        if let Err(e) = self.write_state_record(
547            storage.as_ref(),
548            storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
549            storage_keys::NOSTR_KEY_PACKAGE_SLOTS_ID,
550            &bytes,
551        ) {
552            warn!(error = %e, "Failed to persist Nostr publication slots");
553        }
554    }
555
556    /// Restores the publication slot map.
557    ///
558    /// Every failure mode lands on the same benign outcome — an empty map, so
559    /// the next tick mints fresh slots under fresh ids. The records left at the
560    /// abandoned slot ids are not orphans that need collecting: they expire
561    /// with the key packages inside them, and until then they resolve to
562    /// packages this install still holds.
563    pub(crate) fn restore_nostr_publication_slots(&mut self) {
564        let Some(storage) = self.protocol_state_storage.clone() else {
565            return;
566        };
567
568        let data = match self.read_state_record(
569            storage.as_ref(),
570            storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
571            storage_keys::NOSTR_KEY_PACKAGE_SLOTS_ID,
572        ) {
573            Ok(Some(data)) => data,
574            Ok(None) => return,
575            Err(e) => {
576                warn!(error = %e, "Failed to read Nostr publication slots; starting fresh");
577                return;
578            }
579        };
580
581        match serde_json::from_slice::<Vec<NostrPublicationSlot>>(&data) {
582            Ok(slots) => {
583                // Truncate rather than reject if the configured slot count has
584                // shrunk: the surplus records expire on their own.
585                self.nostr_publication_slots = slots;
586                self.nostr_publication_slots
587                    .truncate(NOSTR_KEY_PACKAGE_SLOTS);
588                debug!(
589                    slots = self.nostr_publication_slots.len(),
590                    "Restored Nostr publication slots"
591                );
592            }
593            Err(e) => {
594                warn!(error = %e, "Corrupted Nostr publication slots; starting fresh");
595            }
596        }
597    }
598}