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}