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 self.sign_control_message(&mut message)?;
417 Ok(message)
418 }
419
420 /// Handles a key-package record returned by a resolution query.
421 ///
422 /// Deliberately narrow: this channel answers exactly one question, so
423 /// anything that is not a key package is dropped rather than dispatched.
424 /// The records come from a public routing tag that anyone may publish to,
425 /// and a query returns whatever the relay holds there — without this gate,
426 /// a squatter could deliver any internal control frame through a path that
427 /// never went past the receive loop's dedup and block checks.
428 ///
429 /// What survives the gate is *not* trusted here either. It goes through
430 /// `process_internal_message_via`, so the Ed25519 signature and the
431 /// sender-address derivation decide whose key package it is: a record
432 /// planted at the queried peer's tag by somebody else is signed by *that*
433 /// party's key, which derives to *that* party's address, so it registers
434 /// under their identity — never under the peer we asked about. A record
435 /// signed by nobody is refused outright.
436 pub fn handle_resolved_key_package(&mut self, data: &[u8]) -> Result<()> {
437 let transport = self
438 .transport_manager
439 .get_transport(TransportType::Nostr)
440 .ok_or_else(|| Error::Other("Nostr transport not installed".to_string()))?;
441 let message = transport
442 .deserialize_message(data)
443 .map_err(|e| Error::Serialization(e.to_string()))?;
444
445 if !message.content.starts_with(internal_prefixes::KEY_PACKAGE) {
446 debug!(
447 sender = %message.sender,
448 "Discarding a non-key-package record returned by a Nostr resolution query"
449 );
450 return Ok(());
451 }
452
453 let sender = message.sender.as_str().to_string();
454 if self.is_user_blocked(&sender) {
455 debug!(sender = %sender, "Discarding a published key package from a blocked user");
456 return Ok(());
457 }
458
459 self.process_internal_message_via(&message, Some(TransportType::Nostr));
460 Ok(())
461 }
462}
463
464#[cfg(test)]
465impl OfflineProtocol {
466 /// The current publication slot map.
467 pub(crate) fn nostr_publication_slots_for_test(&self) -> Vec<NostrPublicationSlot> {
468 self.nostr_publication_slots.clone()
469 }
470
471 /// Drives the slot-exhaustion report directly, standing in for a refresh
472 /// pass whose slots failed — which needs a broken MLS or storage layer to
473 /// reach naturally.
474 pub(crate) fn report_nostr_slot_refresh_failure_for_test(&mut self, failed_slots: usize) {
475 self.report_nostr_slot_refresh_failure(failed_slots, Instant::now());
476 }
477
478 /// Lets the next tick actually re-scan.
479 ///
480 /// Every test that ticks twice must call this, or the throttle makes the
481 /// second pass a no-op — which for an idempotency test looks exactly like
482 /// the property under test holding.
483 pub(crate) fn reset_nostr_slot_throttle_for_test(&mut self) {
484 self.last_nostr_slot_refresh = None;
485 }
486
487 /// Points slot `index` at `package_id` and marks it unpublished, standing
488 /// in for "a Welcome consumed this slot's key package" without needing a
489 /// real session handshake. Returns the slot's stable id.
490 pub(crate) fn force_nostr_slot_package_for_test(
491 &mut self,
492 index: usize,
493 package_id: &str,
494 ) -> String {
495 let slot_id = self.nostr_publication_slots[index].slot_id.clone();
496 self.nostr_publication_slots[index].package_id = package_id.to_string();
497 slot_id
498 }
499}
500
501/// Mints a stable, random addressable-slot id.
502///
503/// Random rather than an index, following the same reasoning Marmot gives for
504/// its publication-slot ids: a `d` tag of `0`..`4` would advertise both the
505/// convention and the slot count to anyone who fetches one record, and would
506/// make two installs' records trivially alignable.
507fn new_slot_id() -> String {
508 uuid::Uuid::new_v4().simple().to_string()
509}
510
511impl OfflineProtocol {
512 /// Persists the publication slot map.
513 pub(crate) fn persist_nostr_publication_slots(&mut self) {
514 let Some(storage) = self.protocol_state_storage.clone() else {
515 return;
516 };
517 let bytes = match serde_json::to_vec(&self.nostr_publication_slots) {
518 Ok(bytes) => bytes,
519 Err(e) => {
520 warn!(error = %e, "Failed to serialize Nostr publication slots");
521 return;
522 }
523 };
524 if let Err(e) = self.write_state_record(
525 storage.as_ref(),
526 storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
527 storage_keys::NOSTR_KEY_PACKAGE_SLOTS_ID,
528 &bytes,
529 ) {
530 warn!(error = %e, "Failed to persist Nostr publication slots");
531 }
532 }
533
534 /// Restores the publication slot map.
535 ///
536 /// Every failure mode lands on the same benign outcome — an empty map, so
537 /// the next tick mints fresh slots under fresh ids. The records left at the
538 /// abandoned slot ids are not orphans that need collecting: they expire
539 /// with the key packages inside them, and until then they resolve to
540 /// packages this install still holds.
541 pub(crate) fn restore_nostr_publication_slots(&mut self) {
542 let Some(storage) = self.protocol_state_storage.clone() else {
543 return;
544 };
545
546 let data = match self.read_state_record(
547 storage.as_ref(),
548 storage_keys::NOSTR_KEY_PACKAGE_SLOTS,
549 storage_keys::NOSTR_KEY_PACKAGE_SLOTS_ID,
550 ) {
551 Ok(Some(data)) => data,
552 Ok(None) => return,
553 Err(e) => {
554 warn!(error = %e, "Failed to read Nostr publication slots; starting fresh");
555 return;
556 }
557 };
558
559 match serde_json::from_slice::<Vec<NostrPublicationSlot>>(&data) {
560 Ok(slots) => {
561 // Truncate rather than reject if the configured slot count has
562 // shrunk: the surplus records expire on their own.
563 self.nostr_publication_slots = slots;
564 self.nostr_publication_slots
565 .truncate(NOSTR_KEY_PACKAGE_SLOTS);
566 debug!(
567 slots = self.nostr_publication_slots.len(),
568 "Restored Nostr publication slots"
569 );
570 }
571 Err(e) => {
572 warn!(error = %e, "Corrupted Nostr publication slots; starting fresh");
573 }
574 }
575 }
576}