offline_protocol_transport/nostr.rs
1//! Nostr relay transport queue engine.
2//!
3//! Censorship-resistant, decentralized messaging over Nostr relays
4//! (WebSockets). No relay connection is opened here: the platform side
5//! manages the actual WebSocket connections and subscriptions; the Rust
6//! side manages queues, metrics, event signing, and the confirmation loop.
7//!
8//! The bridge contract: the platform reports relay connectivity via
9//! [`NostrTransport::on_status_changed`], drains signed events with
10//! [`NostrTransport::get_next_signed_event`] (woken by the
11//! [`NostrTransport::set_on_messages_available`] callback) and submits them
12//! to the relays, correlates relay `OK` responses back via
13//! [`NostrTransport::confirm_sent`] /
14//! [`NostrTransport::report_send_failure`], and injects inbound event
15//! payloads via [`NostrTransport::on_data_received`].
16//!
17//! Addressing uses public routing tags derived from this device's address
18//! ([`nostr_crypto::routing_tag_for_address`]); event signing uses a
19//! per-install secret key that starts out ephemeral and is upgraded to a
20//! persisted identity via [`NostrTransport::install_signing_secret`].
21
22use crate::constants::{
23 NOSTR_CLOCK_SKEW_MARGIN_SECS, NOSTR_CONNECTION_TIMEOUT_SECS, NOSTR_CREATED_AT_JITTER_SECS,
24 NOSTR_FIRST_RUN_BACKFILL_SECS, NOSTR_FUTURE_DATED_TOLERANCE_SECS, NOSTR_MAX_PAYLOAD_SIZE,
25 NOSTR_MAX_TRACKED_PEER_KEYS, NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS,
26};
27use crate::nostr_crypto::{self, now_unix_secs, NostrKeypair};
28use crate::{
29 Error, Result, SharedCallback, Transport, TransportMetrics, TransportStatus, TransportType,
30};
31use base64::Engine;
32use offline_protocol_core::time::{Duration, Instant};
33use offline_protocol_core::{Address, Message, MutexExt, RwLockExt, Username};
34use std::borrow::Cow;
35use std::collections::{HashMap, HashSet, VecDeque};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::{Arc, Mutex, RwLock};
38
39use crate::common::recalculate_delivery_ratios;
40
41/// Maximum number of signing attempts before a message is permanently failed.
42const MAX_SIGN_RETRIES: u8 = 3;
43
44/// Marks a synthetic message id belonging to a published key-package record
45/// rather than a protocol message.
46///
47/// Publications ride the ordinary send queue — the bridge treats `event_json`
48/// as opaque, so nothing platform-side needed to change to publish them — but
49/// they have no outbox entry, no ACK, and no retry ladder behind them. The
50/// prefix keeps that visible in logs and confirmation callbacks; the engine's
51/// `on_transport_send_confirmed` already returns early for an id it holds no
52/// welcome lifecycle for, so a publication's confirm is a no-op by
53/// construction rather than by a check that could be forgotten.
54pub const NOSTR_PUBLICATION_ID_PREFIX: &str = "__nostr_kp__:";
55
56/// Marks a synthetic message id belonging to a published username discovery
57/// record.
58///
59/// A second discriminator beside [`NOSTR_PUBLICATION_ID_PREFIX`], and the
60/// reason the classifier returns an enum rather than an `Option`.
61pub const NOSTR_DISCOVERY_ID_PREFIX: &str = "__nostr_disc__:";
62
63/// Marks a synthetic message id belonging to a NIP-09 deletion request.
64///
65/// Deletions are the best-effort half of a retraction and nothing recovers when
66/// one fails, but they still ride the send queue and so still need to be kept
67/// out of the delivery metrics. Giving them a discriminator is what makes that
68/// automatic rather than a special case someone has to remember.
69pub const NOSTR_DELETION_ID_PREFIX: &str = "__nostr_del__:";
70
71/// What a synthetic message id denotes, when it is one.
72///
73/// # Why this is an enum and not two `Option`-returning helpers
74///
75/// This classifier is consulted by every message-path side effect —
76/// [`NostrTransport::confirm_sent`], [`NostrTransport::report_send_failure`],
77/// `drain_expired_pending` and `fail_all_pending` — and getting *one* of them
78/// wrong is a shipped, previously-diagnosed bug: publication outcomes leaking
79/// into [`TransportMetrics`] poison DORS scoring, because DORS scores this
80/// transport on `success_count / (success_count + failure_count)`, those
81/// counters are lifetime totals with no decay, and an idle install publishes
82/// far more than it sends. A relay that rejects the kind drives the ratio
83/// toward zero and makes DORS deprioritise Nostr for traffic that delivers
84/// fine; publications that succeed equally mask real message failures.
85///
86/// A second record type is exactly the change that historically half-wires:
87/// adding another prefix plus another `Option` helper leaves four call sites
88/// that each *silently* keep compiling while consulting only the old one.
89/// Returning a single enum means every site matches, so a third record type
90/// added later fails to compile at each of them instead of quietly scoring
91/// itself as delivery. That is the whole point of the shape.
92///
93/// The metrics rule is kind-independent — any variant stays out of
94/// [`TransportMetrics`] — while failure *routing* differs per variant, which is
95/// why the payload rides along. See
96/// `test_publication_outcomes_stay_out_of_the_delivery_metrics` and
97/// `test_discovery_publication_outcomes_stay_out_of_the_delivery_metrics`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub(crate) enum SyntheticPublication {
100 /// A key-package record. Carries the addressable slot id, which the engine
101 /// needs in order to mark the slot unpublished and refill it.
102 KeyPackage(String),
103 /// A username discovery record. Carries the discovery tag it was published
104 /// at, which is both its `#p` tag and its `d` tag.
105 Discovery(String),
106 /// A NIP-09 deletion request, the best-effort half of a retraction.
107 ///
108 /// Carries no payload because nothing acts on its outcome: the durable half
109 /// of a retraction is the tombstone, which replaces the claim through the
110 /// addressable rule rather than through a relay's cooperation. It is a
111 /// variant rather than an untagged id so the metrics exclusion covers it by
112 /// construction.
113 Deletion,
114}
115
116/// Classifies a message id, returning `None` for an ordinary protocol message.
117///
118/// See [`SyntheticPublication`] for why this exists and what every caller owes
119/// it.
120pub(crate) fn synthetic_publication(message_id: &str) -> Option<SyntheticPublication> {
121 if let Some(slot_id) = message_id.strip_prefix(NOSTR_PUBLICATION_ID_PREFIX) {
122 return Some(SyntheticPublication::KeyPackage(slot_id.to_string()));
123 }
124 if let Some(tag) = message_id.strip_prefix(NOSTR_DISCOVERY_ID_PREFIX) {
125 return Some(SyntheticPublication::Discovery(tag.to_string()));
126 }
127 if message_id.starts_with(NOSTR_DELETION_ID_PREFIX) {
128 return Some(SyntheticPublication::Deletion);
129 }
130 None
131}
132
133/// Maximum peers queued for key-package resolution at once.
134///
135/// Resolution is triggered by a send to a peer whose per-install key we lack,
136/// and the recipient of a send is wire-influenced, so this is bounded like
137/// every other such queue. Overflow drops the *newest* request: the queued
138/// ones are already-attempted sends waiting on an answer, and a peer whose
139/// request is dropped simply takes the bootstrap leg and is retried on the
140/// next send to them.
141const MAX_PENDING_RESOLUTIONS: usize = 64;
142
143/// Minimum interval between resolution attempts for the same peer.
144///
145/// Without this, every frame to a peer who has published nothing would mint
146/// another relay round-trip that cannot succeed. The bootstrap leg carries
147/// those sends meanwhile, so the only cost of waiting is that the metadata
148/// upgrade lands later.
149const RESOLUTION_RETRY_INTERVAL: Duration = Duration::from_secs(300);
150
151/// Maximum events one resolution query will accept, opened or not.
152///
153/// The REQ asks each relay for [`NOSTR_KEY_PACKAGE_SLOTS`] records, but a relay
154/// is free to ignore `limit` and stream indefinitely, and the query is
155/// broadcast so every connected relay answers under the same subscription id.
156/// Each record that opens costs the key-package handler two durable
157/// secure-storage writes, so the ceiling has to be ours rather than the
158/// relay's. Sized for a generous fan-out — well above `slots × relays` for any
159/// real configuration — since exceeding it only costs the metadata upgrade,
160/// which falls back to the bootstrap leg.
161const MAX_QUERY_EVENTS: usize = 64;
162
163/// A resolution query the platform is currently running.
164#[derive(Debug)]
165struct ActiveQuery {
166 /// What this query is resolving, and the key that opens what comes back.
167 subject: QuerySubject,
168 /// Event ids already taken for this query. The query is broadcast, so the
169 /// same record arrives once per relay, and opening it more than once
170 /// re-runs the key-package handler's durable writes for no gain. Bounded
171 /// by `delivered` below, which is checked before anything is inserted.
172 seen_events: HashSet<String>,
173 /// Events delivered for this query so far, whether or not they opened.
174 delivered: usize,
175}
176
177/// What an active query is asking for.
178///
179/// The variant decides three things at once — which kind is accepted, which
180/// derivable key opens the payload, and which engine handler the plaintext goes
181/// to — so carrying them together is what stops a discovery record from being
182/// fed to the key-package handler, or vice versa. A record delivered under the
183/// wrong subject is not merely useless: `handle_resolved_key_package` is a
184/// deliberately narrow gate, and widening the set of things that can reach it
185/// is exactly what that gate exists to prevent.
186#[derive(Debug, Clone)]
187enum QuerySubject {
188 /// A peer's published key packages.
189 ///
190 /// Carried as a parsed [`Address`] so the record-seal derivation this feeds
191 /// cannot be handed a string that was never validated.
192 KeyPackage(Address),
193 /// The devices claiming a username.
194 ///
195 /// Carried as a parsed [`Username`] for the same reason: the
196 /// discovery-seal derivation must not be handed an unnormalized name.
197 Discovery(Username),
198}
199
200/// What a username-resolution request did.
201///
202/// Exists because "no query was queued" is three different promises to the
203/// caller, and only one of them means an answer is still coming. A bare `false`
204/// makes an app that awaits the resolution event indistinguishable from an app
205/// that hangs.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum ResolveRequest {
208 /// Newly queued. A resolution event will follow.
209 Queued,
210 /// A lookup for this name is already queued. Its event answers both
211 /// callers, so this is a success from the caller's point of view.
212 AlreadyQueued,
213 /// Username discovery is off. Nothing was sent to a relay, and no event
214 /// will ever arrive for this name.
215 Disabled,
216 /// The resolve queue is at its ceiling. Nothing will arrive for this name;
217 /// the caller may retry once earlier lookups drain.
218 QueueFull,
219}
220
221/// What opening a query event produced.
222#[derive(Debug, Clone)]
223pub enum ResolvedRecord {
224 /// A key-package record: the author's Nostr pubkey and the decrypted
225 /// protocol message bytes.
226 KeyPackage {
227 /// The event's author key.
228 author: String,
229 /// The decrypted protocol message.
230 plaintext: Vec<u8>,
231 },
232 /// A discovery record: the username queried, the author key it must be
233 /// bound to, and the decrypted record body.
234 Discovery {
235 /// The username this query resolved.
236 username: Username,
237 /// The event's author key, which the record's `nostr_author` field must
238 /// equal.
239 author: String,
240 /// The decrypted `DiscoveryRecordV1` or tombstone body.
241 plaintext: Vec<u8>,
242 },
243}
244
245/// A key-package record waiting to be published to the relays.
246#[derive(Debug, Clone)]
247struct PendingPublication {
248 /// The addressable slot (`d` tag) this record occupies. Republishing the
249 /// same slot replaces the record rather than adding to it.
250 slot_id: String,
251 /// Serialized protocol message carrying the signed `KeyPackagePayload`.
252 payload: Vec<u8>,
253}
254
255/// A username discovery record waiting to be published.
256#[derive(Debug, Clone)]
257struct PendingDiscoveryPublication {
258 /// The username this record claims. Held parsed so the tag and seal
259 /// derivations at drain time cannot be handed an unnormalized string.
260 username: Username,
261 /// The serialized `DiscoveryRecordV1`, or a tombstone for a retraction.
262 payload: Vec<u8>,
263 /// Whether this publication is a retraction.
264 ///
265 /// A retraction publishes a tombstone into the addressable slot *and*
266 /// emits a best-effort NIP-09 deletion. Carried as a flag rather than a
267 /// separate queue because the two share the whole drain path and differ
268 /// only in that one extra event.
269 retraction: bool,
270}
271
272/// A relay query the platform should issue on the transport's behalf.
273#[derive(Debug, Clone)]
274pub struct NostrQuery {
275 /// Correlation id; also the NIP-01 subscription id the platform must use,
276 /// so inbound events can be routed back to the right request.
277 pub query_id: String,
278 /// Complete `["REQ", ...]` JSON string for the relay WebSocket.
279 pub req_json: String,
280 /// The username being resolved, when this is a discovery query.
281 ///
282 /// Carried out so the engine can open an accumulator keyed by `query_id`
283 /// at the moment the query is minted. Doing it here rather than on the
284 /// first answer means a query that returns *nothing* still emits an empty
285 /// result rather than silently never completing — "no such name" is an
286 /// answer, and an app waiting on one deserves to receive it.
287 pub discovery_username: Option<Username>,
288}
289
290/// Mints a subscription id for a resolution query.
291///
292/// Random rather than sequential: the id goes on the wire in the REQ, and a
293/// counter would tell every relay how many peers this install has looked up.
294/// A failed RNG degrades to a fixed id, which costs correlation between one
295/// install's queries — never correctness, since the platform routes on
296/// whatever id it was handed.
297fn new_query_id() -> String {
298 use rand_core::{OsRng, RngCore};
299 let mut buf = [0u8; 16];
300 if OsRng.try_fill_bytes(&mut buf).is_err() {
301 tracing::warn!("OS RNG unavailable for Nostr query id; using a fixed one");
302 return "offline-protocol-kp-query".to_string();
303 }
304 hex::encode(buf)
305}
306
307/// A signed Nostr event ready for relay submission, together with the
308/// metadata the platform needs for confirmation tracking.
309#[derive(Debug, Clone)]
310pub struct SignedNostrEvent {
311 /// Protocol message ID (for confirm/fail callbacks).
312 pub message_id: String,
313 /// Nostr event ID (SHA-256 hex). The platform uses this to correlate
314 /// relay `["OK", event_id, ...]` responses back to the message.
315 pub event_id: String,
316 /// Complete `["EVENT", {...}]` JSON string for the relay WebSocket.
317 pub event_json: String,
318}
319
320/// Nostr transport configuration.
321#[derive(Debug, Clone)]
322pub struct NostrConfig {
323 /// Relay URLs to connect to (`wss://`), chosen by the application. The
324 /// SDK names none.
325 /// The platform manages actual WebSocket connections.
326 pub relay_urls: Vec<String>,
327 /// Connection timeout for reaching Nostr relays.
328 pub connection_timeout: Duration,
329 /// Enable automatic reconnection to relays.
330 pub auto_reconnect: bool,
331 /// Reconnection delay.
332 pub reconnect_delay: Duration,
333 /// Maximum reconnection attempts (0 = infinite).
334 pub max_reconnect_attempts: u32,
335}
336
337impl Default for NostrConfig {
338 fn default() -> Self {
339 Self {
340 relay_urls: Vec::new(),
341 connection_timeout: Duration::from_secs(NOSTR_CONNECTION_TIMEOUT_SECS),
342 auto_reconnect: true,
343 reconnect_delay: Duration::from_secs(5),
344 max_reconnect_attempts: 0,
345 }
346 }
347}
348
349/// Nostr relay transport implementation.
350///
351/// Provides connectivity via Nostr relays for censorship-resistant,
352/// decentralized messaging. The platform bridges to Nostr relay
353/// WebSocket connections and handles event signing and subscriptions.
354///
355/// ## Lock ordering
356///
357/// When acquiring more than one lock in a single scope, follow this order:
358///
359/// 1. `status`
360/// 2. `pending_confirmation`
361/// 3. `send_queue`
362/// 4. `metrics`
363/// 5. `receive_queue`
364/// 6. `reconnect_attempts` / `platform_handle`
365///
366/// `keypair`, `receive_watermark`, `peer_nostr_pubkeys`, `sealing_enabled` and
367/// `failed_publications` are leaf locks: they are only ever held in a narrow
368/// scope with no other lock acquisition inside. In particular the sealing path
369/// releases each before calling into the crypto layer, so a slow ECDH never
370/// blocks the send queue, and the publication-failure paths collect their slot
371/// ids under `pending_confirmation` and record them only after releasing it.
372pub struct NostrTransport {
373 device_id: String,
374 /// Per-install signing keypair. Ephemeral (random per process) until the
375 /// engine installs the persisted secret via
376 /// [`Self::install_signing_secret`].
377 keypair: RwLock<NostrKeypair>,
378 /// This device's public routing tag (derived from `device_id`); peers
379 /// address us by putting it in the `#p` tag, we subscribe on it.
380 routing_tag: String,
381 /// Newest `created_at` (unix seconds) of any relay event this install has
382 /// accepted, or `None` before the first one — the high-water mark the
383 /// subscription's `since` is derived from. See
384 /// [`Self::advance_receive_watermark`].
385 ///
386 /// Owned here because [`Self::create_subscription`] needs it, but it is the
387 /// *engine* that gives it durability: `OfflineProtocol` persists it as a
388 /// protocol-state record and re-installs it on launch. Without that this is
389 /// a per-process value and every cold start replays a full backfill window.
390 receive_watermark: Mutex<Option<i64>>,
391 /// The publicly computable keypair for our own address.
392 ///
393 /// Held to unseal bootstrap-leg frames and to seal our published records —
394 /// see [`nostr_crypto::record_seal_keypair_for_address`], which documents
395 /// why this is not a secret and must never authenticate anything.
396 ///
397 /// Its public half used to *be* `routing_tag`. It is now a separate,
398 /// domain-separated derivation, so the two are unequal and neither can be
399 /// mistaken for the other.
400 record_seal_keypair: NostrKeypair,
401 /// Peer user ID → that peer's real per-install Nostr public key, learned
402 /// from the `nostr_pubkey` field of their signed key package. Populated by
403 /// the engine via [`Self::set_peer_nostr_pubkey`]; a peer absent here takes
404 /// the bootstrap path.
405 peer_nostr_pubkeys: RwLock<HashMap<String, String>>,
406 /// Whether outgoing frames are sealed into gift wraps. Mirrors
407 /// `TransportConfig::nostr_sealing_enabled`; the receive path always
408 /// accepts both forms regardless.
409 sealing_enabled: Mutex<bool>,
410 /// Whether this install publishes key-package records and resolves peers'.
411 /// Mirrors `TransportConfig::nostr_cold_contact_enabled`.
412 ///
413 /// Gates both halves together on purpose: resolving records nobody
414 /// publishes is pure round-trips, and publishing into a fleet that never
415 /// resolves is a standing beacon bought for nothing.
416 cold_contact_enabled: Mutex<bool>,
417 /// Key-package records waiting to go out, drained ahead of the message
418 /// queue so a fresh slot replaces a consumed one before the traffic that
419 /// depends on it.
420 publication_queue: Mutex<VecDeque<PendingPublication>>,
421 /// Slot ids whose publication left this queue but never reached a relay,
422 /// waiting for the engine to drain them via
423 /// [`Self::take_failed_publications`].
424 ///
425 /// The engine marks a slot published when it *queues* the record, because
426 /// that is the only point it hears about. Without this channel every
427 /// failure after that point — a build error, a relay rejection, a
428 /// confirmation timeout, a disconnect — would leave the slot marked
429 /// published for the life of the process, so the relays hold nothing (or
430 /// last process's stale record) while the engine believes the slot is
431 /// healthy. That is precisely the silently-stale record the publication
432 /// design exists to prevent.
433 ///
434 /// Bounded by construction: the keys are our own slot ids, of which the
435 /// engine keeps at most `NOSTR_KEY_PACKAGE_SLOTS`.
436 failed_publications: Mutex<HashSet<String>>,
437 /// Whether this install publishes and resolves username discovery records.
438 /// Mirrors `TransportConfig::nostr_username_discovery_enabled`.
439 ///
440 /// Off by default, unlike cold contact. Publishing a discovery record binds
441 /// a human-readable name to an address in a public place, which is
442 /// materially more disclosure than the key-package record's "an install
443 /// with this tag exists", and it is the kind of decision an app should make
444 /// deliberately rather than inherit.
445 discovery_enabled: Mutex<bool>,
446 /// Username discovery records waiting to go out.
447 ///
448 /// Separate from [`Self::publication_queue`] rather than a variant inside
449 /// it, because the two drain in a fixed order (key packages first) and
450 /// share no state: a discovery claim republished while a key-package slot
451 /// is queued must not displace it.
452 discovery_queue: Mutex<VecDeque<PendingDiscoveryPublication>>,
453 /// Discovery tags whose publication left the queue but never reached a
454 /// relay, waiting for the engine to drain them via
455 /// [`Self::take_failed_discovery_publications`].
456 ///
457 /// The same channel [`Self::failed_publications`] provides for slots, and
458 /// for the same reason: the engine marks a claim published when it queues
459 /// it, so without this a failure after that point leaves the claim marked
460 /// healthy while the relays hold nothing.
461 ///
462 /// Bounded by construction: an install claims one username, so this holds
463 /// at most one entry in practice and is keyed by our own derived tag in
464 /// every case.
465 failed_discovery_publications: Mutex<HashSet<String>>,
466 /// Discovery tags whose publication a relay acknowledged, waiting for the
467 /// engine to drain them via
468 /// [`Self::take_confirmed_discovery_publications`].
469 ///
470 /// The mirror of [`Self::failed_discovery_publications`], and it exists for
471 /// the half of the lifecycle that failure reporting alone cannot express: a
472 /// **retraction** is owed until it lands, so the engine must keep the
473 /// retracted name until something says the tombstone reached a relay.
474 /// Absence of a failure is not that signal — a report that never arrives is
475 /// indistinguishable from a publication still in flight — so the engine
476 /// would either forget the name too early (stranding a claim in a public
477 /// directory) or retract forever.
478 ///
479 /// Bounded by the same construction as its failure twin: entries are this
480 /// install's own derived tags, one per name it has claimed.
481 confirmed_discovery_publications: Mutex<HashSet<String>>,
482 /// Ready-built NIP-09 deletion events, as `(event_id, relay_json)`.
483 ///
484 /// Queued by a retraction after its tombstone is built, so the deletion
485 /// follows the tombstone rather than racing it. Nothing retries these.
486 pending_deletions: Mutex<VecDeque<(String, String)>>,
487 /// Peer addresses whose published key packages we want fetched.
488 ///
489 /// Holds parsed [`Address`]es rather than strings: an entry becomes the
490 /// `#p` tag of a relay query, so admitting one that is not an address
491 /// would publish a guessable label. `enqueue_resolution` is the only
492 /// writer and parses there, which is what lets `next_query` derive a tag
493 /// without re-validating or re-deciding what to do when it fails.
494 resolve_queue: Mutex<VecDeque<Address>>,
495 /// Usernames whose discovery records we want fetched.
496 ///
497 /// Holds parsed [`Username`]s for the same reason [`Self::resolve_queue`]
498 /// holds parsed [`Address`]es: an entry becomes the `#p` tag of a relay
499 /// query, and an unnormalized name derives a tag nobody publishes at.
500 discovery_resolve_queue: Mutex<VecDeque<Username>>,
501 /// Query id → the query's state. An inbound event is meaningless without
502 /// this: the id tells us whose derivable key opens it, and carries the
503 /// per-query dedup and delivery ceiling with it.
504 active_queries: Mutex<HashMap<String, ActiveQuery>>,
505 /// Peer user id → last resolution attempt, rate-limiting repeats for a
506 /// peer who has published nothing.
507 resolve_attempts: Mutex<HashMap<String, Instant>>,
508 config: NostrConfig,
509 status: Arc<Mutex<TransportStatus>>,
510 receive_queue: Arc<Mutex<VecDeque<Message>>>,
511 send_queue: Arc<Mutex<VecDeque<Message>>>,
512 /// Messages dequeued by the platform but not yet confirmed as sent.
513 pending_confirmation: Arc<Mutex<HashMap<String, Instant>>>,
514 /// Tracks how many times signing has been attempted for a given message ID.
515 /// Entries are removed on success or after reaching [`MAX_SIGN_RETRIES`].
516 sign_retry_counts: Arc<Mutex<HashMap<String, u8>>>,
517 metrics: Arc<Mutex<TransportMetrics>>,
518 reconnect_attempts: Arc<Mutex<u32>>,
519 platform_handle: Arc<Mutex<Option<usize>>>,
520 on_messages_available: SharedCallback,
521 /// Whether the [`Transport::get_next_message`] refusal has already been
522 /// logged for this transport.
523 ///
524 /// The refusal reports a *misintegration* — a caller polling the wrong
525 /// drain — which is a standing condition, not an event: the caller is on a
526 /// timer or a wake callback and will hit it again on every tick. One
527 /// report identifies the bug; the rest are noise that would bury it.
528 generic_poll_refusal_logged: AtomicBool,
529}
530
531impl NostrTransport {
532 /// Creates a new Nostr transport with default configuration.
533 pub fn new(device_id: impl Into<String>) -> Result<Self> {
534 Self::with_config(device_id, NostrConfig::default())
535 }
536
537 /// Creates a new Nostr transport with custom configuration.
538 ///
539 /// # `device_id` must be this device's derived address
540 ///
541 /// Not a profile, not an app-chosen string: the id given here is the sole
542 /// preimage of the routing tag, which is the label this device subscribes
543 /// on and the one peers publish to. Both ways of getting it wrong are
544 /// silent, and one of them is a disclosure:
545 ///
546 /// - a *guessable* preimage (a username-shaped profile) puts a label on a
547 /// public relay that anyone can recompute from the name, which is the
548 /// property the derived address exists to remove;
549 /// - a preimage that is merely *different* from the address peers know
550 /// addresses this device where nobody writes, so every Nostr-carried
551 /// frame is simply never seen — no error, no event, nothing arriving.
552 ///
553 /// Neither failure surfaces anywhere at runtime, so the id is refused
554 /// here rather than hashed. See
555 /// [`test_construction_refuses_an_id_that_is_not_an_address`](self#tests),
556 /// and — for the caller that made this necessary —
557 /// `test_nostr_is_absent_until_the_identity_rebuild_installs_it` in the
558 /// bindings crate.
559 ///
560 /// The signing keypair starts out ephemeral (random for this process);
561 /// call [`Self::install_signing_secret`] once persisted storage is
562 /// available to give the install a stable Nostr identity.
563 pub fn with_config(device_id: impl Into<String>, config: NostrConfig) -> Result<Self> {
564 let device_id = device_id.into();
565 // The value is deliberately absent from the error: it is the rejected
566 // id, which in the case this check exists for is the app's profile.
567 let address = device_id.parse::<Address>().map_err(|e| {
568 Error::ConfigurationError(format!(
569 "Nostr transport requires this device's derived address: {e}"
570 ))
571 })?;
572 let routing_tag = nostr_crypto::routing_tag_for_address(&address)?;
573 let record_seal_keypair = nostr_crypto::record_seal_keypair_for_address(&address)?;
574 let keypair = RwLock::new(NostrKeypair::generate_ephemeral()?);
575 Ok(Self {
576 device_id,
577 keypair,
578 routing_tag,
579 receive_watermark: Mutex::new(None),
580 record_seal_keypair,
581 peer_nostr_pubkeys: RwLock::new(HashMap::new()),
582 sealing_enabled: Mutex::new(true),
583 cold_contact_enabled: Mutex::new(true),
584 publication_queue: Mutex::new(VecDeque::new()),
585 failed_publications: Mutex::new(HashSet::new()),
586 discovery_enabled: Mutex::new(false),
587 discovery_queue: Mutex::new(VecDeque::new()),
588 failed_discovery_publications: Mutex::new(HashSet::new()),
589 confirmed_discovery_publications: Mutex::new(HashSet::new()),
590 pending_deletions: Mutex::new(VecDeque::new()),
591 resolve_queue: Mutex::new(VecDeque::new()),
592 discovery_resolve_queue: Mutex::new(VecDeque::new()),
593 active_queries: Mutex::new(HashMap::new()),
594 resolve_attempts: Mutex::new(HashMap::new()),
595 config,
596 status: Arc::new(Mutex::new(TransportStatus::Unavailable)),
597 receive_queue: Arc::new(Mutex::new(VecDeque::new())),
598 send_queue: Arc::new(Mutex::new(VecDeque::new())),
599 pending_confirmation: Arc::new(Mutex::new(HashMap::new())),
600 sign_retry_counts: Arc::new(Mutex::new(HashMap::new())),
601 metrics: Arc::new(Mutex::new(TransportMetrics::default())),
602 reconnect_attempts: Arc::new(Mutex::new(0)),
603 platform_handle: Arc::new(Mutex::new(None)),
604 on_messages_available: Arc::new(Mutex::new(None)),
605 generic_poll_refusal_logged: AtomicBool::new(false),
606 })
607 }
608
609 /// Gets the local device ID.
610 pub fn device_id(&self) -> &str {
611 &self.device_id
612 }
613
614 /// Gets the configuration.
615 pub fn config(&self) -> &NostrConfig {
616 &self.config
617 }
618
619 /// Sets the platform-specific handle.
620 pub fn set_platform_handle(&self, handle: usize) {
621 crate::common::set_platform_handle(&self.platform_handle, handle);
622 }
623
624 /// Gets the platform-specific handle.
625 pub fn platform_handle(&self) -> Option<usize> {
626 crate::common::platform_handle(&self.platform_handle)
627 }
628
629 /// Notifies the platform that messages are ready to send.
630 ///
631 /// The callback Arc is cloned out of the mutex and the guard dropped
632 /// before the call, so a callback that re-enters the transport (e.g.
633 /// another `send()`) cannot self-deadlock on the callback mutex.
634 fn notify_messages_available(&self) {
635 let callback = self.on_messages_available.lock_or_recover().clone();
636 if let Some(cb) = callback {
637 cb();
638 }
639 }
640
641 /// Called when a message is received.
642 pub fn on_message_received(&self, message: Message) {
643 crate::common::on_message_received(&self.receive_queue, message);
644 }
645
646 /// Like [`on_message_received`](Self::on_message_received), but attaches a
647 /// transport-verified `peer_id` to the message.
648 pub fn on_message_received_from(&self, message: Message, peer_id: String) {
649 crate::common::on_message_received_from(&self.receive_queue, message, peer_id);
650 }
651
652 /// Serializes a message to JSON bytes.
653 pub fn serialize_message(&self, message: &Message) -> Result<Vec<u8>> {
654 crate::common::serialize_message_with(message)
655 }
656
657 /// Whether the transport should attempt reconnection.
658 pub fn should_reconnect(&self) -> bool {
659 if !self.config.auto_reconnect {
660 return false;
661 }
662 if self.config.max_reconnect_attempts == 0 {
663 return true;
664 }
665 *self.reconnect_attempts.lock_or_recover() < self.config.max_reconnect_attempts
666 }
667
668 /// Increments the reconnection attempt counter.
669 pub fn increment_reconnect_attempts(&self) {
670 let mut attempts = self.reconnect_attempts.lock_or_recover();
671 *attempts = attempts.saturating_add(1);
672 }
673
674 /// Updates transport metrics, preserving confirmation-loop counts.
675 pub fn update_metrics(&self, incoming: TransportMetrics) {
676 let mut metrics = self.metrics.lock_or_recover();
677 let prev_success = metrics.success_count;
678 let prev_failure = metrics.failure_count;
679 *metrics = incoming;
680 metrics.success_count = prev_success;
681 metrics.failure_count = prev_failure;
682 recalculate_delivery_ratios(&mut metrics);
683 }
684
685 /// Checks if there are messages waiting to be sent.
686 pub fn has_pending_sends(&self) -> bool {
687 !self.send_queue.lock_or_recover().is_empty()
688 }
689
690 /// Returns the number of messages awaiting platform confirmation.
691 pub fn pending_confirmation_count(&self) -> usize {
692 self.pending_confirmation.lock_or_recover().len()
693 }
694
695 // ========================================================================
696 // Nostr crypto methods
697 // ========================================================================
698
699 /// Returns this install's Nostr signing public key as a 64-char hex string.
700 ///
701 /// This is the key outgoing events are signed with (their `pubkey`
702 /// field), used by platforms to filter self-published events. It changes
703 /// when [`Self::install_signing_secret`] swaps the ephemeral key for the
704 /// persisted one, so platforms should read it after protocol
705 /// initialization, not cache it across that boundary.
706 pub fn public_key_hex(&self) -> String {
707 self.keypair.read_or_recover().public_key_hex().to_string()
708 }
709
710 /// Returns this device's public routing tag (the `#p` value peers use to
711 /// address us, and the value our relay subscription filters on).
712 pub fn routing_tag(&self) -> &str {
713 &self.routing_tag
714 }
715
716 /// Replaces the ephemeral signing keypair with one derived from the
717 /// persisted per-install secret.
718 ///
719 /// Idempotent for a given secret: deriving from the same secret yields
720 /// the same keypair. Events signed before this call used the ephemeral
721 /// key, which peers accept because inbound events are never
722 /// authenticated by their Nostr pubkey (sender authenticity comes from
723 /// the protocol-layer MLS signatures).
724 pub fn install_signing_secret(&self, secret: &[u8]) -> Result<()> {
725 let keypair = NostrKeypair::from_install_secret(secret)?;
726 let pubkey = keypair.public_key_hex().to_string();
727 *self.keypair.write_or_recover() = keypair;
728 tracing::debug!(
729 pubkey = %pubkey,
730 "Installed persisted Nostr signing key"
731 );
732 Ok(())
733 }
734
735 /// Enables or disables sealing of outgoing frames.
736 ///
737 /// Only the *send* side is gated. Inbound gift wraps are unsealed
738 /// unconditionally, so turning this off does not make a peer's traffic
739 /// unreadable and turning it back on needs no renegotiation.
740 pub fn set_sealing_enabled(&self, enabled: bool) {
741 *self.sealing_enabled.lock_or_recover() = enabled;
742 }
743
744 /// Whether outgoing frames are sealed.
745 pub fn sealing_enabled(&self) -> bool {
746 *self.sealing_enabled.lock_or_recover()
747 }
748
749 /// Enables or disables key-package publication and peer resolution.
750 pub fn set_cold_contact_enabled(&self, enabled: bool) {
751 *self.cold_contact_enabled.lock_or_recover() = enabled;
752 }
753
754 /// Whether key-package publication and peer resolution are enabled.
755 pub fn cold_contact_enabled(&self) -> bool {
756 *self.cold_contact_enabled.lock_or_recover()
757 }
758
759 /// Queues a key-package record for publication at `slot_id`.
760 ///
761 /// Replacing a slot is the caller's decision, not this queue's: the record
762 /// is addressable, so publishing the same `slot_id` again overwrites it at
763 /// the relay. A queued slot that is queued again before it drains is
764 /// collapsed to the newer payload — the older one names a key package the
765 /// engine has already replaced, and publishing both would briefly stand a
766 /// consumed package back up as the live record.
767 pub fn publish_key_package(&self, slot_id: &str, payload: Vec<u8>) {
768 if !self.cold_contact_enabled() {
769 return;
770 }
771 {
772 let mut queue = self.publication_queue.lock_or_recover();
773 queue.retain(|p| p.slot_id != slot_id);
774 queue.push_back(PendingPublication {
775 slot_id: slot_id.to_string(),
776 payload,
777 });
778 }
779 self.notify_messages_available();
780 }
781
782 /// Whether any key-package record is waiting to be published.
783 pub fn has_pending_publications(&self) -> bool {
784 !self.publication_queue.lock_or_recover().is_empty()
785 }
786
787 /// Records that a slot's publication never reached a relay.
788 ///
789 /// A `warn!` rather than a security warning: with the engine re-publishing
790 /// on its next tick this is transient and self-healing, unlike an engine
791 /// side refill failure (which leaves a genuinely stale record standing and
792 /// does emit `NostrKeyPackageSlotExhausted`). A relay that rejects the kind
793 /// outright makes this repeat once per refresh interval per slot — loud in
794 /// the log and visible in the transport's failure metrics, but never
795 /// escalating and never head-of-line blocking anything.
796 fn mark_publications_failed(&self, slot_ids: Vec<String>) {
797 if slot_ids.is_empty() {
798 return;
799 }
800 let mut failed = self.failed_publications.lock_or_recover();
801 for slot_id in slot_ids {
802 tracing::warn!(
803 slot_id = %slot_id,
804 "Nostr key-package publication did not reach a relay; the slot will be republished"
805 );
806 failed.insert(slot_id);
807 }
808 }
809
810 /// Records discovery publications that never reached a relay.
811 fn mark_discovery_publications_failed(&self, tags: Vec<String>) {
812 if tags.is_empty() {
813 return;
814 }
815 let mut failed = self.failed_discovery_publications.lock_or_recover();
816 for tag in tags {
817 tracing::warn!(
818 "Nostr username discovery publication did not reach a relay; \
819 the claim will be republished"
820 );
821 failed.insert(tag);
822 }
823 }
824
825 /// Drains the discovery tags whose publication never reached a relay.
826 pub fn take_failed_discovery_publications(&self) -> Vec<String> {
827 self.failed_discovery_publications
828 .lock_or_recover()
829 .drain()
830 .collect()
831 }
832
833 /// Records a discovery publication a relay acknowledged.
834 fn mark_discovery_publication_confirmed(&self, tag: String) {
835 self.confirmed_discovery_publications
836 .lock_or_recover()
837 .insert(tag);
838 }
839
840 /// Drains the discovery tags whose publication a relay acknowledged.
841 ///
842 /// The engine uses this to retire a retraction it owes. A tag appears here
843 /// for a claim and for a tombstone alike, which is not an ambiguity to
844 /// resolve: a name is either claimed or being retracted, and publishing a
845 /// claim for a name cancels any retraction owed for it anyway.
846 pub fn take_confirmed_discovery_publications(&self) -> Vec<String> {
847 self.confirmed_discovery_publications
848 .lock_or_recover()
849 .drain()
850 .collect()
851 }
852
853 /// Enables or disables username discovery publication and resolution.
854 pub fn set_discovery_enabled(&self, enabled: bool) {
855 *self.discovery_enabled.lock_or_recover() = enabled;
856 }
857
858 /// Whether username discovery is active.
859 ///
860 /// **Requires cold contact as well as its own switch.** A discovery record
861 /// points at an address whose key packages are what a resolver fetches
862 /// next; with cold contact off, nothing is published there and the claim
863 /// resolves to a dead end one hop later. Hard-coupled here rather than
864 /// documented, so the dead-end configuration is unreachable rather than
865 /// merely discouraged.
866 pub fn discovery_enabled(&self) -> bool {
867 *self.discovery_enabled.lock_or_recover() && self.cold_contact_enabled()
868 }
869
870 /// Queues a username discovery record for publication.
871 ///
872 /// Replacing a claim is the caller's decision, not this queue's: the record
873 /// is addressable at a deterministic `d`, so publishing the same username
874 /// again overwrites it at the relay. A claim queued twice before it drains
875 /// is collapsed to the newer payload — the older one is by definition
876 /// superseded, and publishing both would briefly stand the stale one back
877 /// up.
878 pub fn publish_discovery_record(&self, username: Username, payload: Vec<u8>) {
879 self.enqueue_discovery_publication(username, payload, false);
880 }
881
882 /// Queues a retraction: a tombstone into the claim's slot, plus a
883 /// best-effort NIP-09 deletion.
884 ///
885 /// Not gated on [`Self::discovery_enabled`], deliberately and unlike
886 /// [`Self::publish_discovery_record`]. Turning the feature *off* is the
887 /// single most likely reason to retract, and a gate here would make the
888 /// switch strand exactly the claim the user just asked to withdraw.
889 pub fn retract_discovery_record(&self, username: Username, payload: Vec<u8>) {
890 self.enqueue_discovery_publication(username, payload, true);
891 }
892
893 fn enqueue_discovery_publication(
894 &self,
895 username: Username,
896 payload: Vec<u8>,
897 retraction: bool,
898 ) {
899 if !retraction && !self.discovery_enabled() {
900 return;
901 }
902 {
903 let mut queue = self.discovery_queue.lock_or_recover();
904 queue.retain(|pending| pending.username != username);
905 queue.push_back(PendingDiscoveryPublication {
906 username,
907 payload,
908 retraction,
909 });
910 }
911 self.notify_messages_available();
912 }
913
914 /// Drains the slot ids whose publication never reached a relay, so the
915 /// engine can clear them from its published set and republish.
916 pub fn take_failed_publications(&self) -> Vec<String> {
917 self.failed_publications.lock_or_recover().drain().collect()
918 }
919
920 /// Requests resolution of `user_id`'s published key packages.
921 ///
922 /// Returns whether a request was newly queued; a `false` means the peer was
923 /// already queued, was attempted within `RESOLUTION_RETRY_INTERVAL`, or
924 /// cold contact is disabled. Callers treat it as advisory — the send that
925 /// triggered it proceeds on the bootstrap leg either way, because waiting
926 /// on a relay round-trip would convert a metadata upgrade into latency.
927 pub fn request_peer_key_packages(&self, user_id: &str) -> bool {
928 let queued = self.enqueue_resolution(user_id);
929 if queued {
930 self.notify_messages_available();
931 }
932 queued
933 }
934
935 /// Whether a request for `user_id`'s published key packages would be
936 /// queued now, without queueing one.
937 ///
938 /// The engine asks this before it reads storage to decide whether it
939 /// wants the packages at all, so that a peer with a message waiting costs
940 /// one storage read per `RESOLUTION_RETRY_INTERVAL` rather than one per
941 /// tick. It is advisory: the answer can change before
942 /// [`Self::request_peer_key_packages`] runs, which re-checks everything.
943 pub fn resolution_due(&self, user_id: &str) -> bool {
944 self.admissible_resolution(user_id).is_some()
945 }
946
947 /// The checks a resolution request must pass, read without changing
948 /// anything: cold contact on, the id an address, the rate limit lapsed,
949 /// and room in the queue for an id not already in it.
950 fn admissible_resolution(&self, user_id: &str) -> Option<Address> {
951 // A queued id becomes the `#p` tag of a relay query, so it is held to
952 // the same address requirement as a send recipient — and held here,
953 // at the queue's only gate, so `next_query` can never pop an id whose
954 // tag derivation fails after the entry was already consumed. Subsumes
955 // the emptiness check this replaces: an empty id is not an address.
956 if !self.cold_contact_enabled() {
957 return None;
958 }
959 let address = user_id.parse::<Address>().ok()?;
960
961 // Read the rate limit without stamping it. Stamping here would burn the
962 // whole interval on a request the queue then refuses, so a peer dropped
963 // at capacity would wait out `RESOLUTION_RETRY_INTERVAL` despite never
964 // having been looked up — the opposite of what the overflow policy
965 // promises.
966 {
967 let attempts = self.resolve_attempts.lock_or_recover();
968 if let Some(last) = attempts.get(user_id) {
969 if last.elapsed() < RESOLUTION_RETRY_INTERVAL {
970 return None;
971 }
972 }
973 }
974
975 let queue = self.resolve_queue.lock_or_recover();
976 if queue.iter().any(|q| *q == address) || queue.len() >= MAX_PENDING_RESOLUTIONS {
977 return None;
978 }
979 Some(address)
980 }
981
982 /// Forgets every resolution attempt, as if `RESOLUTION_RETRY_INTERVAL`
983 /// had lapsed for every peer. For tests that drive a retry without
984 /// waiting out the interval.
985 #[cfg(any(test, feature = "test-utils"))]
986 pub fn forget_resolution_attempts_for_test(&self) {
987 self.resolve_attempts.lock_or_recover().clear();
988 }
989
990 /// Queues a resolution without waking the platform.
991 ///
992 /// Used from the send path, which is already running inside the platform's
993 /// poll loop: waking it from there would re-enter the transport partway
994 /// through building an event, to tell it something it is about to look for
995 /// anyway.
996 fn enqueue_resolution(&self, user_id: &str) -> bool {
997 let Some(address) = self.admissible_resolution(user_id) else {
998 return false;
999 };
1000
1001 {
1002 // Checked again under the lock that pushes, so two callers racing
1003 // past the read above cannot queue one peer twice or overrun the cap.
1004 let mut queue = self.resolve_queue.lock_or_recover();
1005 if queue.iter().any(|q| *q == address) {
1006 return false;
1007 }
1008 if queue.len() >= MAX_PENDING_RESOLUTIONS {
1009 return false;
1010 }
1011 queue.push_back(address);
1012 }
1013
1014 // Only now is an attempt real. Bounded like the peer-key map, and for
1015 // the same reason: the key is a wire-influenced recipient id. Clearing
1016 // costs at most one premature retry per forgotten peer.
1017 let mut attempts = self.resolve_attempts.lock_or_recover();
1018 if attempts.len() >= NOSTR_MAX_TRACKED_PEER_KEYS {
1019 attempts.clear();
1020 }
1021 attempts.insert(user_id.to_string(), Instant::now());
1022 true
1023 }
1024
1025 /// Pops the next queued resolution and returns the REQ for the platform to
1026 /// issue, registering the query so inbound events can be routed back.
1027 pub fn next_query(&self) -> Result<Option<NostrQuery>> {
1028 // Key-package resolutions drain first: they are triggered by a send
1029 // that is already waiting on the bootstrap leg, whereas a discovery
1030 // query is a user-initiated lookup with no frame behind it.
1031 let subject = {
1032 let mut queue = self.resolve_queue.lock_or_recover();
1033 queue.pop_front().map(QuerySubject::KeyPackage)
1034 };
1035 let subject = match subject {
1036 Some(subject) => subject,
1037 None => {
1038 let mut queue = self.discovery_resolve_queue.lock_or_recover();
1039 match queue.pop_front() {
1040 Some(username) => QuerySubject::Discovery(username),
1041 None => return Ok(None),
1042 }
1043 }
1044 };
1045
1046 let query_id = new_query_id();
1047 let req_json = match &subject {
1048 QuerySubject::KeyPackage(address) => {
1049 let routing_tag = nostr_crypto::routing_tag_for_address(address)?;
1050 nostr_crypto::create_key_package_query_message(&routing_tag, &query_id)?
1051 }
1052 QuerySubject::Discovery(username) => {
1053 let discovery_tag = nostr_crypto::discovery_tag_for_username(username)?;
1054 nostr_crypto::create_discovery_query_message(&discovery_tag, &query_id)?
1055 }
1056 };
1057
1058 {
1059 let mut active = self.active_queries.lock_or_recover();
1060 // A query the platform never completes would otherwise leak an
1061 // entry per attempt. The rate limit bounds the rate, this bounds
1062 // the total; the victim is whichever entry the map yields first
1063 // (arbitrary, not oldest — the order carries no meaning here) and
1064 // costs one unresolvable answer.
1065 if active.len() >= MAX_PENDING_RESOLUTIONS {
1066 if let Some(stale) = active.keys().next().cloned() {
1067 active.remove(&stale);
1068 }
1069 }
1070 active.insert(
1071 query_id.clone(),
1072 ActiveQuery {
1073 subject: subject.clone(),
1074 seen_events: HashSet::new(),
1075 delivered: 0,
1076 },
1077 );
1078 }
1079
1080 let discovery_username = match &subject {
1081 QuerySubject::Discovery(username) => Some(username.clone()),
1082 QuerySubject::KeyPackage(_) => None,
1083 };
1084
1085 Ok(Some(NostrQuery {
1086 query_id,
1087 req_json,
1088 discovery_username,
1089 }))
1090 }
1091
1092 /// Opens an event delivered for `query_id`, returning the author's Nostr
1093 /// pubkey and the decrypted protocol message bytes.
1094 ///
1095 /// `Ok(None)` means the event was not a record this query can open — a
1096 /// wrong kind, a foreign publisher at the same tag, or a payload that does
1097 /// not decrypt. All three are ordinary: the routing tag is public, so
1098 /// anyone may publish to it, and a query returns whatever the relay holds
1099 /// there. The caller drops those and keeps going.
1100 ///
1101 /// What comes back is *not* trusted here. It is fed through the same
1102 /// receive path as any inbound frame, so the Ed25519 control gate and the
1103 /// sender-address derivation decide whose key package it is — and a record
1104 /// placed at this peer's tag by somebody else therefore registers under
1105 /// *that* signer's identity, not under the peer we asked about.
1106 ///
1107 /// # The one asymmetry: discovery events are authenticated here
1108 ///
1109 /// A key-package record needs no event-level check, for the reason above:
1110 /// whatever it carries is authenticated downstream by its own signature. A
1111 /// discovery *tombstone* has nothing downstream — its body is a constant
1112 /// and its meaning is entirely "who published this" — so discovery events
1113 /// are checked against their own BIP-340 signature before they are opened.
1114 /// See `nostr_crypto::event_is_authentic` for what a forged tombstone would
1115 /// otherwise buy.
1116 ///
1117 /// # What squatting a tag does buy
1118 ///
1119 /// Two things, both bounded, neither a loss:
1120 ///
1121 /// 1. **Crowding.** Foreign records displace real ones from the query's
1122 /// `limit`, costing the metadata upgrade and nothing else: the send
1123 /// falls back to the bootstrap leg exactly as it did before this
1124 /// existed.
1125 /// 2. **Replaying a consumed record.** Every published record is openable
1126 /// by anyone who knows the username — that is the design — so a squatter
1127 /// can unseal one of the peer's *spent* records, re-seal the untouched
1128 /// (and genuinely Ed25519-signed) message inside it under their own
1129 /// author key, and stand it back up with a fresh `created_at`. Nothing
1130 /// here detects it: the inner signature is real, and no freshness
1131 /// binding ties a record to the live slot. A resolver then imports a
1132 /// genuine-but-consumed key package and builds a Welcome the peer can
1133 /// never process — worse than crowding, because it commits to a dead
1134 /// session rather than staying on the working bootstrap leg.
1135 ///
1136 /// It self-heals rather than stranding the pair: importing the package
1137 /// runs the ordinary key-package handler, which pushes *our* package
1138 /// back under `auto_key_exchange`, and the peer then establishes from
1139 /// their side against a package that is actually live. So the cost is
1140 /// delivery delayed by one exchange — the same bounded class as the
1141 /// already-accepted `key_package_data` substitution vector, and not
1142 /// something a squatter can escalate. Closing it outright needs the
1143 /// record to carry slot-bound freshness (its slot id plus a signed issue
1144 /// time) so a replay is distinguishable from the current record.
1145 pub fn open_query_event(
1146 &self,
1147 query_id: &str,
1148 event_json: &str,
1149 ) -> Result<Option<ResolvedRecord>> {
1150 if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1151 tracing::warn!(
1152 len = event_json.len(),
1153 "Oversized Nostr resolution record; ignoring"
1154 );
1155 return Ok(None);
1156 }
1157
1158 let subject = {
1159 let mut active = self.active_queries.lock_or_recover();
1160 let Some(query) = active.get_mut(query_id) else {
1161 tracing::debug!(query_id = %query_id, "Nostr query event for an unknown query");
1162 return Ok(None);
1163 };
1164 // The relay decides how many events arrive here, so the ceiling has
1165 // to be ours. See [`MAX_QUERY_EVENTS`].
1166 //
1167 // Counted before the record is parsed or authenticated, and that
1168 // ordering is the deliberate half: the budget is a bound on *work*,
1169 // so a relay streaming junk must spend it. The cost is that such a
1170 // relay can also crowd out honest records that arrive later, which
1171 // is the same crowding the per-relay `limit` accepts, and is why a
1172 // resolution reports what it dropped rather than presenting a
1173 // truncated set as complete. Only the per-query duplicate slot is
1174 // spent after authentication, so a forgery cannot displace the
1175 // genuine record whose id it copied.
1176 if query.delivered >= MAX_QUERY_EVENTS {
1177 tracing::warn!(
1178 query_id = %query_id,
1179 cap = MAX_QUERY_EVENTS,
1180 "Nostr resolution query exceeded its event ceiling; ignoring the rest"
1181 );
1182 return Ok(None);
1183 }
1184 query.delivered += 1;
1185 query.subject.clone()
1186 };
1187
1188 let event: serde_json::Value = match serde_json::from_str(event_json) {
1189 Ok(v) => v,
1190 Err(e) => {
1191 tracing::debug!(error = %e, "Unparseable Nostr resolution record");
1192 return Ok(None);
1193 }
1194 };
1195
1196 // The kind is checked against what *this query asked for*, not against
1197 // the set of kinds this transport understands. A relay answering a
1198 // discovery REQ with a key-package record — or the reverse — must be
1199 // dropped rather than routed to the other handler.
1200 let expected_kind = match &subject {
1201 QuerySubject::KeyPackage(_) => nostr_crypto::NOSTR_KEY_PACKAGE_KIND,
1202 QuerySubject::Discovery(_) => nostr_crypto::NOSTR_DISCOVERY_KIND,
1203 };
1204 if event.get("kind").and_then(|k| k.as_u64()) != Some(expected_kind as u64) {
1205 return Ok(None);
1206 }
1207
1208 let (Some(author), Some(content)) = (
1209 event.get("pubkey").and_then(|p| p.as_str()),
1210 event.get("content").and_then(|c| c.as_str()),
1211 ) else {
1212 return Ok(None);
1213 };
1214
1215 // A discovery event must genuinely be from the key it names, and this
1216 // is the only record kind for which that matters. A tombstone's whole
1217 // meaning is *who published it* — there is nothing inside it to sign —
1218 // and the discovery seal key is public, so without this one hostile
1219 // relay could forge a retraction for an honest claimant and erase them
1220 // from the resolved set. See `nostr_crypto::event_is_authentic`.
1221 //
1222 // Checked before the dedup mark below, so an unauthentic event never
1223 // consumes a genuine record's slot.
1224 if matches!(subject, QuerySubject::Discovery(_))
1225 && !nostr_crypto::event_is_authentic(&event)
1226 {
1227 tracing::debug!("Discovery event failed its own signature check; ignoring");
1228 return Ok(None);
1229 }
1230
1231 // The query is broadcast, so every connected relay answers it and the
1232 // same record arrives once per relay. Take each event id once: behind
1233 // this call the key-package handler performs two durable
1234 // secure-storage writes per record, and re-importing a package we
1235 // already hold buys nothing. Marked before opening rather than after,
1236 // so a record that repeatedly fails to open is absorbed too.
1237 if let Some(event_id) = event.get("id").and_then(|i| i.as_str()) {
1238 let mut active = self.active_queries.lock_or_recover();
1239 let Some(query) = active.get_mut(query_id) else {
1240 return Ok(None);
1241 };
1242 if !query.seen_events.insert(event_id.to_string()) {
1243 tracing::debug!(
1244 query_id = %query_id,
1245 "Duplicate Nostr resolution record for this query; already taken"
1246 );
1247 return Ok(None);
1248 }
1249 }
1250
1251 let sealed = match base64::engine::general_purpose::STANDARD.decode(content) {
1252 Ok(bytes) => bytes,
1253 Err(e) => {
1254 tracing::debug!(error = %e, "Nostr resolution record content is not base64");
1255 return Ok(None);
1256 }
1257 };
1258
1259 match subject {
1260 QuerySubject::KeyPackage(address) => {
1261 // The peer's derivable key is computable from their address —
1262 // that is the whole reason a record sealed to it stays
1263 // fetchable by anyone entitled to fetch it, while remaining
1264 // opaque to a relay scraping by kind alone.
1265 let peer_key = nostr_crypto::record_seal_keypair_for_address(&address)?;
1266 match nostr_crypto::open_key_package_publication(&peer_key, author, &sealed) {
1267 Ok(plaintext) => Ok(Some(ResolvedRecord::KeyPackage {
1268 author: author.to_string(),
1269 plaintext,
1270 })),
1271 Err(e) => {
1272 tracing::debug!(error = %e, "Nostr key-package record did not open");
1273 Ok(None)
1274 }
1275 }
1276 }
1277 QuerySubject::Discovery(username) => {
1278 // Reconstructed from the queried name, exactly as any resolver
1279 // would. See `discovery_seal_keypair_for_username` for why this
1280 // key is public by construction and must never be load-bearing.
1281 let seal_key = nostr_crypto::discovery_seal_keypair_for_username(&username)?;
1282 match nostr_crypto::open_discovery_record(&seal_key, author, &sealed) {
1283 Ok(plaintext) => Ok(Some(ResolvedRecord::Discovery {
1284 username,
1285 // Lower-cased because the resolver *keys* claims and
1286 // retractions by this string while verification
1287 // compares decoded bytes. Two spellings of one key
1288 // would otherwise be two devices to the accumulator
1289 // and one to the verifier, so a retraction in one
1290 // spelling would not suppress a claim in the other.
1291 author: author.to_ascii_lowercase(),
1292 plaintext,
1293 })),
1294 Err(e) => {
1295 tracing::debug!(error = %e, "Nostr discovery record did not open");
1296 Ok(None)
1297 }
1298 }
1299 }
1300 }
1301 }
1302
1303 /// Releases a query once the platform has seen its end-of-stored-events.
1304 ///
1305 /// Returns the username when the query was a discovery lookup, so the
1306 /// engine knows which accumulated claim set to flush. End-of-stored-events
1307 /// is the *only* natural completion signal a Nostr query has, which is why
1308 /// the engine also sweeps on a timer: a relay that never sends EOSE would
1309 /// otherwise leave a resolution accumulating forever and never answering.
1310 pub fn complete_query(&self, query_id: &str) -> Option<Username> {
1311 let query = self.active_queries.lock_or_recover().remove(query_id)?;
1312 match query.subject {
1313 QuerySubject::Discovery(username) => Some(username),
1314 QuerySubject::KeyPackage(_) => None,
1315 }
1316 }
1317
1318 /// Requests resolution of the devices claiming `username`.
1319 ///
1320 /// Returns *why* it did what it did rather than a bare boolean. The three
1321 /// refusals mean materially different things to a caller: after
1322 /// [`ResolveRequest::AlreadyQueued`] an answer is still coming, while after
1323 /// the other two nothing will ever arrive. Collapsing them into `false`
1324 /// leaves an app that waits on the resolution event unable to tell "wait"
1325 /// from "hang". See `OfflineProtocol::resolve_username`, which is what
1326 /// turns that distinction into an error.
1327 ///
1328 /// Deliberately *not* rate-limited the way key-package resolution is. That
1329 /// limiter exists because a send to an unpublished peer would otherwise
1330 /// mint a round-trip per frame, automatically and invisibly. A discovery
1331 /// lookup is user-initiated — someone typed a name and pressed search — so
1332 /// throttling it would make the second attempt at a mistyped name silently
1333 /// do nothing. The queue bound is what keeps it finite.
1334 pub fn resolve_username(&self, username: Username) -> ResolveRequest {
1335 if !self.discovery_enabled() {
1336 return ResolveRequest::Disabled;
1337 }
1338
1339 {
1340 let mut queue = self.discovery_resolve_queue.lock_or_recover();
1341 if queue.iter().any(|queued| *queued == username) {
1342 return ResolveRequest::AlreadyQueued;
1343 }
1344 if queue.len() >= MAX_PENDING_RESOLUTIONS {
1345 return ResolveRequest::QueueFull;
1346 }
1347 queue.push_back(username);
1348 }
1349
1350 self.notify_messages_available();
1351 ResolveRequest::Queued
1352 }
1353
1354 /// Removes a queued username lookup that has not been minted into a query.
1355 ///
1356 /// Returns whether one was removed. The engine calls this when it gives up
1357 /// on a lookup that never reached a relay — queries are pumped only while
1358 /// the socket is up, so a lookup made offline can sit here indefinitely.
1359 /// Without the removal, [`Self::resolve_username`] would keep refusing that
1360 /// name as "already queued" while nothing was ever going to answer it, so a
1361 /// name that timed out once could not be looked up again.
1362 pub fn cancel_username_resolution(&self, username: &Username) -> bool {
1363 let mut queue = self.discovery_resolve_queue.lock_or_recover();
1364 let before = queue.len();
1365 queue.retain(|queued| queued != username);
1366 before != queue.len()
1367 }
1368
1369 /// Records a peer's real per-install Nostr public key, learned from the
1370 /// `nostr_pubkey` field of their key package.
1371 ///
1372 /// Until this is known, frames to that peer are sealed to their publicly
1373 /// computable key instead (bootstrap leg). Once it is known, every
1374 /// subsequent frame is sealed to a key only that install holds.
1375 ///
1376 /// A wrong value here is not merely a delivery denial: the plaintext is
1377 /// sealed *to* this key, so an attacker who substitutes their own reads the
1378 /// envelope metadata off a public relay. The engine is what keeps that
1379 /// shut — it calls this only for a key package whose Ed25519 signature it
1380 /// verified, never for one that merely arrived (its security gate accepts
1381 /// unsigned control messages from not-yet-pinned peers). Do not add a
1382 /// caller that skips that check.
1383 ///
1384 /// Bounded at [`NOSTR_MAX_TRACKED_PEER_KEYS`]; at capacity the map resets
1385 /// rather than evicting selectively, matching the engine's other
1386 /// wire-keyed maps. The only cost of forgetting a peer is that the next
1387 /// frame to them takes the bootstrap path until their key package is seen
1388 /// again.
1389 pub fn set_peer_nostr_pubkey(&self, user_id: &str, pubkey_hex: &str) {
1390 if user_id.is_empty()
1391 || pubkey_hex.len() != 64
1392 || !pubkey_hex.bytes().all(|b| b.is_ascii_hexdigit())
1393 {
1394 tracing::debug!(
1395 user_id = %user_id,
1396 "Ignoring malformed peer Nostr pubkey"
1397 );
1398 return;
1399 }
1400
1401 let mut map = self.peer_nostr_pubkeys.write_or_recover();
1402 if map.len() >= NOSTR_MAX_TRACKED_PEER_KEYS && !map.contains_key(user_id) {
1403 tracing::warn!(
1404 capacity = NOSTR_MAX_TRACKED_PEER_KEYS,
1405 "Nostr peer key map at capacity; clearing"
1406 );
1407 map.clear();
1408 }
1409 map.insert(user_id.to_string(), pubkey_hex.to_ascii_lowercase());
1410 }
1411
1412 /// Forgets a peer's per-install Nostr public key, reverting frames to them
1413 /// to the bootstrap path.
1414 ///
1415 /// The engine calls this when it declares everything learned about a peer
1416 /// stale (the unblock clean slate). Reverting rather than refusing to send
1417 /// is deliberate: the bootstrap key is derived from the peer's user id, so
1418 /// it is readable by whatever install they are on now — where a key cached
1419 /// from a since-wiped install is readable by nobody.
1420 pub fn forget_peer_nostr_pubkey(&self, user_id: &str) {
1421 self.peer_nostr_pubkeys.write_or_recover().remove(user_id);
1422 }
1423
1424 /// Returns the recipient's known per-install Nostr public key, if any.
1425 fn peer_nostr_pubkey(&self, user_id: &str) -> Option<String> {
1426 self.peer_nostr_pubkeys
1427 .read_or_recover()
1428 .get(user_id)
1429 .cloned()
1430 }
1431
1432 /// Unseals an inbound event payload, or returns it unchanged if it is not
1433 /// sealed.
1434 ///
1435 /// `sender_pubkey_hex` is the wrapper event's `pubkey` — a single-use key
1436 /// with no identity attached. `data` is the base64-decoded event `content`,
1437 /// which is the form the platform bridges produce.
1438 ///
1439 /// Two of our own keys can unseal a frame and both are tried, cheapest and
1440 /// most likely first:
1441 ///
1442 /// 1. the per-install signing key, used by any peer that has seen our key
1443 /// package — the steady state;
1444 /// 2. the publicly computable key, used on the bootstrap leg before the
1445 /// sender has learned our real one.
1446 ///
1447 /// A wrong key is rejected by the NIP-44 MAC, so trying both is
1448 /// unambiguous rather than a guess. A frame that unseals with neither is
1449 /// returned to the caller **as-is**: it may still be a legacy unsealed
1450 /// frame, and the caller's decoder is what decides. That ordering matters —
1451 /// deciding "sealed but undecryptable" here and dropping would break every
1452 /// peer that predates sealing.
1453 pub fn unseal_event_payload<'a>(
1454 &self,
1455 sender_pubkey_hex: &str,
1456 data: &'a [u8],
1457 ) -> Cow<'a, [u8]> {
1458 if !nostr_crypto::is_sealed_payload(data) {
1459 return Cow::Borrowed(data);
1460 }
1461 // Anyone can publish to our routing tag, so this is attacker-sized
1462 // input. Unsealing costs an ECDH plus an HMAC and a ChaCha20 pass over
1463 // the whole buffer; do that only for something that could plausibly be
1464 // a message. The receive path rejects it at the same bound anyway, so
1465 // this only moves the rejection ahead of the crypto.
1466 if data.len() > crate::constants::DEFAULT_MAX_MESSAGE_SIZE {
1467 tracing::warn!(
1468 len = data.len(),
1469 "Oversized sealed Nostr frame; not attempting to unseal"
1470 );
1471 return Cow::Borrowed(data);
1472 }
1473
1474 {
1475 let keypair = self.keypair.read_or_recover();
1476 if let Ok(plaintext) = nostr_crypto::unwrap_gift_wrap(&keypair, sender_pubkey_hex, data)
1477 {
1478 return Cow::Owned(plaintext);
1479 }
1480 }
1481
1482 match nostr_crypto::unwrap_gift_wrap(&self.record_seal_keypair, sender_pubkey_hex, data) {
1483 Ok(plaintext) => {
1484 tracing::debug!("Unsealed a bootstrap-leg Nostr frame");
1485 Cow::Owned(plaintext)
1486 }
1487 Err(e) => {
1488 // Not addressed to us, or corrupt. Handing the original bytes
1489 // back lets the caller's decoder produce the single
1490 // "undecodable frame" outcome rather than two divergent ones.
1491 tracing::debug!(error = %e, "Sealed Nostr frame did not unseal with either key");
1492 Cow::Borrowed(data)
1493 }
1494 }
1495 }
1496
1497 /// Builds the event content for `message`: a gift wrap when sealing is on,
1498 /// the legacy base64 envelope otherwise.
1499 ///
1500 /// Returns the whole signed event so the two paths differ in kind, signing
1501 /// key, and timestamp — not just in how `content` is encoded.
1502 fn build_event(&self, message: &Message) -> Result<nostr_crypto::NostrEvent> {
1503 let recipient_device_id = message.recipient.as_str();
1504 // `send` already refused a non-address recipient before queueing, so
1505 // this parse is a restatement rather than a new gate — but it is the
1506 // one that produces the typed value the derivations below require, and
1507 // re-deriving it here means a future queue-filling path cannot skip
1508 // the check by accident.
1509 let recipient_address = recipient_device_id.parse::<Address>().map_err(|_| {
1510 crate::Error::PeerNotReachable(
1511 "Nostr addresses peers by their derived address, and the \
1512 recipient id is not one"
1513 .to_string(),
1514 )
1515 })?;
1516 let recipient_tag = nostr_crypto::routing_tag_for_address(&recipient_address)?;
1517 let data = self.serialize_message(message)?;
1518
1519 if !self.sealing_enabled() {
1520 let content_base64 = base64::engine::general_purpose::STANDARD.encode(&data);
1521 let keypair = self.keypair.read_or_recover();
1522 return nostr_crypto::NostrEvent::create_dm(&keypair, &recipient_tag, &content_base64);
1523 }
1524
1525 // With no known per-install key for the recipient, seal to their
1526 // publicly computable one. That is the bootstrap leg: it defeats bulk
1527 // collection by a relay, and nothing more — anyone who guesses the
1528 // recipient's user ID holds the matching private half. It is chosen
1529 // over refusing to send (which would make a stranger unreachable over
1530 // Nostr) and over falling back to a cleartext kind-4 event (which
1531 // would make first contact trivially identifiable with a one-line
1532 // relay filter). On the wire the two cases are indistinguishable.
1533 let encryption_pubkey = match self.peer_nostr_pubkey(recipient_device_id) {
1534 Some(key) => key,
1535 None => {
1536 // Ask the relays for the recipient's published records, so the
1537 // *next* frame seals to a key only they hold. This frame still
1538 // takes the bootstrap leg: blocking a send on a relay
1539 // round-trip would turn a metadata upgrade into latency, and
1540 // the round-trip may have nothing to return.
1541 //
1542 // This is also what bounds the peer-key map's reset-at-capacity
1543 // downgrade for peers who publish — a forgotten key is
1544 // re-resolved on the next send rather than waiting for a fresh
1545 // key-package exchange.
1546 self.enqueue_resolution(recipient_device_id);
1547 // Their record-seal key, reconstructed from their address —
1548 // no longer the routing tag, which since the key split is a
1549 // label with no private half anyone holds.
1550 nostr_crypto::record_seal_keypair_for_address(&recipient_address)?
1551 .public_key_hex()
1552 .to_string()
1553 }
1554 };
1555
1556 nostr_crypto::NostrEvent::create_gift_wrap(&recipient_tag, &encryption_pubkey, &data)
1557 }
1558
1559 /// Builds the next queued key-package publication, if any.
1560 ///
1561 /// Publications are drained ahead of messages so a slot refreshed after
1562 /// consumption reaches the relay before the traffic that made it stale.
1563 /// A failure here drops the record rather than re-queueing it — retrying in
1564 /// place would head-of-line block the message queue behind a record that
1565 /// keeps failing — and instead reports the slot through
1566 /// [`Self::mark_publications_failed`], which is what makes the engine
1567 /// republish it on a later tick. Reporting it is not optional: the engine
1568 /// marked the slot published when it queued the record, so a silent drop
1569 /// here strands the slot until the process restarts.
1570 fn next_publication_event(&self) -> Result<Option<SignedNostrEvent>> {
1571 let pending = {
1572 let mut queue = self.publication_queue.lock_or_recover();
1573 match queue.pop_front() {
1574 Some(p) => p,
1575 None => return Ok(None),
1576 }
1577 };
1578
1579 let message_id = format!("{}{}", NOSTR_PUBLICATION_ID_PREFIX, pending.slot_id);
1580
1581 let result = (|| {
1582 let event = {
1583 let keypair = self.keypair.read_or_recover();
1584 nostr_crypto::NostrEvent::create_key_package_publication(
1585 &keypair,
1586 &self.routing_tag,
1587 self.record_seal_keypair.public_key_hex(),
1588 &pending.slot_id,
1589 &pending.payload,
1590 )?
1591 };
1592 let event_id = event.id.clone();
1593 let event_json = event.to_relay_message()?;
1594 if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1595 return Err(crate::Error::MessageTooLarge(
1596 event_json.len(),
1597 NOSTR_MAX_PAYLOAD_SIZE,
1598 ));
1599 }
1600 Ok((event_id, event_json))
1601 })();
1602
1603 match result {
1604 Ok((event_id, event_json)) => {
1605 self.pending_confirmation
1606 .lock_or_recover()
1607 .insert(message_id.clone(), Instant::now());
1608 Ok(Some(SignedNostrEvent {
1609 message_id,
1610 event_id,
1611 event_json,
1612 }))
1613 }
1614 Err(e) => {
1615 tracing::error!(
1616 slot_id = %pending.slot_id,
1617 error = %e,
1618 "Failed to build a Nostr key-package publication; slot left unpublished"
1619 );
1620 self.mark_publications_failed(vec![pending.slot_id]);
1621 Err(e)
1622 }
1623 }
1624 }
1625
1626 /// Builds the next queued discovery publication, if any.
1627 ///
1628 /// Mirrors [`Self::next_publication_event`], including its failure policy:
1629 /// a build error drops the record rather than re-queueing it (retrying in
1630 /// place would head-of-line block the message queue behind something that
1631 /// keeps failing) and reports the tag through
1632 /// [`Self::mark_discovery_publications_failed`], which is what makes the
1633 /// engine republish it on a later tick. Reporting is not optional — the
1634 /// engine marked the claim published when it queued the record, so a silent
1635 /// drop strands it until the process restarts.
1636 ///
1637 /// A retraction additionally emits a NIP-09 deletion. That event is
1638 /// best-effort: it is queued behind the tombstone, and a failure to build
1639 /// it is logged rather than propagated, because the tombstone is the half
1640 /// that actually works through addressable replacement.
1641 fn next_discovery_event(&self) -> Result<Option<SignedNostrEvent>> {
1642 let pending = {
1643 let mut queue = self.discovery_queue.lock_or_recover();
1644 match queue.pop_front() {
1645 Some(p) => p,
1646 None => return Ok(None),
1647 }
1648 };
1649
1650 let tag = match nostr_crypto::discovery_tag_for_username(&pending.username) {
1651 Ok(tag) => tag,
1652 Err(e) => {
1653 tracing::error!(
1654 error = %e,
1655 "Failed to derive a Nostr discovery tag; claim left unpublished"
1656 );
1657 return Err(e);
1658 }
1659 };
1660 let message_id = format!("{}{}", NOSTR_DISCOVERY_ID_PREFIX, tag);
1661
1662 let result = (|| {
1663 let seal = nostr_crypto::discovery_seal_keypair_for_username(&pending.username)?;
1664 let (event, deletion) = {
1665 let keypair = self.keypair.read_or_recover();
1666 let event = nostr_crypto::NostrEvent::create_discovery_publication(
1667 &keypair,
1668 &tag,
1669 seal.public_key_hex(),
1670 &pending.payload,
1671 )?;
1672 // Built under the *same* keypair guard as the tombstone it
1673 // follows. Re-reading the keypair for it would let an identity
1674 // swap in between name a coordinate this install never
1675 // published at, and a NIP-09 deletion is per-author, so that
1676 // request would delete nothing while reporting nothing.
1677 let deletion = pending
1678 .retraction
1679 .then(|| nostr_crypto::NostrEvent::create_discovery_deletion(&keypair, &tag));
1680 (event, deletion)
1681 };
1682 let event_id = event.id.clone();
1683 let event_json = event.to_relay_message()?;
1684 if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1685 return Err(crate::Error::MessageTooLarge(
1686 event_json.len(),
1687 NOSTR_MAX_PAYLOAD_SIZE,
1688 ));
1689 }
1690 Ok((event_id, event_json, deletion))
1691 })();
1692
1693 match result {
1694 Ok((event_id, event_json, deletion)) => {
1695 if let Some(deletion) = deletion {
1696 self.queue_discovery_deletion(deletion);
1697 }
1698 self.pending_confirmation
1699 .lock_or_recover()
1700 .insert(message_id.clone(), Instant::now());
1701 Ok(Some(SignedNostrEvent {
1702 message_id,
1703 event_id,
1704 event_json,
1705 }))
1706 }
1707 Err(e) => {
1708 tracing::error!(
1709 error = %e,
1710 "Failed to build a Nostr discovery publication; claim left unpublished"
1711 );
1712 self.mark_discovery_publications_failed(vec![tag]);
1713 Err(e)
1714 }
1715 }
1716 }
1717
1718 /// Queues the best-effort NIP-09 deletion half of a retraction.
1719 ///
1720 /// Takes an already-built event rather than building one, so it is signed
1721 /// by the same key as the tombstone whose slot it names. See
1722 /// [`Self::next_discovery_event`].
1723 ///
1724 /// Held in its own queue, drained after the discovery queue, so the
1725 /// deletion follows its tombstone rather than racing it. A claim for
1726 /// another name queued in between will go out first; that is harmless,
1727 /// since the deletion names its coordinate explicitly. It carries no
1728 /// synthetic id and no pending-confirmation entry: nothing republishes a
1729 /// deletion and nothing recovers if a relay ignores it, which is what
1730 /// "best effort" means here.
1731 fn queue_discovery_deletion(&self, event: Result<nostr_crypto::NostrEvent>) {
1732 match event {
1733 Ok(event) => {
1734 let event_id = event.id.clone();
1735 match event.to_relay_message() {
1736 Ok(json) => self
1737 .pending_deletions
1738 .lock_or_recover()
1739 .push_back((event_id, json)),
1740 Err(e) => tracing::warn!(
1741 error = %e,
1742 "Failed to serialize a Nostr discovery deletion; the tombstone still stands"
1743 ),
1744 }
1745 }
1746 Err(e) => tracing::warn!(
1747 error = %e,
1748 "Failed to build a Nostr discovery deletion; the tombstone still stands"
1749 ),
1750 }
1751 }
1752
1753 /// Pops a ready-built deletion event, if any.
1754 fn next_deletion_event(&self) -> Option<SignedNostrEvent> {
1755 let (event_id, event_json) = self.pending_deletions.lock_or_recover().pop_front()?;
1756 Some(SignedNostrEvent {
1757 message_id: format!("{}{}", NOSTR_DELETION_ID_PREFIX, event_id),
1758 event_id,
1759 event_json,
1760 })
1761 }
1762
1763 /// Pops the next outgoing message, creates a signed Nostr event, and returns
1764 /// `(message_id, recipient_device_id, relay_event_json)`.
1765 ///
1766 /// The `relay_event_json` is a complete `["EVENT", {...}]` string ready to
1767 /// send over a WebSocket connection. The platform no longer needs to do
1768 /// any signing or event creation.
1769 ///
1770 /// Events larger than [`NOSTR_MAX_PAYLOAD_SIZE`] are dropped here rather
1771 /// than handed to the platform, since relays would reject them on arrival.
1772 /// That drop is permanent — unlike a signing failure, an oversized event is
1773 /// oversized on every attempt, so retrying it would only head-of-line-block
1774 /// the queue behind a message no relay will accept.
1775 ///
1776 /// **Sealing shrinks how much fits under that cap**, by more than the
1777 /// base64 layer alone suggests: NIP-44 pads to a power-of-two bucket, so a
1778 /// payload just past a boundary very nearly doubles before the MAC and
1779 /// base64 are applied. The check therefore runs on the final event, after
1780 /// sealing, and a message that fits unsealed may not fit sealed.
1781 pub fn get_next_signed_event(&self) -> Result<Option<SignedNostrEvent>> {
1782 self.drain_expired_pending();
1783
1784 if let Some(publication) = self.next_publication_event()? {
1785 return Ok(Some(publication));
1786 }
1787
1788 // Discovery records drain after key packages and before messages. The
1789 // order matters in one direction only: a claim points at an address
1790 // whose key packages a resolver fetches next, so publishing the claim
1791 // first would advertise a name that momentarily resolves to nothing.
1792 if let Some(publication) = self.next_discovery_event()? {
1793 return Ok(Some(publication));
1794 }
1795
1796 // Deletions last among the synthetic events, so a retraction's
1797 // tombstone is always on the wire before the deletion that accompanies
1798 // it. If a relay honours the deletion and drops the tombstone, the
1799 // claim is gone either way; the reverse order could delete the old
1800 // claim and then have the tombstone fail, leaving nothing to mark the
1801 // slot retracted.
1802 if let Some(deletion) = self.next_deletion_event() {
1803 return Ok(Some(deletion));
1804 }
1805
1806 let message = {
1807 let mut queue = self.send_queue.lock_or_recover();
1808 match queue.pop_front() {
1809 Some(m) => m,
1810 None => return Ok(None),
1811 }
1812 };
1813
1814 let message_id = message.id.to_string();
1815
1816 let result = (|| {
1817 let event = self.build_event(&message)?;
1818 let event_id = event.id.clone();
1819 let event_json = event.to_relay_message()?;
1820 if event_json.len() > NOSTR_MAX_PAYLOAD_SIZE {
1821 return Err(crate::Error::MessageTooLarge(
1822 event_json.len(),
1823 NOSTR_MAX_PAYLOAD_SIZE,
1824 ));
1825 }
1826 Ok((event_id, event_json))
1827 })();
1828
1829 match result {
1830 Ok((event_id, event_json)) => {
1831 self.sign_retry_counts.lock_or_recover().remove(&message_id);
1832 self.pending_confirmation
1833 .lock_or_recover()
1834 .insert(message_id.clone(), Instant::now());
1835
1836 Ok(Some(SignedNostrEvent {
1837 message_id,
1838 event_id,
1839 event_json,
1840 }))
1841 }
1842 Err(e) => {
1843 let retriable = match &e {
1844 // No number of attempts shrinks an oversized event.
1845 crate::Error::MessageTooLarge(_, _) => false,
1846 _ => self.record_sign_attempt(&message_id) < MAX_SIGN_RETRIES,
1847 };
1848
1849 if retriable {
1850 // Re-enqueue for another attempt.
1851 self.send_queue.lock_or_recover().push_front(message);
1852 } else {
1853 self.fail_permanently(&message_id, &e);
1854 }
1855 Err(e)
1856 }
1857 }
1858 }
1859
1860 /// Records a signing attempt for `message_id` and returns the running count.
1861 fn record_sign_attempt(&self, message_id: &str) -> u8 {
1862 let mut counts = self.sign_retry_counts.lock_or_recover();
1863 let count = counts.entry(message_id.to_string()).or_insert(0);
1864 *count = count.saturating_add(1);
1865 *count
1866 }
1867
1868 /// Drops a message that cannot be published and records the failure.
1869 ///
1870 /// The message has already been popped off the send queue and was never
1871 /// entered into `pending_confirmation` — that happens only once an event
1872 /// reaches the platform — so the failure is counted directly here.
1873 /// [`Transport::report_send_failure`] is keyed on a pending entry and would
1874 /// be a no-op, leaving the failure invisible to DORS.
1875 fn fail_permanently(&self, message_id: &str, error: &crate::Error) {
1876 self.sign_retry_counts.lock_or_recover().remove(message_id);
1877
1878 let mut metrics = self.metrics.lock_or_recover();
1879 metrics.failure_count = metrics.failure_count.saturating_add(1);
1880 recalculate_delivery_ratios(&mut metrics);
1881 drop(metrics);
1882
1883 tracing::error!(
1884 message_id = %message_id,
1885 error_code = error.code(),
1886 error = %error,
1887 "Nostr event dropped permanently; it will not be published"
1888 );
1889 }
1890
1891 /// Returns the newest accepted event timestamp (unix seconds), or `None`
1892 /// if no event has been accepted since the last watermark install.
1893 ///
1894 /// Read by the engine to persist the watermark.
1895 pub fn receive_watermark_secs(&self) -> Option<i64> {
1896 *self.receive_watermark.lock_or_recover()
1897 }
1898
1899 /// Records that an event dated `created_at_secs` (unix seconds) has been
1900 /// received, moving the receive watermark forward. Returns whether the
1901 /// watermark actually moved.
1902 ///
1903 /// Two rules, both load-bearing:
1904 ///
1905 /// - **Monotonic.** The mark only ever advances, so out-of-order delivery
1906 /// (the relay streams stored events newest-first) cannot walk it back.
1907 /// - **Never into the future.** Anyone can address an event to our routing
1908 /// tag — it is derived from a public user id — and `created_at` is
1909 /// written by whoever published the event. A single event dated far ahead
1910 /// would otherwise pin the mark there and make every later subscription
1911 /// ask for events `since` the far future, receiving nothing forever. So
1912 /// values more than [`NOSTR_FUTURE_DATED_TOLERANCE_SECS`] ahead of local
1913 /// time are dropped, not clamped.
1914 ///
1915 /// The restore path goes through this same function deliberately: a
1916 /// watermark persisted by a build without the future check must not be able
1917 /// to stall a subscription either.
1918 ///
1919 /// Callers should advance the mark only for frames that actually decoded
1920 /// into a [`Message`]. The mark means "receive progress has reached here",
1921 /// and a frame we cannot parse is one we never processed — counting it
1922 /// would let undecodable traffic push the window past real messages still
1923 /// waiting on the relay. The failure mode of the stricter rule is replaying
1924 /// more, never less, which is the right bias for a delivery path.
1925 pub fn advance_receive_watermark(&self, created_at_secs: i64) -> bool {
1926 if created_at_secs <= 0 {
1927 return false;
1928 }
1929 let ceiling = now_unix_secs().saturating_add(NOSTR_FUTURE_DATED_TOLERANCE_SECS);
1930 if created_at_secs > ceiling {
1931 tracing::debug!(
1932 created_at = created_at_secs,
1933 ceiling,
1934 "Ignoring future-dated Nostr event for the receive watermark"
1935 );
1936 return false;
1937 }
1938
1939 let mut mark = self.receive_watermark.lock_or_recover();
1940 match *mark {
1941 Some(current) if current >= created_at_secs => false,
1942 _ => {
1943 *mark = Some(created_at_secs);
1944 true
1945 }
1946 }
1947 }
1948
1949 /// The `since` this device's subscription filter should carry (unix
1950 /// seconds), derived from the receive watermark.
1951 ///
1952 /// With a watermark, it reaches back past it by the gift-wrap jitter window
1953 /// plus a clock-skew margin — an event published now may legitimately carry
1954 /// a `created_at` up to a jitter window old, and a sender's clock may lag
1955 /// ours, so a `since` sitting exactly at the mark would skip both. The
1956 /// watermark is also clamped to `now` first, so a mark restored from an
1957 /// older build that lacked the future-dated check cannot push `since`
1958 /// forward.
1959 ///
1960 /// With no watermark — a fresh install, a wiped one, or any subscription
1961 /// built before protocol-state storage has been restored — it falls back to
1962 /// [`NOSTR_FIRST_RUN_BACKFILL_SECS`] ago. Never zero: an absent or zero
1963 /// `since` is the unbounded filter this exists to remove.
1964 ///
1965 /// **Residual:** the mark can only be as good as what we have received. A
1966 /// relay that truncates a reconnect's history at
1967 /// [`NOSTR_INITIAL_QUERY_LIMIT`](crate::constants::NOSTR_INITIAL_QUERY_LIMIT)
1968 /// hands back its newest events, so a device that comes back to more than
1969 /// that much stored history advances past what it never saw. The overlap
1970 /// below bounds how much: only events already older than jitter + skew at
1971 /// the moment of truncation are at risk.
1972 fn subscription_since(&self) -> i64 {
1973 let now = now_unix_secs();
1974 match self.receive_watermark_secs() {
1975 Some(mark) => mark
1976 .min(now)
1977 .saturating_sub(NOSTR_CREATED_AT_JITTER_SECS)
1978 .saturating_sub(NOSTR_CLOCK_SKEW_MARGIN_SECS)
1979 .max(0),
1980 None => now.saturating_sub(NOSTR_FIRST_RUN_BACKFILL_SECS).max(0),
1981 }
1982 }
1983
1984 /// Returns a NIP-01 subscription filter JSON for this device's routing tag.
1985 ///
1986 /// The platform should send this to each relay after connecting:
1987 /// `["REQ", "<sub_id>", {"#p": ["<routing_tag>"], "kinds": [4, 1059], "since": T, "limit": N}]`
1988 ///
1989 /// The filter is on the routing tag — not the signing pubkey — so it is
1990 /// stable across signing-key changes and derivable by peers. `since` bounds
1991 /// how far back stored-event replay reaches — it is derived from the
1992 /// receive watermark by the private `subscription_since`, documented on
1993 /// [`Self::advance_receive_watermark`] — while `limit` caps how much of
1994 /// that window one (re)connect pulls down. Neither caps live delivery.
1995 pub fn create_subscription(&self, subscription_id: &str) -> Result<String> {
1996 nostr_crypto::create_subscription_message(
1997 &self.routing_tag,
1998 subscription_id,
1999 self.subscription_since(),
2000 )
2001 }
2002
2003 /// Fails all pending confirmations and records them as failures.
2004 fn fail_all_pending(&self) {
2005 let (pending, slots, tags) = {
2006 let mut map = self.pending_confirmation.lock_or_recover();
2007 let mut slots = Vec::new();
2008 let mut tags = Vec::new();
2009 let mut synthetic = 0usize;
2010 for id in map.keys() {
2011 match synthetic_publication(id) {
2012 Some(SyntheticPublication::KeyPackage(slot)) => {
2013 slots.push(slot);
2014 synthetic += 1;
2015 }
2016 Some(SyntheticPublication::Discovery(tag)) => {
2017 tags.push(tag);
2018 synthetic += 1;
2019 }
2020 // Nothing republishes a deletion; it is counted only so it
2021 // does not fall through into the delivery failures.
2022 Some(SyntheticPublication::Deletion) => synthetic += 1,
2023 None => {}
2024 }
2025 }
2026 // Only the messages count as failures — the publications among
2027 // them are reported to the engine instead. See
2028 // [`SyntheticPublication`].
2029 let count = map.len().saturating_sub(synthetic);
2030 map.clear();
2031 (count, slots, tags)
2032 };
2033 self.mark_publications_failed(slots);
2034 self.mark_discovery_publications_failed(tags);
2035 if pending > 0 {
2036 let mut metrics = self.metrics.lock_or_recover();
2037 metrics.failure_count = metrics.failure_count.saturating_add(pending as u32);
2038 recalculate_delivery_ratios(&mut metrics);
2039 }
2040 }
2041
2042 /// Expires pending confirmations that have exceeded the timeout.
2043 fn drain_expired_pending(&self) {
2044 let timeout = Duration::from_secs(NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS);
2045 let now = Instant::now();
2046 let mut expired_count = 0u32;
2047 let mut expired_slots = Vec::new();
2048 let mut expired_tags = Vec::new();
2049
2050 {
2051 let mut pending = self.pending_confirmation.lock_or_recover();
2052 pending.retain(|message_id, enqueued_at| {
2053 if now.duration_since(*enqueued_at) > timeout {
2054 // A timed-out publication is reported to the engine, not
2055 // counted as a delivery failure. See
2056 // [`SyntheticPublication`].
2057 match synthetic_publication(message_id) {
2058 Some(SyntheticPublication::KeyPackage(slot_id)) => {
2059 expired_slots.push(slot_id)
2060 }
2061 Some(SyntheticPublication::Discovery(tag)) => expired_tags.push(tag),
2062 // Best effort: a deletion the relay never acknowledged
2063 // is simply gone. The tombstone is what carries the
2064 // retraction.
2065 Some(SyntheticPublication::Deletion) => {}
2066 None => expired_count += 1,
2067 }
2068 false
2069 } else {
2070 true
2071 }
2072 });
2073 }
2074
2075 self.mark_publications_failed(expired_slots);
2076 self.mark_discovery_publications_failed(expired_tags);
2077
2078 if expired_count > 0 {
2079 let mut metrics = self.metrics.lock_or_recover();
2080 metrics.failure_count = metrics.failure_count.saturating_add(expired_count);
2081 recalculate_delivery_ratios(&mut metrics);
2082 }
2083 }
2084}
2085
2086impl Transport for NostrTransport {
2087 fn as_any(&self) -> &dyn std::any::Any {
2088 self
2089 }
2090
2091 fn transport_type(&self) -> TransportType {
2092 TransportType::Nostr
2093 }
2094
2095 fn status(&self) -> TransportStatus {
2096 *self.status.lock_or_recover()
2097 }
2098
2099 fn metrics(&self) -> TransportMetrics {
2100 self.metrics.lock_or_recover().clone()
2101 }
2102
2103 fn send(&self, message: &Message) -> Result<()> {
2104 let status = *self.status.lock_or_recover();
2105 if status != TransportStatus::Available {
2106 return Err(crate::Error::TransportNotAvailable(format!(
2107 "Nostr transport is {:?}",
2108 status
2109 )));
2110 }
2111
2112 // The recipient is the sole preimage of the `#p` tag this frame is
2113 // published under, so it is refused here rather than hashed — the same
2114 // rule `with_config` applies to our own id, and for the same two silent
2115 // failures: a username-shaped id puts a label anyone can recompute from
2116 // the name onto third-party relays, and any non-address id addresses
2117 // the frame where nobody subscribes.
2118 //
2119 // Refused at `send` rather than in `build_event` because a build
2120 // failure is retriable by the drain: a permanently undeliverable
2121 // recipient would burn the whole `MAX_SIGN_RETRIES` ladder, republishing
2122 // that label each time, before failing. Here it costs one error to the
2123 // caller, which routes to another transport or fails the message.
2124 //
2125 // The value stays out of the error for the reason `with_config` keeps
2126 // it out: the id this rejects is, in the case worth catching, a
2127 // username.
2128 if message.recipient.as_str().parse::<Address>().is_err() {
2129 return Err(crate::Error::PeerNotReachable(
2130 "Nostr addresses peers by their derived address, and the \
2131 recipient id is not one"
2132 .to_string(),
2133 ));
2134 }
2135
2136 let queue_len = {
2137 let mut queue = self.send_queue.lock_or_recover();
2138 queue.push_back(message.clone());
2139 queue.len()
2140 };
2141
2142 let mut metrics = self.metrics.lock_or_recover();
2143 metrics.queue_depth = queue_len;
2144 metrics.congestion = ((queue_len as f32) / 50.0).clamp(0.0, 1.0);
2145 drop(metrics);
2146
2147 self.notify_messages_available();
2148
2149 Ok(())
2150 }
2151
2152 fn receive(&self) -> Result<Option<Message>> {
2153 let mut queue = self.receive_queue.lock_or_recover();
2154 Ok(queue.pop_front())
2155 }
2156
2157 fn start(&self) -> Result<()> {
2158 // Actual connection is managed by the platform.
2159 // Status is updated via on_status_changed().
2160 Ok(())
2161 }
2162
2163 /// Stops the transport, clearing in-flight queues.
2164 ///
2165 /// `receive_watermark` is deliberately **not** cleared: it records how far
2166 /// receive progress has reached, which a `stop()`/`start()` cycle does not
2167 /// undo. Clearing it would make every restart fall back to the first-run
2168 /// backfill window and replay history the engine has already processed.
2169 fn stop(&self) -> Result<()> {
2170 *self.status.lock_or_recover() = TransportStatus::Disconnected;
2171 self.fail_all_pending();
2172 self.send_queue.lock_or_recover().clear();
2173 self.receive_queue.lock_or_recover().clear();
2174 Ok(())
2175 }
2176
2177 /// Called when connection status changes.
2178 ///
2179 /// Resets reconnect counter on successful connection.
2180 /// Fails all pending confirmations on disconnect.
2181 fn on_status_changed(&self, status: TransportStatus) {
2182 let previous_status = {
2183 let mut guard = self.status.lock_or_recover();
2184 let prev = *guard;
2185 *guard = status;
2186 prev
2187 };
2188
2189 if status == TransportStatus::Available {
2190 let queue_len = self.send_queue.lock_or_recover().len();
2191 *self.reconnect_attempts.lock_or_recover() = 0;
2192
2193 if queue_len > 0 {
2194 tracing::info!(
2195 pending_messages = queue_len,
2196 "Nostr transport available, {} messages pending in queue",
2197 queue_len
2198 );
2199 }
2200 } else if previous_status == TransportStatus::Available
2201 && status != TransportStatus::Available
2202 {
2203 self.fail_all_pending();
2204
2205 let queue_len = self.send_queue.lock_or_recover().len();
2206 if queue_len > 0 {
2207 tracing::warn!(
2208 pending_messages = queue_len,
2209 new_status = ?status,
2210 "Nostr transport disconnected with {} messages in queue (will retry)",
2211 queue_len
2212 );
2213 }
2214 }
2215 }
2216
2217 /// Queues an inbound frame that has **already been unsealed**.
2218 ///
2219 /// Unsealing needs the wrapper event's `pubkey` (the sender's single-use
2220 /// key), which this signature has no way to carry, so the caller must run
2221 /// [`NostrTransport::unseal_event_payload`] first — the FFI receive entry
2222 /// does. A still-sealed frame arriving here cannot be recovered, so it is
2223 /// reported rather than being left to look like ordinary malformed data.
2224 fn on_data_received(&self, data: Vec<u8>) -> Result<()> {
2225 if nostr_crypto::is_sealed_payload(&data) {
2226 tracing::warn!(
2227 "Sealed Nostr frame reached the unsealed receive path; dropping. \
2228 Callers must unseal via unseal_event_payload() first."
2229 );
2230 return Ok(());
2231 }
2232 crate::common::on_data_received(&self.receive_queue, data)
2233 }
2234
2235 /// Like [`Transport::on_data_received`], but attaches a
2236 /// transport-verified `peer_id` to the deserialized message.
2237 fn on_data_received_from(&self, data: Vec<u8>, peer_id: String) -> Result<()> {
2238 crate::common::on_data_received_from(&self.receive_queue, data, peer_id)
2239 }
2240
2241 /// **Refused.** This transport has no unsealed whole-message drain.
2242 ///
2243 /// The signature can only return a bare serialized `Message` — the entire
2244 /// protocol envelope, both endpoints included — with no gift wrap, no
2245 /// signature and no event around it, so publishing the result would put
2246 /// exactly the cleartext this transport exists to avoid in front of every
2247 /// relay. There is nowhere to put a signed, sealed event in a
2248 /// `(String, Vec<u8>)`, and a Nostr frame without its event envelope is
2249 /// not deliverable anyway.
2250 ///
2251 /// It used to return that cleartext, with this comment warning callers off
2252 /// it. A doc comment is not a control: this method sits on the generic
2253 /// [`Transport`] trait, which the engine hands out as `dyn Transport` from
2254 /// `TransportManager::get_transport`, so reaching the unsealed bytes took
2255 /// no downcast and no unsafe — and the leak ran regardless of
2256 /// `nostr_sealing_enabled`, since this path never consulted it. The
2257 /// refusal is the enforcement.
2258 ///
2259 /// Poll [`NostrTransport::get_next_signed_event`] instead: it produces a
2260 /// complete signed, sealed `["EVENT", …]` message ready for the wire. That
2261 /// is what the bundled bridges and the UniFFI `nostr_get_next_message`
2262 /// entry call.
2263 ///
2264 /// **Nothing is consumed here.** The refusal returns before the send queue
2265 /// or `pending_confirmation` are read, so a caller that reaches this by
2266 /// mistake cannot also steal a frame out from under the sealed drain — it
2267 /// stays queued, and the next `get_next_signed_event` serves it. What such
2268 /// a caller *will* observe is a queue that only grows: `queue_depth` rises,
2269 /// `congestion` saturates at 1.0 by depth 50, and DORS stops selecting
2270 /// Nostr. That is the intended shape of the failure — traffic reroutes
2271 /// instead of publishing cleartext — but it is a misintegration, not
2272 /// backpressure, which is why it is also logged.
2273 ///
2274 /// The refusal is logged **once** per transport, not per call. Every
2275 /// in-tree caller of this trait method takes the shape
2276 /// `if let Ok(Some((..))) = t.get_next_message()`, which discards an `Err`
2277 /// down the same silent path as `Ok(None)` — so for exactly the mistake
2278 /// this guard exists to catch, returning `Err` is no louder than the
2279 /// `Ok(None)` it was chosen over. The log is what makes it loud; the
2280 /// one-shot is what keeps a polling caller from burying it.
2281 fn get_next_message(&self) -> Result<Option<(String, Vec<u8>)>> {
2282 if !self
2283 .generic_poll_refusal_logged
2284 .swap(true, Ordering::Relaxed)
2285 {
2286 tracing::error!(
2287 "Nostr get_next_message() refused: this transport has no \
2288 unsealed whole-message drain. Poll get_next_signed_event() \
2289 instead. Outbound frames stay queued until it is polled. \
2290 Logged once per transport."
2291 );
2292 }
2293
2294 Err(crate::Error::ConfigurationError(
2295 "Nostr has no unsealed message drain: get_next_message() would \
2296 return the bare protocol envelope in cleartext. Poll \
2297 NostrTransport::get_next_signed_event() instead, which returns a \
2298 signed, sealed [\"EVENT\", …] relay message. The queued frame is \
2299 untouched."
2300 .to_string(),
2301 ))
2302 }
2303
2304 /// Sets the callback invoked when outgoing messages are queued.
2305 fn set_on_messages_available(&self, callback: Arc<dyn Fn() + Send + Sync>) {
2306 *self.on_messages_available.lock_or_recover() = Some(callback);
2307 }
2308
2309 /// Platform confirms a message was sent successfully.
2310 fn confirm_sent(&self, message_id: &str) {
2311 let removed = self
2312 .pending_confirmation
2313 .lock_or_recover()
2314 .remove(message_id);
2315
2316 // A publication is not a message and never moves the delivery metrics
2317 // — see [`SyntheticPublication`] for why counting it would misreport
2318 // this transport's reliability to DORS. The rule is the same for every
2319 // record kind, so this tests only that the id *is* synthetic.
2320 if removed.is_some() && synthetic_publication(message_id).is_none() {
2321 let mut metrics = self.metrics.lock_or_recover();
2322 metrics.success_count = metrics.success_count.saturating_add(1);
2323 recalculate_delivery_ratios(&mut metrics);
2324 }
2325
2326 // Unconditional for the same reason the failure path is: a confirmation
2327 // that races the pending-confirmation timeout finds nothing to remove,
2328 // and the relay accepted the event either way. Reporting it is what
2329 // lets the engine retire a retraction it owes, so dropping it on the
2330 // race would leave that name being retracted on every tick forever.
2331 match synthetic_publication(message_id) {
2332 Some(SyntheticPublication::Discovery(tag)) => {
2333 self.mark_discovery_publication_confirmed(tag)
2334 }
2335 Some(SyntheticPublication::KeyPackage(_)) | Some(SyntheticPublication::Deletion) => {}
2336 None => {}
2337 }
2338 }
2339
2340 /// Platform reports a send failure.
2341 fn report_send_failure(&self, message_id: &str) {
2342 let removed = self
2343 .pending_confirmation
2344 .lock_or_recover()
2345 .remove(message_id);
2346
2347 // Same rule as `confirm_sent`: a publication's outcome is reported to
2348 // the engine below, never to the delivery metrics DORS scores on.
2349 if removed.is_some() && synthetic_publication(message_id).is_none() {
2350 let mut metrics = self.metrics.lock_or_recover();
2351 metrics.failure_count = metrics.failure_count.saturating_add(1);
2352 recalculate_delivery_ratios(&mut metrics);
2353 }
2354
2355 // Unconditional, unlike the metrics above: a report that races the
2356 // confirmation timeout finds no pending entry, and missing a real
2357 // failure strands the record until restart while a redundant republish
2358 // costs one idempotent relay write.
2359 match synthetic_publication(message_id) {
2360 Some(SyntheticPublication::KeyPackage(slot_id)) => {
2361 self.mark_publications_failed(vec![slot_id])
2362 }
2363 Some(SyntheticPublication::Discovery(tag)) => {
2364 self.mark_discovery_publications_failed(vec![tag])
2365 }
2366 Some(SyntheticPublication::Deletion) => {}
2367 None => {}
2368 }
2369 }
2370}
2371
2372/// Builder for [`NostrTransport`].
2373pub struct NostrTransportBuilder {
2374 device_id: String,
2375 config: NostrConfig,
2376}
2377
2378impl NostrTransportBuilder {
2379 /// Creates a new builder.
2380 pub fn new(device_id: impl Into<String>) -> Self {
2381 Self {
2382 device_id: device_id.into(),
2383 config: NostrConfig::default(),
2384 }
2385 }
2386
2387 /// Sets the relay URLs.
2388 pub fn relay_urls(mut self, urls: Vec<String>) -> Self {
2389 self.config.relay_urls = urls;
2390 self
2391 }
2392
2393 /// Adds a single relay URL.
2394 pub fn add_relay_url(mut self, url: impl Into<String>) -> Self {
2395 self.config.relay_urls.push(url.into());
2396 self
2397 }
2398
2399 /// Sets the connection timeout.
2400 pub fn connection_timeout(mut self, timeout: Duration) -> Self {
2401 self.config.connection_timeout = timeout;
2402 self
2403 }
2404
2405 /// Sets whether to auto-reconnect.
2406 pub fn auto_reconnect(mut self, auto_reconnect: bool) -> Self {
2407 self.config.auto_reconnect = auto_reconnect;
2408 self
2409 }
2410
2411 /// Sets the reconnection delay.
2412 pub fn reconnect_delay(mut self, delay: Duration) -> Self {
2413 self.config.reconnect_delay = delay;
2414 self
2415 }
2416
2417 /// Sets the maximum reconnection attempts.
2418 pub fn max_reconnect_attempts(mut self, max: u32) -> Self {
2419 self.config.max_reconnect_attempts = max;
2420 self
2421 }
2422
2423 /// Builds the transport.
2424 pub fn build(self) -> Result<NostrTransport> {
2425 NostrTransport::with_config(self.device_id, self.config)
2426 }
2427}
2428
2429#[cfg(test)]
2430mod tests {
2431 use super::*;
2432 use crate::constants::DEFAULT_MAX_MESSAGE_SIZE;
2433 use offline_protocol_core::{AppId, UserId};
2434
2435 /// The address a test label stands for.
2436 ///
2437 /// Every id this transport touches — its own, a recipient's, a peer being
2438 /// resolved — is a derived address now, and construction refuses anything
2439 /// else, so a fixture cannot simply *be* `"alice"`. It can hold the same
2440 /// address every time it says `"alice"`, which is what this gives.
2441 ///
2442 /// Seeded from the label rather than randomly so a failure reproduces, and
2443 /// built straight from the hash rather than through a real keypair because
2444 /// nothing here verifies the address against a key: the transport only
2445 /// hashes it. The core crate's `test_identity::id` is the version that does
2446 /// come from a key, for fixtures that also have to satisfy MLS.
2447 fn addr(label: &str) -> String {
2448 addr_typed(label).to_string()
2449 }
2450
2451 /// A parsed [`Username`], for the derivations that take one.
2452 fn user(label: &str) -> Username {
2453 label.parse().expect("test username should parse")
2454 }
2455
2456 /// `addr` as the parsed type, for the derivations that take an [`Address`].
2457 fn addr_typed(label: &str) -> Address {
2458 use sha2::{Digest, Sha256};
2459 let digest = Sha256::digest(label.as_bytes());
2460 let mut hash = [0u8; Address::HASH_LEN];
2461 hash.copy_from_slice(&digest[..Address::HASH_LEN]);
2462 Address::from_hash_bytes(hash)
2463 }
2464
2465 fn create_test_message() -> Message {
2466 Message::new(
2467 UserId::new(addr("alice")).unwrap(),
2468 UserId::new(addr("bob")).unwrap(),
2469 AppId::new("test").unwrap(),
2470 "Test message",
2471 )
2472 }
2473
2474 /// An id that is not an address is refused, not hashed into a tag.
2475 ///
2476 /// The two failures this prevents are both silent — a guessable preimage
2477 /// is a disclosure nothing reports, a merely-wrong one addresses this
2478 /// device where nobody writes — so construction is the only place either
2479 /// can be made to surface.
2480 #[test]
2481 fn test_construction_refuses_an_id_that_is_not_an_address() {
2482 for bad in [
2483 "device1",
2484 "alice",
2485 "",
2486 // Right shape, wrong checksum: the check must be the real address
2487 // parse, not a prefix or length test.
2488 "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
2489 // Canonical address, uppercased. Accepting both spellings would
2490 // give one identity two routing tags.
2491 &addr("alice").to_uppercase(),
2492 ] {
2493 assert!(
2494 NostrTransport::new(bad).is_err(),
2495 "construction must refuse a non-address id: {bad:?}"
2496 );
2497 }
2498
2499 assert!(
2500 NostrTransport::new(addr("alice")).is_ok(),
2501 "a derived address is exactly what construction accepts"
2502 );
2503 }
2504
2505 /// A recipient that is not an address is refused at `send`, not hashed
2506 /// into a public routing tag.
2507 ///
2508 /// The constructor already refuses a non-address for *our* id; this is the
2509 /// same rule on the other side of the frame, and it has the same two silent
2510 /// failure modes. It is asserted at `send` specifically because that is the
2511 /// only queue writer: past it, the drain would treat the failure as
2512 /// retriable and republish the label `MAX_SIGN_RETRIES` times.
2513 #[test]
2514 fn test_send_refuses_a_recipient_that_is_not_an_address() {
2515 let transport = NostrTransport::new(addr("alice")).unwrap();
2516 transport.start().unwrap();
2517 transport.on_status_changed(TransportStatus::Available);
2518
2519 let msg = Message::new(
2520 UserId::new(addr("alice")).unwrap(),
2521 UserId::new("bob").unwrap(),
2522 AppId::new("test").unwrap(),
2523 "for a username",
2524 );
2525
2526 assert!(
2527 transport.send(&msg).is_err(),
2528 "a username-shaped recipient must not reach the send queue"
2529 );
2530 assert!(
2531 !transport.has_pending_sends(),
2532 "the refused message must not be queued"
2533 );
2534 assert!(
2535 transport.get_next_signed_event().unwrap().is_none(),
2536 "nothing may be published for a refused recipient"
2537 );
2538
2539 // The address form of the same peer is what does go out.
2540 let ok = Message::new(
2541 UserId::new(addr("alice")).unwrap(),
2542 UserId::new(addr("bob")).unwrap(),
2543 AppId::new("test").unwrap(),
2544 "for an address",
2545 );
2546 assert!(transport.send(&ok).is_ok());
2547 }
2548
2549 /// The resolution queue applies the same rule at its only gate, so
2550 /// `next_query` cannot pop an id whose tag derivation would fail after the
2551 /// entry was already consumed.
2552 #[test]
2553 fn test_resolution_refuses_a_peer_id_that_is_not_an_address() {
2554 let transport = NostrTransport::new(addr("alice")).unwrap();
2555
2556 for bad in ["bob", "", "off1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"] {
2557 assert!(
2558 !transport.request_peer_key_packages(bad),
2559 "a non-address peer id must not be queued for resolution: {bad:?}"
2560 );
2561 }
2562 assert!(
2563 transport.next_query().unwrap().is_none(),
2564 "no query may be issued for a refused peer id"
2565 );
2566
2567 assert!(
2568 transport.request_peer_key_packages(&addr("bob")),
2569 "an address is exactly what the resolution queue accepts"
2570 );
2571 }
2572
2573 #[test]
2574 fn test_nostr_transport_creation() {
2575 let transport = NostrTransport::new(addr("device1")).unwrap();
2576 assert_eq!(transport.device_id(), addr("device1"));
2577 assert_eq!(transport.transport_type(), TransportType::Nostr);
2578 assert_eq!(transport.status(), TransportStatus::Unavailable);
2579 }
2580
2581 #[test]
2582 fn test_builder() {
2583 let transport = NostrTransportBuilder::new(addr("device1"))
2584 .relay_urls(vec!["wss://relay.example.com".to_string()])
2585 .add_relay_url("wss://relay2.example.com")
2586 .connection_timeout(Duration::from_secs(60))
2587 .auto_reconnect(false)
2588 .reconnect_delay(Duration::from_secs(10))
2589 .max_reconnect_attempts(5)
2590 .build()
2591 .unwrap();
2592 assert_eq!(transport.config().relay_urls.len(), 2);
2593 assert_eq!(
2594 transport.config().connection_timeout,
2595 Duration::from_secs(60)
2596 );
2597 assert!(!transport.config().auto_reconnect);
2598 assert_eq!(transport.config().reconnect_delay, Duration::from_secs(10));
2599 assert_eq!(transport.config().max_reconnect_attempts, 5);
2600 }
2601
2602 // `test_send_receive` lived here. Its two assertions moved to the sealed
2603 // drain rather than being duplicated onto it: the queue-to-pending
2604 // transition is `test_get_next_signed_event`, and the content round trip
2605 // is `test_sealed_frame_round_trips_through_the_recipients_transport`,
2606 // which asserts the sender as well as the id.
2607
2608 #[test]
2609 fn test_send_when_unavailable_fails() {
2610 let transport = NostrTransport::new(addr("device1")).unwrap();
2611 let msg = create_test_message();
2612 assert!(transport.send(&msg).is_err());
2613 }
2614
2615 #[test]
2616 fn test_receive_when_empty_returns_none() {
2617 let transport = NostrTransport::new(addr("device1")).unwrap();
2618 assert!(transport.receive().unwrap().is_none());
2619 }
2620
2621 #[test]
2622 fn test_confirmation_loop() {
2623 let transport = NostrTransport::new(addr("device1")).unwrap();
2624 transport.start().unwrap();
2625 transport.on_status_changed(TransportStatus::Available);
2626
2627 let msg = create_test_message();
2628 transport.send(&msg).unwrap();
2629
2630 let signed = transport.get_next_signed_event().unwrap().unwrap();
2631 transport.confirm_sent(&signed.message_id);
2632
2633 let metrics = transport.metrics();
2634 assert_eq!(metrics.success_count, 1);
2635 assert_eq!(metrics.failure_count, 0);
2636 }
2637
2638 #[test]
2639 fn test_send_failure_reporting() {
2640 let transport = NostrTransport::new(addr("device1")).unwrap();
2641 transport.start().unwrap();
2642 transport.on_status_changed(TransportStatus::Available);
2643
2644 let msg = create_test_message();
2645 transport.send(&msg).unwrap();
2646
2647 let signed = transport.get_next_signed_event().unwrap().unwrap();
2648 transport.report_send_failure(&signed.message_id);
2649
2650 let metrics = transport.metrics();
2651 assert_eq!(metrics.success_count, 0);
2652 assert_eq!(metrics.failure_count, 1);
2653 }
2654
2655 #[test]
2656 fn test_fail_all_pending_on_disconnect() {
2657 let transport = NostrTransport::new(addr("device1")).unwrap();
2658 transport.start().unwrap();
2659 transport.on_status_changed(TransportStatus::Available);
2660
2661 let msg = create_test_message();
2662 transport.send(&msg).unwrap();
2663 let _ = transport.get_next_signed_event().unwrap();
2664
2665 transport.on_status_changed(TransportStatus::Disconnected);
2666
2667 let metrics = transport.metrics();
2668 assert_eq!(metrics.failure_count, 1);
2669 }
2670
2671 #[test]
2672 fn test_stop_fails_pending() {
2673 let transport = NostrTransport::new(addr("device1")).unwrap();
2674 transport.start().unwrap();
2675 transport.on_status_changed(TransportStatus::Available);
2676
2677 let msg = create_test_message();
2678 transport.send(&msg).unwrap();
2679 let _ = transport.get_next_signed_event().unwrap();
2680
2681 transport.stop().unwrap();
2682
2683 let metrics = transport.metrics();
2684 assert_eq!(metrics.failure_count, 1);
2685 }
2686
2687 #[test]
2688 fn test_serialization() {
2689 let transport = NostrTransport::new(addr("device1")).unwrap();
2690 let msg = create_test_message();
2691 let data = transport.serialize_message(&msg).unwrap();
2692 let deserialized = transport.deserialize_message(&data).unwrap();
2693 assert_eq!(deserialized.id, msg.id);
2694 }
2695
2696 #[test]
2697 fn test_reconnect_logic() {
2698 let transport = NostrTransportBuilder::new(addr("device1"))
2699 .max_reconnect_attempts(3)
2700 .build()
2701 .unwrap();
2702
2703 assert!(transport.should_reconnect());
2704 transport.increment_reconnect_attempts();
2705 transport.increment_reconnect_attempts();
2706 assert!(transport.should_reconnect());
2707 transport.increment_reconnect_attempts();
2708 assert!(!transport.should_reconnect());
2709 }
2710
2711 #[test]
2712 fn test_on_data_received_invalid_json_drops_ok() {
2713 let transport = NostrTransport::new(addr("device1")).unwrap();
2714 let result = transport.on_data_received(b"not json".to_vec());
2715 assert!(result.is_ok());
2716 assert!(transport.receive().unwrap().is_none());
2717 }
2718
2719 #[test]
2720 fn test_on_data_received_rejects_oversized_payload() {
2721 let transport = NostrTransport::new(addr("device1")).unwrap();
2722 let oversized = vec![0u8; DEFAULT_MAX_MESSAGE_SIZE + 1];
2723 let result = transport.on_data_received(oversized);
2724 assert!(result.is_err());
2725 }
2726
2727 #[test]
2728 fn test_on_messages_available_callback() {
2729 let transport = NostrTransport::new(addr("device1")).unwrap();
2730 transport.start().unwrap();
2731 transport.on_status_changed(TransportStatus::Available);
2732
2733 let called = Arc::new(Mutex::new(false));
2734 let called_clone = Arc::clone(&called);
2735 transport.set_on_messages_available(Arc::new(move || {
2736 *called_clone.lock().unwrap() = true;
2737 }));
2738
2739 let msg = create_test_message();
2740 transport.send(&msg).unwrap();
2741 assert!(*called.lock().unwrap());
2742 }
2743
2744 #[test]
2745 fn test_messages_available_callback_reentrant_send() {
2746 use std::sync::atomic::{AtomicBool, Ordering};
2747
2748 let transport = Arc::new(NostrTransport::new(addr("device1")).unwrap());
2749 transport.on_status_changed(TransportStatus::Available);
2750
2751 let reentered = Arc::new(AtomicBool::new(false));
2752 let reentered_clone = Arc::clone(&reentered);
2753 let transport_clone = Arc::clone(&transport);
2754 transport.set_on_messages_available(Arc::new(move || {
2755 // Re-enters send() from inside the callback. If send() held the
2756 // callback mutex across this call, the inner send would
2757 // self-deadlock re-locking it.
2758 if !reentered_clone.swap(true, Ordering::SeqCst) {
2759 transport_clone.send(&create_test_message()).unwrap();
2760 }
2761 }));
2762
2763 transport.send(&create_test_message()).unwrap();
2764
2765 assert!(reentered.load(Ordering::SeqCst));
2766 assert_eq!(transport.send_queue.lock().unwrap().len(), 2);
2767 }
2768
2769 #[test]
2770 fn test_update_metrics_preserves_confirmation_counts() {
2771 let transport = NostrTransport::new(addr("device1")).unwrap();
2772 transport.start().unwrap();
2773 transport.on_status_changed(TransportStatus::Available);
2774
2775 let msg = create_test_message();
2776 transport.send(&msg).unwrap();
2777 let signed = transport.get_next_signed_event().unwrap().unwrap();
2778 transport.confirm_sent(&signed.message_id);
2779
2780 let mut new_metrics = TransportMetrics::default();
2781 new_metrics.rssi = Some(-70);
2782 transport.update_metrics(new_metrics);
2783
2784 let metrics = transport.metrics();
2785 assert_eq!(metrics.success_count, 1);
2786 assert_eq!(metrics.rssi, Some(-70));
2787 }
2788
2789 #[test]
2790 fn test_platform_handle() {
2791 let transport = NostrTransport::new(addr("device1")).unwrap();
2792 assert!(transport.platform_handle().is_none());
2793 transport.set_platform_handle(42);
2794 assert_eq!(transport.platform_handle(), Some(42));
2795 }
2796
2797 #[test]
2798 fn test_drain_expired_pending_expires_old_entries() {
2799 let transport = NostrTransport::new(addr("device1")).unwrap();
2800 transport.start().unwrap();
2801 transport.on_status_changed(TransportStatus::Available);
2802
2803 // Insert a pending entry that is already past the timeout by backdating it.
2804 let timeout_secs = NOSTR_PENDING_CONFIRMATION_TIMEOUT_SECS;
2805 let expired_at = Instant::now() - Duration::from_secs(timeout_secs + 1);
2806 transport
2807 .pending_confirmation
2808 .lock()
2809 .unwrap()
2810 .insert("expired-msg".to_string(), expired_at);
2811
2812 // Insert a recent pending entry that should survive.
2813 transport
2814 .pending_confirmation
2815 .lock()
2816 .unwrap()
2817 .insert("recent-msg".to_string(), Instant::now());
2818
2819 transport.drain_expired_pending();
2820
2821 let pending = transport.pending_confirmation.lock().unwrap();
2822 assert!(
2823 !pending.contains_key("expired-msg"),
2824 "Expired entry should have been drained"
2825 );
2826 assert!(
2827 pending.contains_key("recent-msg"),
2828 "Recent entry should be retained"
2829 );
2830 drop(pending);
2831
2832 let metrics = transport.metrics();
2833 assert_eq!(
2834 metrics.failure_count, 1,
2835 "Expired entry should be counted as a failure"
2836 );
2837 }
2838
2839 #[test]
2840 fn test_has_pending_sends() {
2841 let transport = NostrTransport::new(addr("device1")).unwrap();
2842 transport.start().unwrap();
2843 transport.on_status_changed(TransportStatus::Available);
2844
2845 assert!(!transport.has_pending_sends());
2846
2847 let msg = create_test_message();
2848 transport.send(&msg).unwrap();
2849 assert!(transport.has_pending_sends());
2850 }
2851
2852 #[test]
2853 fn test_pending_confirmation_count() {
2854 let transport = NostrTransport::new(addr("device1")).unwrap();
2855 transport.start().unwrap();
2856 transport.on_status_changed(TransportStatus::Available);
2857
2858 assert_eq!(transport.pending_confirmation_count(), 0);
2859
2860 let msg = create_test_message();
2861 transport.send(&msg).unwrap();
2862 let _ = transport.get_next_signed_event().unwrap();
2863
2864 assert_eq!(transport.pending_confirmation_count(), 1);
2865 }
2866
2867 #[test]
2868 fn test_default_config() {
2869 let config = NostrConfig::default();
2870 assert!(config.relay_urls.is_empty());
2871 assert_eq!(
2872 config.connection_timeout,
2873 Duration::from_secs(NOSTR_CONNECTION_TIMEOUT_SECS)
2874 );
2875 assert!(config.auto_reconnect);
2876 assert_eq!(config.reconnect_delay, Duration::from_secs(5));
2877 assert_eq!(config.max_reconnect_attempts, 0);
2878 }
2879
2880 #[test]
2881 fn test_get_next_signed_event() {
2882 let transport = NostrTransport::new(addr("device1")).unwrap();
2883 transport.start().unwrap();
2884 transport.on_status_changed(TransportStatus::Available);
2885
2886 let msg = create_test_message();
2887 transport.send(&msg).unwrap();
2888
2889 let signed = transport.get_next_signed_event().unwrap().unwrap();
2890 assert!(!signed.message_id.is_empty());
2891 assert_eq!(signed.event_id.len(), 64); // 32-byte SHA-256 hex
2892 assert!(signed.event_json.starts_with("[\"EVENT\",{"));
2893 assert!(signed.event_json.ends_with("}]"));
2894 assert!(signed.event_json.contains(&signed.event_id));
2895
2896 // Message was dequeued and moved to pending confirmation
2897 assert!(!transport.has_pending_sends());
2898 assert_eq!(transport.pending_confirmation_count(), 1);
2899
2900 // No more messages
2901 assert!(transport.get_next_signed_event().unwrap().is_none());
2902 }
2903
2904 /// The generic whole-message poll must refuse rather than hand back the
2905 /// unsealed envelope, and must cost the sealed drain nothing.
2906 ///
2907 /// Dispatched through `&dyn Transport` on purpose: that is the shape the
2908 /// leak had. `TransportManager::get_transport` hands out an
2909 /// `Arc<dyn Transport>`, so this was reachable with no downcast and no
2910 /// unsafe, and a concrete-typed call here would not pin the vtable entry
2911 /// that actually mattered.
2912 #[test]
2913 fn test_generic_transport_poll_refuses_rather_than_leaking_cleartext() {
2914 let transport = NostrTransport::new(addr("device1")).unwrap();
2915 transport.start().unwrap();
2916 transport.on_status_changed(TransportStatus::Available);
2917
2918 let msg = create_test_message();
2919 transport.send(&msg).unwrap();
2920
2921 let generic: &dyn Transport = &transport;
2922 let err = generic.get_next_message().unwrap_err();
2923 // Asserted on `code()` rather than the variant or the message text:
2924 // `Error` documents the code as the stable contract and `Display` as
2925 // free to change, so this is the surface a caller is entitled to
2926 // classify on.
2927 assert_eq!(err.code(), "CONFIGURATION_ERROR", "got {err:?}");
2928
2929 // The other generic egress door must stay shut too. Nostr is safe here
2930 // only because it does not override the trait's `Ok(None)` default —
2931 // nothing else records that as a requirement, so a later fragmenting
2932 // implementation would reopen this leak on a method nobody thinks of
2933 // as a leak surface.
2934 assert!(
2935 generic.get_next_fragment().unwrap().is_none(),
2936 "Nostr must expose no generic fragment drain either"
2937 );
2938
2939 // The one-shot on the log must gate the *log*, never the refusal: a
2940 // second caller has to be refused exactly as loudly as the first.
2941 let second = generic.get_next_message().unwrap_err();
2942 assert_eq!(second.code(), "CONFIGURATION_ERROR", "got {second:?}");
2943
2944 // The refusal returns before the queue or the pending map are touched,
2945 // so a caller that lands here by mistake cannot also strand the frame
2946 // it failed to get: nothing is dequeued, and nothing is left awaiting a
2947 // confirmation that no bridge will ever send.
2948 assert!(
2949 transport.has_pending_sends(),
2950 "the refused frame must stay queued"
2951 );
2952 assert_eq!(
2953 transport.pending_confirmation_count(),
2954 0,
2955 "a refusal must not enter the confirmation loop"
2956 );
2957
2958 // ...and the sealed drain still serves that same frame.
2959 let signed = transport.get_next_signed_event().unwrap().unwrap();
2960 assert_eq!(signed.message_id, msg.id.to_string());
2961 assert!(signed.event_json.starts_with("[\"EVENT\",{"));
2962 }
2963
2964 #[test]
2965 fn test_get_next_signed_event_confirm_flow() {
2966 let transport = NostrTransport::new(addr("device1")).unwrap();
2967 transport.start().unwrap();
2968 transport.on_status_changed(TransportStatus::Available);
2969
2970 let msg = create_test_message();
2971 transport.send(&msg).unwrap();
2972
2973 let signed = transport.get_next_signed_event().unwrap().unwrap();
2974 transport.confirm_sent(&signed.message_id);
2975
2976 let metrics = transport.metrics();
2977 assert_eq!(metrics.success_count, 1);
2978 assert_eq!(metrics.failure_count, 0);
2979 assert_eq!(transport.pending_confirmation_count(), 0);
2980 }
2981
2982 #[test]
2983 fn test_subscription_filters_on_routing_tag_not_signing_key() {
2984 let transport = NostrTransport::new(addr("device1")).unwrap();
2985 let expected_tag = nostr_crypto::routing_tag_for_address(&addr_typed("device1")).unwrap();
2986
2987 assert_eq!(transport.routing_tag(), expected_tag);
2988 // The signing key is random per install and must never leak into the
2989 // subscription filter, which peers derive from our device_id.
2990 assert_ne!(transport.public_key_hex(), expected_tag);
2991
2992 let filter = transport.create_subscription("sub1").unwrap();
2993 assert!(filter.contains(&expected_tag));
2994 assert!(!filter.contains(&transport.public_key_hex()));
2995 }
2996
2997 #[test]
2998 fn test_install_signing_secret_gives_stable_identity() {
2999 let transport_a = NostrTransport::new(addr("device1")).unwrap();
3000 let transport_b = NostrTransport::new(addr("device1")).unwrap();
3001
3002 // Ephemeral keys are random: two instances differ.
3003 assert_ne!(transport_a.public_key_hex(), transport_b.public_key_hex());
3004
3005 // Installing the same persisted secret (a simulated restart)
3006 // converges both on the same identity.
3007 let secret = [42u8; 32];
3008 transport_a.install_signing_secret(&secret).unwrap();
3009 transport_b.install_signing_secret(&secret).unwrap();
3010 assert_eq!(transport_a.public_key_hex(), transport_b.public_key_hex());
3011
3012 // Addressing is untouched by the key swap.
3013 assert_eq!(
3014 transport_a.routing_tag(),
3015 nostr_crypto::routing_tag_for_address(&addr_typed("device1")).unwrap()
3016 );
3017 }
3018
3019 #[test]
3020 fn test_oversized_event_is_dropped_permanently_and_does_not_block_queue() {
3021 let transport = NostrTransport::new(addr("device1")).unwrap();
3022 transport.start().unwrap();
3023 transport.on_status_changed(TransportStatus::Available);
3024
3025 let oversized = Message::new(
3026 UserId::new(addr("alice")).unwrap(),
3027 UserId::new(addr("bob")).unwrap(),
3028 AppId::new("test").unwrap(),
3029 "x".repeat(NOSTR_MAX_PAYLOAD_SIZE),
3030 );
3031 transport.send(&oversized).unwrap();
3032 let queued_behind = create_test_message();
3033 transport.send(&queued_behind).unwrap();
3034
3035 let err = transport.get_next_signed_event().unwrap_err();
3036 assert!(
3037 matches!(
3038 err,
3039 crate::Error::MessageTooLarge(actual, limit)
3040 if actual > NOSTR_MAX_PAYLOAD_SIZE && limit == NOSTR_MAX_PAYLOAD_SIZE
3041 ),
3042 "expected MessageTooLarge, got {err:?}"
3043 );
3044
3045 // Dropped on the first attempt rather than re-queued at the front for
3046 // MAX_SIGN_RETRIES rounds, so the message behind it is served now.
3047 let signed = transport.get_next_signed_event().unwrap().unwrap();
3048 assert_eq!(signed.message_id, queued_behind.id.to_string());
3049
3050 assert!(
3051 transport.sign_retry_counts.lock().unwrap().is_empty(),
3052 "an unshrinkable message must not accumulate retry state"
3053 );
3054 assert_eq!(
3055 transport.metrics().failure_count,
3056 1,
3057 "the drop must reach metrics, or DORS never learns Nostr failed"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_size_cap_measures_the_relay_message_not_the_inner_payload() {
3063 let transport = NostrTransport::new(addr("device1")).unwrap();
3064 transport.start().unwrap();
3065 transport.on_status_changed(TransportStatus::Available);
3066
3067 // The serialized message fits under the cap; base64 inflation (4/3)
3068 // pushes the event a relay actually sees over it. Capping the inner
3069 // payload instead would let this onto the wire to be rejected there.
3070 let msg = Message::new(
3071 UserId::new(addr("alice")).unwrap(),
3072 UserId::new(addr("bob")).unwrap(),
3073 AppId::new("test").unwrap(),
3074 "x".repeat(50_000),
3075 );
3076 assert!(
3077 transport.serialize_message(&msg).unwrap().len() < NOSTR_MAX_PAYLOAD_SIZE,
3078 "test premise: the inner payload is under the cap"
3079 );
3080
3081 transport.send(&msg).unwrap();
3082 assert!(matches!(
3083 transport.get_next_signed_event().unwrap_err(),
3084 crate::Error::MessageTooLarge(_, _)
3085 ));
3086 }
3087
3088 #[test]
3089 fn test_default_size_media_chunk_exceeds_the_relay_cap() {
3090 // Ground truth for why the cap matters, and why it is not a
3091 // regression: DORS gives Nostr a media_bonus of 30.0, so media routes
3092 // here — but `Message::binary_content` has no base64 serde adapter, so
3093 // a chunk becomes a JSON array of decimal numbers (~3.6x) before the
3094 // event's own base64 (~1.33x) is applied on top. At the engine's
3095 // 32 KiB DEFAULT_CHUNK_SIZE that is ~156 KB on the wire, well past
3096 // both this cap and the 64-128 KB relays typically accept. Such events
3097 // were never deliverable; they now fail here instead of at the relay.
3098 let transport = NostrTransport::new(addr("device1")).unwrap();
3099 transport.start().unwrap();
3100 transport.on_status_changed(TransportStatus::Available);
3101
3102 let mut chunk_message = create_test_message();
3103 chunk_message.content = String::new();
3104 chunk_message.binary_content = Some(vec![0xABu8; 32 * 1024]);
3105
3106 transport.send(&chunk_message).unwrap();
3107 assert!(matches!(
3108 transport.get_next_signed_event().unwrap_err(),
3109 crate::Error::MessageTooLarge(_, _)
3110 ));
3111
3112 // A BLE-sized 4 KiB chunk still fits, so the cap does not forbid
3113 // media over Nostr outright — only the default chunking for it.
3114 let mut small_chunk = create_test_message();
3115 small_chunk.content = String::new();
3116 small_chunk.binary_content = Some(vec![0xABu8; 4 * 1024]);
3117
3118 transport.send(&small_chunk).unwrap();
3119 assert!(transport.get_next_signed_event().unwrap().is_some());
3120 }
3121
3122 /// Parses the REQ filter object out of a `["REQ", id, {…}]` message.
3123 fn subscription_filter(transport: &NostrTransport) -> serde_json::Value {
3124 let msg = transport.create_subscription("sub1").unwrap();
3125 let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
3126 parsed[2].clone()
3127 }
3128
3129 #[test]
3130 fn test_first_run_since_is_a_recent_window_never_zero() {
3131 // A zero (or absent) `since` is the unbounded filter the watermark
3132 // exists to remove, and no-watermark is the *common* case: the bridges
3133 // subscribe on every relay connect, which can precede the restore.
3134 let transport = NostrTransport::new(addr("device1")).unwrap();
3135 assert!(transport.receive_watermark_secs().is_none());
3136
3137 let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3138 let now = now_unix_secs();
3139
3140 assert!(since > 0, "first-run since must not be zero: {since}");
3141 let backfill = now - since;
3142 assert!(
3143 (NOSTR_FIRST_RUN_BACKFILL_SECS..NOSTR_FIRST_RUN_BACKFILL_SECS + 60).contains(&backfill),
3144 "first-run since should reach back one backfill window, got {backfill}s"
3145 );
3146 }
3147
3148 #[test]
3149 fn test_since_follows_the_watermark_with_a_jitter_and_skew_overlap() {
3150 let transport = NostrTransport::new(addr("device1")).unwrap();
3151 let mark = now_unix_secs() - 30;
3152 assert!(transport.advance_receive_watermark(mark));
3153
3154 let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3155 assert_eq!(
3156 since,
3157 mark - NOSTR_CREATED_AT_JITTER_SECS - NOSTR_CLOCK_SKEW_MARGIN_SECS,
3158 "since must sit a full jitter + skew window below the watermark"
3159 );
3160 }
3161
3162 #[test]
3163 fn test_since_overlap_covers_the_gift_wrap_jitter_window() {
3164 // The sealed-envelope work jitters `created_at` backwards by up to
3165 // NOSTR_CREATED_AT_JITTER_SECS, so an event published *now* can carry a
3166 // timestamp that far in the past. If `since` did not reach back at
3167 // least that far past the mark, those events would be filtered out by
3168 // the very relay query meant to fetch them — a silent delivery loss
3169 // with no error anywhere. This pins the two uses of the constant
3170 // together.
3171 let transport = NostrTransport::new(addr("device1")).unwrap();
3172 let mark = now_unix_secs();
3173 transport.advance_receive_watermark(mark);
3174
3175 let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3176 assert!(
3177 mark - since >= NOSTR_CREATED_AT_JITTER_SECS,
3178 "since must reach at least a jitter window below the mark"
3179 );
3180 }
3181
3182 #[test]
3183 fn test_watermark_only_advances() {
3184 let transport = NostrTransport::new(addr("device1")).unwrap();
3185 let base = now_unix_secs() - 3600;
3186
3187 assert!(transport.advance_receive_watermark(base));
3188 assert_eq!(transport.receive_watermark_secs(), Some(base));
3189
3190 // Relays stream stored events newest-first, so older ones arrive after
3191 // newer ones. They must not walk the mark back.
3192 assert!(!transport.advance_receive_watermark(base - 500));
3193 assert_eq!(transport.receive_watermark_secs(), Some(base));
3194
3195 // Equal timestamps are not progress either.
3196 assert!(!transport.advance_receive_watermark(base));
3197
3198 assert!(transport.advance_receive_watermark(base + 1));
3199 assert_eq!(transport.receive_watermark_secs(), Some(base + 1));
3200 }
3201
3202 #[test]
3203 fn test_future_dated_event_cannot_stall_the_subscription() {
3204 // The routing tag is derived from a public user id, so anyone can
3205 // publish an event addressed to us, and `created_at` is whatever the
3206 // publisher wrote. Accepting a far-future value would pin the mark
3207 // there and every later subscription would ask for events `since` the
3208 // far future — receiving nothing, permanently, with no error raised.
3209 let transport = NostrTransport::new(addr("device1")).unwrap();
3210 let honest = now_unix_secs() - 10;
3211 transport.advance_receive_watermark(honest);
3212
3213 let year_3000 = 32_503_680_000i64;
3214 assert!(
3215 !transport.advance_receive_watermark(year_3000),
3216 "a far-future created_at must not advance the watermark"
3217 );
3218 assert_eq!(transport.receive_watermark_secs(), Some(honest));
3219
3220 let since = subscription_filter(&transport)["since"].as_i64().unwrap();
3221 assert!(
3222 since < now_unix_secs(),
3223 "since must stay in the past: {since}"
3224 );
3225 }
3226
3227 #[test]
3228 fn test_non_positive_created_at_is_ignored() {
3229 // A missing `created_at` reaches the FFI as 0; a malformed one can be
3230 // negative. Neither is receive progress.
3231 let transport = NostrTransport::new(addr("device1")).unwrap();
3232 assert!(!transport.advance_receive_watermark(0));
3233 assert!(!transport.advance_receive_watermark(-1));
3234 assert!(transport.receive_watermark_secs().is_none());
3235 }
3236
3237 #[test]
3238 fn test_stop_preserves_the_receive_watermark() {
3239 // stop() clears in-flight queues, but receive progress is not undone by
3240 // a restart — resetting it here would replay a full backfill window on
3241 // every stop/start cycle.
3242 let transport = NostrTransport::new(addr("device1")).unwrap();
3243 let mark = now_unix_secs() - 60;
3244 transport.advance_receive_watermark(mark);
3245
3246 transport.stop().unwrap();
3247
3248 assert_eq!(transport.receive_watermark_secs(), Some(mark));
3249 }
3250
3251 #[test]
3252 fn test_signed_event_uses_recipient_routing_tag_and_own_signing_key() {
3253 let transport = NostrTransport::new(addr("device1")).unwrap();
3254 transport.start().unwrap();
3255 transport.on_status_changed(TransportStatus::Available);
3256 // Addressing is a property of the unsealed path too; assert it there so
3257 // this test keeps covering the legacy shape rather than duplicating the
3258 // sealed-path tests below.
3259 transport.set_sealing_enabled(false);
3260
3261 // create_test_message is addressed to "bob".
3262 let msg = create_test_message();
3263 transport.send(&msg).unwrap();
3264
3265 let signed = transport.get_next_signed_event().unwrap().unwrap();
3266 let bob_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3267 assert!(
3268 signed.event_json.contains(&bob_tag),
3269 "event must be addressed to the recipient's routing tag"
3270 );
3271 assert!(
3272 signed
3273 .event_json
3274 .contains(&format!("\"pubkey\":\"{}\"", transport.public_key_hex())),
3275 "unsealed event must be signed by this install's signing key"
3276 );
3277 }
3278
3279 // ===================================================================
3280 // Sealed envelope
3281 // ===================================================================
3282
3283 /// Parses the event object out of a signed `["EVENT", {…}]` message.
3284 fn event_object(signed: &SignedNostrEvent) -> serde_json::Value {
3285 let parsed: serde_json::Value = serde_json::from_str(&signed.event_json).unwrap();
3286 parsed[1].clone()
3287 }
3288
3289 #[test]
3290 fn test_relay_visible_payload_contains_no_username() {
3291 // THE regression test for the finding this work closes. Before sealing,
3292 // the event content was base64 of the whole `Message` JSON, so both
3293 // usernames, the app id, the content type, the metadata map and a
3294 // millisecond timestamp were readable by every relay, permanently.
3295 //
3296 // Asserted over the whole event except the ciphertext: a future change
3297 // that moved a username into a tag, or reintroduced a stable signing
3298 // pubkey derived from one, must fail here too. The ciphertext itself is
3299 // excluded only *after* pinning its sealed shape — it is random bytes,
3300 // and a case-folded substring assertion over its base64 spells "bob"
3301 // roughly once per hundred runs, failing CI with no leak present.
3302 let transport = NostrTransport::new(addr("alice")).unwrap();
3303 transport.start().unwrap();
3304 transport.on_status_changed(TransportStatus::Available);
3305
3306 let msg = Message::new(
3307 UserId::new(addr("alice")).unwrap(),
3308 UserId::new(addr("bob")).unwrap(),
3309 AppId::new("example-app").unwrap(),
3310 "the quick brown fox",
3311 );
3312 transport.send(&msg).unwrap();
3313
3314 let signed = transport.get_next_signed_event().unwrap().unwrap();
3315
3316 // The content must be a sealed NIP-44 payload — not the pre-sealing
3317 // leak shape, base64 of the message JSON.
3318 let mut event = event_object(&signed);
3319 let content = base64::engine::general_purpose::STANDARD
3320 .decode(event["content"].as_str().unwrap())
3321 .unwrap();
3322 assert!(
3323 nostr_crypto::is_sealed_payload(&content),
3324 "event content is not a sealed payload: {}",
3325 signed.event_json
3326 );
3327
3328 // With the random ciphertext scrubbed, nothing left in the event may
3329 // name anyone or anything. `content` is the only non-hex field, so
3330 // nothing else can spell these strings by chance.
3331 event["content"] = serde_json::Value::String(String::new());
3332 let wire = serde_json::to_string(&event).unwrap().to_lowercase();
3333
3334 for leak in ["alice", "bob", "example-app", "the quick brown fox"] {
3335 assert!(
3336 !wire.contains(leak),
3337 "relay-visible payload leaks {leak:?}: {}",
3338 signed.event_json
3339 );
3340 }
3341
3342 // The only thing a relay legitimately learns is the recipient's routing
3343 // tag — an opaque label it cannot invert without already knowing the
3344 // user id it is looking for.
3345 let bob_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3346 assert!(wire.contains(&bob_tag));
3347 }
3348
3349 #[test]
3350 fn test_sealed_event_is_a_gift_wrap_not_signed_by_our_install_key() {
3351 let transport = NostrTransport::new(addr("alice")).unwrap();
3352 transport.start().unwrap();
3353 transport.on_status_changed(TransportStatus::Available);
3354 transport.send(&create_test_message()).unwrap();
3355
3356 let signed = transport.get_next_signed_event().unwrap().unwrap();
3357 let event = event_object(&signed);
3358
3359 assert_eq!(
3360 event["kind"].as_u64(),
3361 Some(u64::from(nostr_crypto::NOSTR_GIFT_WRAP_KIND))
3362 );
3363 assert_ne!(
3364 event["pubkey"].as_str().unwrap(),
3365 transport.public_key_hex(),
3366 "the wrapper must not be signed by our stable install key, or every \
3367 event we publish is linkable back to this device"
3368 );
3369 }
3370
3371 #[test]
3372 fn test_sealed_frame_round_trips_through_the_recipients_transport() {
3373 // End-to-end over the two transports, which is what proves the send and
3374 // receive halves agree about which key seals what.
3375 let alice = NostrTransport::new(addr("alice")).unwrap();
3376 alice.start().unwrap();
3377 alice.on_status_changed(TransportStatus::Available);
3378
3379 let bob = NostrTransport::new(addr("bob")).unwrap();
3380 bob.install_signing_secret(&[88u8; 32]).unwrap();
3381 // Alice has seen Bob's key package, so this is the steady state.
3382 alice.set_peer_nostr_pubkey(&addr("bob"), &bob.public_key_hex());
3383
3384 let msg = create_test_message();
3385 alice.send(&msg).unwrap();
3386 let signed = alice.get_next_signed_event().unwrap().unwrap();
3387
3388 let event = event_object(&signed);
3389 let sealed = base64::engine::general_purpose::STANDARD
3390 .decode(event["content"].as_str().unwrap())
3391 .unwrap();
3392 let sender_pubkey = event["pubkey"].as_str().unwrap();
3393
3394 let plaintext = bob.unseal_event_payload(sender_pubkey, &sealed);
3395 let received = bob.deserialize_message(&plaintext).unwrap();
3396 assert_eq!(received.id, msg.id);
3397 assert_eq!(received.sender.as_str(), addr("alice"));
3398 }
3399
3400 #[test]
3401 fn test_bootstrap_frame_round_trips_before_any_key_exchange() {
3402 // Cold first contact: Alice knows only Bob's user id. The frame is
3403 // sealed to Bob's publicly computable key, and Bob's receive path finds
3404 // it on the second attempt.
3405 let alice = NostrTransport::new(addr("alice")).unwrap();
3406 alice.start().unwrap();
3407 alice.on_status_changed(TransportStatus::Available);
3408
3409 let bob = NostrTransport::new(addr("bob")).unwrap();
3410 bob.install_signing_secret(&[91u8; 32]).unwrap();
3411 // Deliberately NOT calling set_peer_nostr_pubkey.
3412
3413 let msg = create_test_message();
3414 alice.send(&msg).unwrap();
3415 let signed = alice.get_next_signed_event().unwrap().unwrap();
3416 let event = event_object(&signed);
3417
3418 // Indistinguishable from the steady-state case on the wire: same kind,
3419 // same tag shape, ephemeral pubkey. A relay cannot filter for "these
3420 // two are just starting to talk".
3421 assert_eq!(
3422 event["kind"].as_u64(),
3423 Some(u64::from(nostr_crypto::NOSTR_GIFT_WRAP_KIND))
3424 );
3425
3426 let sealed = base64::engine::general_purpose::STANDARD
3427 .decode(event["content"].as_str().unwrap())
3428 .unwrap();
3429 let plaintext = bob.unseal_event_payload(event["pubkey"].as_str().unwrap(), &sealed);
3430 assert_eq!(bob.deserialize_message(&plaintext).unwrap().id, msg.id);
3431 }
3432
3433 #[test]
3434 fn test_unseal_leaves_legacy_unsealed_frames_untouched() {
3435 // A peer on a build from before sealing publishes the old cleartext
3436 // envelope. It must pass straight through, or upgrading breaks every
3437 // conversation with a peer that has not upgraded yet.
3438 let alice = NostrTransport::new(addr("alice")).unwrap();
3439 alice.set_sealing_enabled(false);
3440 alice.start().unwrap();
3441 alice.on_status_changed(TransportStatus::Available);
3442
3443 let msg = create_test_message();
3444 alice.send(&msg).unwrap();
3445 let signed = alice.get_next_signed_event().unwrap().unwrap();
3446 let event = event_object(&signed);
3447 assert_eq!(
3448 event["kind"].as_u64(),
3449 Some(u64::from(nostr_crypto::NOSTR_LEGACY_DM_KIND))
3450 );
3451
3452 let legacy = base64::engine::general_purpose::STANDARD
3453 .decode(event["content"].as_str().unwrap())
3454 .unwrap();
3455
3456 let bob = NostrTransport::new(addr("bob")).unwrap();
3457 let passed_through = bob.unseal_event_payload(event["pubkey"].as_str().unwrap(), &legacy);
3458 assert_eq!(passed_through.as_ref(), legacy.as_slice());
3459 assert_eq!(bob.deserialize_message(&passed_through).unwrap().id, msg.id);
3460 }
3461
3462 #[test]
3463 fn test_frame_sealed_to_someone_else_is_returned_unchanged_not_forged() {
3464 // An event addressed to our routing tag but sealed to a third party
3465 // must fail closed. Returning the ciphertext unchanged lets the
3466 // ordinary decoder reject it as one undecodable frame — the same
3467 // outcome as random junk, which is what it is to us.
3468 let bob = NostrTransport::new(addr("bob")).unwrap();
3469 let carol_tag = nostr_crypto::routing_tag_for_address(&addr_typed("carol")).unwrap();
3470
3471 let event =
3472 nostr_crypto::NostrEvent::create_gift_wrap(&carol_tag, &carol_tag, b"not for bob")
3473 .unwrap();
3474 let sealed = base64::engine::general_purpose::STANDARD
3475 .decode(&event.content)
3476 .unwrap();
3477
3478 let out = bob.unseal_event_payload(&event.pubkey, &sealed);
3479 assert_eq!(out.as_ref(), sealed.as_slice());
3480 assert!(bob.deserialize_message(&out).is_err());
3481 }
3482
3483 #[test]
3484 fn test_sealed_frame_on_the_unsealed_receive_path_is_dropped_not_queued() {
3485 // `on_data_received` cannot unseal — it has no access to the wrapper's
3486 // pubkey. A bridge wired to it would otherwise enqueue ciphertext as if
3487 // it were a message.
3488 let bob = NostrTransport::new(addr("bob")).unwrap();
3489 let bob_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3490 let event =
3491 nostr_crypto::NostrEvent::create_gift_wrap(&bob_tag, &bob_tag, b"sealed").unwrap();
3492 let sealed = base64::engine::general_purpose::STANDARD
3493 .decode(&event.content)
3494 .unwrap();
3495
3496 assert!(bob.on_data_received(sealed).is_ok());
3497 assert!(bob.receive().unwrap().is_none());
3498 }
3499
3500 #[test]
3501 fn test_peer_key_map_is_bounded_and_rejects_malformed_keys() {
3502 let transport = NostrTransport::new(addr("device1")).unwrap();
3503
3504 transport.set_peer_nostr_pubkey(&addr("bob"), "not-hex");
3505 transport.set_peer_nostr_pubkey(&addr("bob"), &"ab".repeat(31)); // 62 chars
3506 transport.set_peer_nostr_pubkey("", &"ab".repeat(32));
3507 assert!(transport.peer_nostr_pubkey(&addr("bob")).is_none());
3508
3509 // Case is normalized: `#p`-style values are lowercase hex by spec, and a
3510 // mixed-case duplicate must not seal to a different string.
3511 let key = "AB".repeat(32);
3512 transport.set_peer_nostr_pubkey(&addr("bob"), &key);
3513 assert_eq!(
3514 transport.peer_nostr_pubkey(&addr("bob")),
3515 Some(key.to_lowercase())
3516 );
3517
3518 for i in 0..NOSTR_MAX_TRACKED_PEER_KEYS + 10 {
3519 transport.set_peer_nostr_pubkey(&format!("peer{i}"), &"cd".repeat(32));
3520 }
3521 assert!(
3522 transport.peer_nostr_pubkeys.read().unwrap().len() <= NOSTR_MAX_TRACKED_PEER_KEYS,
3523 "wire-keyed map must stay bounded"
3524 );
3525 }
3526
3527 #[test]
3528 fn test_sealing_can_be_disabled_without_breaking_inbound_unsealing() {
3529 // The kill switch gates the send side only. A peer that keeps sealing
3530 // must stay readable, or flipping the flag on one device would sever
3531 // conversations rather than merely un-protecting our own traffic.
3532 let alice = NostrTransport::new(addr("alice")).unwrap();
3533 alice.start().unwrap();
3534 alice.on_status_changed(TransportStatus::Available);
3535
3536 let bob = NostrTransport::new(addr("bob")).unwrap();
3537 bob.set_sealing_enabled(false);
3538 assert!(!bob.sealing_enabled());
3539
3540 let msg = create_test_message();
3541 alice.send(&msg).unwrap();
3542 let signed = alice.get_next_signed_event().unwrap().unwrap();
3543 let event = event_object(&signed);
3544 let sealed = base64::engine::general_purpose::STANDARD
3545 .decode(event["content"].as_str().unwrap())
3546 .unwrap();
3547
3548 let plaintext = bob.unseal_event_payload(event["pubkey"].as_str().unwrap(), &sealed);
3549 assert_eq!(bob.deserialize_message(&plaintext).unwrap().id, msg.id);
3550 }
3551
3552 #[test]
3553 fn test_sealing_overhead_stays_within_the_relay_cap_for_ordinary_messages() {
3554 // NIP-44 pads to a power-of-two bucket, so sealing can nearly double a
3555 // payload before base64 — considerably more than the ~33% a base64
3556 // layer alone would suggest. Ordinary text messages must still fit
3557 // comfortably, and the cap must be measured on the sealed event.
3558 let transport = NostrTransport::new(addr("alice")).unwrap();
3559 transport.start().unwrap();
3560 transport.on_status_changed(TransportStatus::Available);
3561
3562 let msg = Message::new(
3563 UserId::new(addr("alice")).unwrap(),
3564 UserId::new(addr("bob")).unwrap(),
3565 AppId::new("test").unwrap(),
3566 "x".repeat(4096),
3567 );
3568 transport.send(&msg).unwrap();
3569
3570 let signed = transport.get_next_signed_event().unwrap().unwrap();
3571 assert!(
3572 signed.event_json.len() <= NOSTR_MAX_PAYLOAD_SIZE,
3573 "a 4 KiB message must still fit sealed: {} bytes",
3574 signed.event_json.len()
3575 );
3576 }
3577
3578 // ========================================================================
3579 // Published key packages (cold contact)
3580 // ========================================================================
3581
3582 /// The regression test for the reason published records are sealed at all.
3583 ///
3584 /// A key package carries its owner's user id in the payload *and*,
3585 /// unremovably, in the MLS leaf credential. Published in the clear, a
3586 /// filter naming only the kind — no tag, no author — would return a
3587 /// directory of every username on the relay, which is precisely the
3588 /// preimage the `SHA-256(user_id)` routing tag exists to withhold.
3589 #[test]
3590 fn test_published_key_package_record_leaks_no_username() {
3591 let transport = NostrTransport::new(addr("alice-the-identifiable")).unwrap();
3592 transport.start().unwrap();
3593 transport.on_status_changed(TransportStatus::Available);
3594
3595 // Stand in for a key package: what matters is that the user id appears
3596 // in the plaintext, which it does in both of the real carriers.
3597 let payload = br#"{"user_id":"alice-the-identifiable","key_package_data":[1,2,3]}"#;
3598 transport.publish_key_package("slot-a", payload.to_vec());
3599
3600 let signed = transport.get_next_signed_event().unwrap().unwrap();
3601 assert!(
3602 !signed.event_json.contains("alice-the-identifiable"),
3603 "a published record must not carry the username in the clear: {}",
3604 signed.event_json
3605 );
3606 }
3607
3608 #[test]
3609 fn test_published_record_is_addressable_and_opens_with_the_derivable_key() {
3610 let transport = NostrTransport::new(addr("alice")).unwrap();
3611 transport.install_signing_secret(&[7u8; 32]).unwrap();
3612 transport.start().unwrap();
3613 transport.on_status_changed(TransportStatus::Available);
3614
3615 transport.publish_key_package("slot-a", b"the key package".to_vec());
3616 let signed = transport.get_next_signed_event().unwrap().unwrap();
3617 let event = event_object(&signed);
3618
3619 assert_eq!(
3620 event["kind"].as_u64(),
3621 Some(u64::from(nostr_crypto::NOSTR_KEY_PACKAGE_KIND))
3622 );
3623 // Signed by the real install key — that is how a fetcher learns which
3624 // key to seal to — unlike a gift wrap's throwaway key.
3625 assert_eq!(
3626 event["pubkey"].as_str().unwrap(),
3627 transport.public_key_hex(),
3628 "a fetcher reads the sealing key off this field"
3629 );
3630 let tags = event["tags"].as_array().unwrap();
3631 assert_eq!(tags[0][0].as_str(), Some("d"));
3632 assert_eq!(tags[0][1].as_str(), Some("slot-a"));
3633 assert_eq!(tags[1][0].as_str(), Some("p"));
3634 assert_eq!(tags[1][1].as_str(), Some(transport.routing_tag()));
3635
3636 // A stranger who knows only the username can open it.
3637 let stranger_view =
3638 nostr_crypto::record_seal_keypair_for_address(&addr_typed("alice")).unwrap();
3639 let sealed = base64::engine::general_purpose::STANDARD
3640 .decode(event["content"].as_str().unwrap())
3641 .unwrap();
3642 let opened = nostr_crypto::open_key_package_publication(
3643 &stranger_view,
3644 event["pubkey"].as_str().unwrap(),
3645 &sealed,
3646 )
3647 .unwrap();
3648 assert_eq!(opened, b"the key package");
3649 }
3650
3651 /// A published record's `created_at` must not be jittered backwards the way
3652 /// a gift wrap's is. Relays keep the newest event per `(kind, pubkey, d)`,
3653 /// so a backdated republication is silently discarded — leaving a consumed
3654 /// key package standing as the live record.
3655 #[test]
3656 fn test_republication_is_not_backdated_below_the_record_it_replaces() {
3657 let transport = NostrTransport::new(addr("alice")).unwrap();
3658 transport.start().unwrap();
3659 transport.on_status_changed(TransportStatus::Available);
3660
3661 let now = nostr_crypto::now_unix_secs();
3662 transport.publish_key_package("slot-a", b"first".to_vec());
3663 let first = event_object(&transport.get_next_signed_event().unwrap().unwrap());
3664
3665 let created = first["created_at"].as_i64().unwrap();
3666 assert!(
3667 created >= now,
3668 "publication was backdated by {}s; a relay would drop the replacement",
3669 now - created
3670 );
3671 }
3672
3673 /// Re-queueing a slot before it drains must collapse to the newer payload:
3674 /// the older one names a key package the engine has already replaced, and
3675 /// publishing both would briefly stand a consumed package back up.
3676 #[test]
3677 fn test_requeued_slot_collapses_to_the_newer_payload() {
3678 let transport = NostrTransport::new(addr("alice")).unwrap();
3679 transport.start().unwrap();
3680 transport.on_status_changed(TransportStatus::Available);
3681
3682 transport.publish_key_package("slot-a", b"stale".to_vec());
3683 transport.publish_key_package("slot-a", b"fresh".to_vec());
3684
3685 let signed = transport.get_next_signed_event().unwrap().unwrap();
3686 let event = event_object(&signed);
3687 let sealed = base64::engine::general_purpose::STANDARD
3688 .decode(event["content"].as_str().unwrap())
3689 .unwrap();
3690 let key = nostr_crypto::record_seal_keypair_for_address(&addr_typed("alice")).unwrap();
3691 let opened = nostr_crypto::open_key_package_publication(
3692 &key,
3693 event["pubkey"].as_str().unwrap(),
3694 &sealed,
3695 )
3696 .unwrap();
3697 assert_eq!(opened, b"fresh");
3698
3699 assert!(
3700 transport.get_next_signed_event().unwrap().is_none(),
3701 "the superseded payload must not also publish"
3702 );
3703 }
3704
3705 #[test]
3706 fn test_publications_drain_ahead_of_messages() {
3707 let transport = NostrTransport::new(addr("alice")).unwrap();
3708 transport.start().unwrap();
3709 transport.on_status_changed(TransportStatus::Available);
3710
3711 transport.send(&create_test_message()).unwrap();
3712 transport.publish_key_package("slot-a", b"kp".to_vec());
3713
3714 let first = transport.get_next_signed_event().unwrap().unwrap();
3715 assert!(
3716 first.message_id.starts_with(NOSTR_PUBLICATION_ID_PREFIX),
3717 "a refreshed slot must reach the relay before the traffic that \
3718 depends on it, got {}",
3719 first.message_id
3720 );
3721 }
3722
3723 /// A publication that leaves the queue but never reaches a relay must be
3724 /// reported back, or the engine — which marked the slot published when it
3725 /// queued the record — leaves it looking healthy for the life of the
3726 /// process while the relays hold nothing.
3727 #[test]
3728 fn test_rejected_publication_is_reported_back_for_republication() {
3729 let transport = NostrTransport::new(addr("alice")).unwrap();
3730 transport.start().unwrap();
3731 transport.on_status_changed(TransportStatus::Available);
3732
3733 transport.publish_key_package("slot-a", b"kp".to_vec());
3734 let signed = transport.get_next_signed_event().unwrap().unwrap();
3735 assert!(
3736 transport.take_failed_publications().is_empty(),
3737 "a publication in flight is not yet a failure"
3738 );
3739
3740 transport.report_send_failure(&signed.message_id);
3741
3742 assert_eq!(
3743 transport.take_failed_publications(),
3744 vec!["slot-a".to_string()],
3745 "a relay rejection must surface the slot for republication"
3746 );
3747 assert!(
3748 transport.take_failed_publications().is_empty(),
3749 "draining must consume the report"
3750 );
3751 }
3752
3753 /// The same, for the disconnect path: dropping the relay fails every
3754 /// in-flight event, and a publication among them is no exception.
3755 #[test]
3756 fn test_disconnect_reports_in_flight_publications_for_republication() {
3757 let transport = NostrTransport::new(addr("alice")).unwrap();
3758 transport.start().unwrap();
3759 transport.on_status_changed(TransportStatus::Available);
3760
3761 transport.publish_key_package("slot-a", b"kp".to_vec());
3762 transport.get_next_signed_event().unwrap().unwrap();
3763
3764 transport.on_status_changed(TransportStatus::Disconnected);
3765
3766 assert_eq!(
3767 transport.take_failed_publications(),
3768 vec!["slot-a".to_string()],
3769 "a publication in flight when the relay dropped must be republished"
3770 );
3771 }
3772
3773 /// A message failing must not be mistaken for a publication failing — the
3774 /// slot set is keyed on the synthetic publication id and nothing else.
3775 #[test]
3776 fn test_message_failure_reports_no_publication_slot() {
3777 let transport = NostrTransport::new(addr("alice")).unwrap();
3778 transport.start().unwrap();
3779 transport.on_status_changed(TransportStatus::Available);
3780
3781 transport.send(&create_test_message()).unwrap();
3782 let signed = transport.get_next_signed_event().unwrap().unwrap();
3783 transport.report_send_failure(&signed.message_id);
3784
3785 assert!(transport.take_failed_publications().is_empty());
3786 }
3787
3788 #[test]
3789 fn test_cold_contact_disabled_publishes_nothing_and_resolves_nothing() {
3790 let transport = NostrTransport::new(addr("alice")).unwrap();
3791 transport.set_cold_contact_enabled(false);
3792 transport.start().unwrap();
3793 transport.on_status_changed(TransportStatus::Available);
3794
3795 transport.publish_key_package("slot-a", b"kp".to_vec());
3796 assert!(!transport.has_pending_publications());
3797 assert!(!transport.request_peer_key_packages(&addr("bob")));
3798 assert!(transport.next_query().unwrap().is_none());
3799 }
3800
3801 #[test]
3802 fn test_send_to_an_unknown_peer_queues_a_resolution_and_still_sends() {
3803 let transport = NostrTransport::new(addr("alice")).unwrap();
3804 transport.start().unwrap();
3805 transport.on_status_changed(TransportStatus::Available);
3806
3807 // No key for bob: the frame takes the bootstrap leg *and* asks.
3808 transport.send(&create_test_message()).unwrap();
3809 let signed = transport.get_next_signed_event().unwrap().unwrap();
3810 assert!(
3811 !signed.message_id.starts_with(NOSTR_PUBLICATION_ID_PREFIX),
3812 "the message must still go out rather than waiting on a round-trip"
3813 );
3814
3815 let query = transport.next_query().unwrap().expect("resolution queued");
3816 let expected_tag = nostr_crypto::routing_tag_for_address(&addr_typed("bob")).unwrap();
3817 assert!(query.req_json.contains(&expected_tag));
3818 assert!(query
3819 .req_json
3820 .contains(&nostr_crypto::NOSTR_KEY_PACKAGE_KIND.to_string()));
3821 }
3822
3823 #[test]
3824 fn test_resolution_is_rate_limited_per_peer() {
3825 let transport = NostrTransport::new(addr("alice")).unwrap();
3826 assert!(transport.request_peer_key_packages(&addr("bob")));
3827 assert!(
3828 !transport.request_peer_key_packages(&addr("bob")),
3829 "a peer who has published nothing must not mint a round-trip per frame"
3830 );
3831 assert!(transport.request_peer_key_packages(&addr("carol")));
3832 }
3833
3834 /// The probe says what a request would do and does none of it: the engine
3835 /// asks it before a storage read, so a probe that queued or stamped would
3836 /// spend the interval on a peer the engine then decides not to ask for.
3837 #[test]
3838 fn test_resolution_due_agrees_with_the_request_and_changes_nothing() {
3839 let transport = NostrTransport::new(addr("alice")).unwrap();
3840 assert!(transport.resolution_due(&addr("bob")));
3841 assert!(
3842 transport.resolution_due(&addr("bob")),
3843 "asking twice changes nothing"
3844 );
3845 assert!(
3846 transport.next_query().unwrap().is_none(),
3847 "the probe queued nothing"
3848 );
3849
3850 assert!(transport.request_peer_key_packages(&addr("bob")));
3851 assert!(
3852 !transport.resolution_due(&addr("bob")),
3853 "queued, then rate-limited"
3854 );
3855 assert!(transport.resolution_due(&addr("carol")));
3856 assert!(!transport.resolution_due("not-an-address"));
3857
3858 transport.set_cold_contact_enabled(false);
3859 assert!(!transport.resolution_due(&addr("carol")));
3860 }
3861
3862 #[test]
3863 fn test_query_event_opens_only_for_the_peer_it_was_issued_for() {
3864 let alice = NostrTransport::new(addr("alice")).unwrap();
3865 let bob = NostrTransport::new(addr("bob")).unwrap();
3866 bob.install_signing_secret(&[9u8; 32]).unwrap();
3867 bob.start().unwrap();
3868 bob.on_status_changed(TransportStatus::Available);
3869
3870 bob.publish_key_package("slot-a", b"bob's key package".to_vec());
3871 let published = bob.get_next_signed_event().unwrap().unwrap();
3872 let event = event_object(&published);
3873 let event_json = serde_json::to_string(&event).unwrap();
3874
3875 alice.request_peer_key_packages(&addr("bob"));
3876 let query = alice.next_query().unwrap().unwrap();
3877
3878 let opened = alice
3879 .open_query_event(&query.query_id, &event_json)
3880 .unwrap()
3881 .expect("bob's record opens with bob's derivable key");
3882 match opened {
3883 ResolvedRecord::KeyPackage { plaintext, .. } => {
3884 assert_eq!(plaintext, b"bob's key package");
3885 }
3886 ResolvedRecord::Discovery { .. } => {
3887 panic!("a key-package query must not yield a discovery record")
3888 }
3889 }
3890
3891 // The same record delivered under a query for someone else does not
3892 // open: the query id is what says whose key to try.
3893 alice.request_peer_key_packages(&addr("carol"));
3894 let other = alice.next_query().unwrap().unwrap();
3895 assert!(alice
3896 .open_query_event(&other.query_id, &event_json)
3897 .unwrap()
3898 .is_none());
3899 }
3900
3901 #[test]
3902 fn test_query_event_for_an_unknown_or_completed_query_is_dropped() {
3903 let alice = NostrTransport::new(addr("alice")).unwrap();
3904 alice.request_peer_key_packages(&addr("bob"));
3905 let query = alice.next_query().unwrap().unwrap();
3906 alice.complete_query(&query.query_id);
3907
3908 assert!(alice
3909 .open_query_event(&query.query_id, "{\"kind\":30443}")
3910 .unwrap()
3911 .is_none());
3912 assert!(alice
3913 .open_query_event("never-issued", "{\"kind\":30443}")
3914 .unwrap()
3915 .is_none());
3916 }
3917
3918 /// A public routing tag means anyone can leave anything there, so a query
3919 /// returns whatever the relay holds. Junk must be dropped quietly rather
3920 /// than failing the query.
3921 #[test]
3922 fn test_query_event_ignores_wrong_kinds_and_unopenable_content() {
3923 let alice = NostrTransport::new(addr("alice")).unwrap();
3924 alice.request_peer_key_packages(&addr("bob"));
3925 let query = alice.next_query().unwrap().unwrap();
3926
3927 let wrong_kind = r#"{"kind":1059,"pubkey":"aa","content":"AQID"}"#;
3928 assert!(alice
3929 .open_query_event(&query.query_id, wrong_kind)
3930 .unwrap()
3931 .is_none());
3932
3933 let bad_content = r#"{"kind":30443,"pubkey":"not-hex","content":"!!!"}"#;
3934 assert!(alice
3935 .open_query_event(&query.query_id, bad_content)
3936 .unwrap()
3937 .is_none());
3938
3939 assert!(alice
3940 .open_query_event(&query.query_id, "not json at all")
3941 .unwrap()
3942 .is_none());
3943 }
3944
3945 /// A publication is not a message, and DORS does not get to hear about it.
3946 ///
3947 /// The reliability score is `success / (success + failure)` over lifetime
3948 /// counters that never decay, and an idle install publishes far more than
3949 /// it sends — so counting publications would score this transport on
3950 /// something other than its ability to carry messages. A relay that
3951 /// rejects kind 30443 would drive the ratio toward zero and make DORS
3952 /// deprioritise Nostr for traffic that delivers perfectly well.
3953 #[test]
3954 fn test_publication_failures_stay_out_of_the_delivery_metrics() {
3955 let transport = NostrTransport::new(addr("alice")).unwrap();
3956 transport.start().unwrap();
3957 transport.on_status_changed(TransportStatus::Available);
3958
3959 // One real message, delivered.
3960 transport.send(&create_test_message()).unwrap();
3961 let msg = transport.get_next_signed_event().unwrap().unwrap();
3962 transport.confirm_sent(&msg.message_id);
3963
3964 // A full slot set the relay rejects outright.
3965 for i in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
3966 transport.publish_key_package(&format!("slot-{}", i), b"kp".to_vec());
3967 }
3968 for _ in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
3969 let published = transport.get_next_signed_event().unwrap().unwrap();
3970 assert!(published
3971 .message_id
3972 .starts_with(NOSTR_PUBLICATION_ID_PREFIX));
3973 transport.report_send_failure(&published.message_id);
3974 }
3975
3976 let metrics = transport.metrics();
3977 assert_eq!(metrics.success_count, 1);
3978 assert_eq!(
3979 metrics.failure_count, 0,
3980 "publication rejections were counted as message delivery failures"
3981 );
3982 assert_eq!(
3983 metrics.delivery_ratio,
3984 Some(1.0),
3985 "the only message sent was delivered; the ratio DORS reads must say so"
3986 );
3987
3988 // ...and the slots are still reported back for republication.
3989 assert_eq!(
3990 transport.take_failed_publications().len(),
3991 crate::constants::NOSTR_KEY_PACKAGE_SLOTS,
3992 "keeping publications out of the metrics must not lose the reports"
3993 );
3994 }
3995
3996 /// The same rule, for the second record type.
3997 ///
3998 /// This is the test that exists because the rule was *nearly* half-wired
3999 /// once: adding a record kind means adding a discriminator, and a
4000 /// discriminator consulted at three of four sites produces exactly the DORS
4001 /// poisoning the key-package version above prevents — silently, since
4002 /// nothing observes a transport's score directly.
4003 ///
4004 /// It covers all three paths that move the counters: `confirm_sent`,
4005 /// `report_send_failure`, and the confirmation timeout via
4006 /// `fail_all_pending`.
4007 #[test]
4008 fn test_discovery_publication_outcomes_stay_out_of_the_delivery_metrics() {
4009 let transport = NostrTransport::new(addr("alice")).unwrap();
4010 transport.set_discovery_enabled(true);
4011 transport.start().unwrap();
4012 transport.on_status_changed(TransportStatus::Available);
4013
4014 // One real message, delivered.
4015 transport.send(&create_test_message()).unwrap();
4016 let msg = transport.get_next_signed_event().unwrap().unwrap();
4017 transport.confirm_sent(&msg.message_id);
4018
4019 // A claim the relay rejects.
4020 transport.publish_discovery_record(user("alice"), b"{}".to_vec());
4021 let published = transport.get_next_signed_event().unwrap().unwrap();
4022 assert!(published.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX));
4023 transport.report_send_failure(&published.message_id);
4024
4025 // A claim the relay accepts.
4026 transport.publish_discovery_record(user("bob"), b"{}".to_vec());
4027 let accepted = transport.get_next_signed_event().unwrap().unwrap();
4028 transport.confirm_sent(&accepted.message_id);
4029
4030 let metrics = transport.metrics();
4031 assert_eq!(
4032 metrics.success_count, 1,
4033 "an accepted discovery publication was counted as a delivered message"
4034 );
4035 assert_eq!(
4036 metrics.failure_count, 0,
4037 "a rejected discovery publication was counted as a delivery failure"
4038 );
4039 assert_eq!(
4040 metrics.delivery_ratio,
4041 Some(1.0),
4042 "the only message sent was delivered; the ratio DORS reads must say so"
4043 );
4044
4045 // ...and the failed claim is still reported back for republication.
4046 assert_eq!(
4047 transport.take_failed_discovery_publications(),
4048 vec![nostr_crypto::discovery_tag_for_username(&user("alice")).unwrap()],
4049 "keeping publications out of the metrics must not lose the reports"
4050 );
4051 }
4052
4053 /// A retraction's NIP-09 deletion rides the send queue like anything else,
4054 /// so it must be kept out of the metrics too — and, unlike the tombstone,
4055 /// it must *not* be reported for republication, because nothing retries a
4056 /// deletion.
4057 #[test]
4058 fn test_discovery_deletion_is_metrics_neutral_and_never_republished() {
4059 let transport = NostrTransport::new(addr("alice")).unwrap();
4060 transport.set_discovery_enabled(true);
4061 transport.start().unwrap();
4062 transport.on_status_changed(TransportStatus::Available);
4063
4064 transport.retract_discovery_record(user("alice"), b"{\"v\":1}".to_vec());
4065
4066 let tombstone = transport.get_next_signed_event().unwrap().unwrap();
4067 assert!(tombstone.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX));
4068 transport.confirm_sent(&tombstone.message_id);
4069
4070 let deletion = transport.get_next_signed_event().unwrap().unwrap();
4071 assert!(
4072 deletion.message_id.starts_with(NOSTR_DELETION_ID_PREFIX),
4073 "the deletion must follow the tombstone, not precede it"
4074 );
4075 transport.report_send_failure(&deletion.message_id);
4076
4077 let metrics = transport.metrics();
4078 assert_eq!(metrics.success_count, 0);
4079 assert_eq!(metrics.failure_count, 0);
4080 assert!(
4081 transport.take_failed_discovery_publications().is_empty(),
4082 "a failed deletion must not queue a republication: the tombstone \
4083 already carried the retraction"
4084 );
4085 }
4086
4087 /// A retraction must go out even when discovery has just been switched off,
4088 /// which is the single most likely reason to retract. A gate here would
4089 /// strand exactly the claim the user asked to withdraw.
4090 #[test]
4091 fn test_retraction_is_not_gated_on_discovery_being_enabled() {
4092 let transport = NostrTransport::new(addr("alice")).unwrap();
4093 transport.start().unwrap();
4094 transport.on_status_changed(TransportStatus::Available);
4095
4096 // Never enabled: a publication is refused...
4097 transport.publish_discovery_record(user("alice"), b"{}".to_vec());
4098 assert!(
4099 transport.get_next_signed_event().unwrap().is_none(),
4100 "a claim must not publish while discovery is disabled"
4101 );
4102
4103 // ...but a retraction still goes out.
4104 transport.retract_discovery_record(user("alice"), b"{\"v\":1}".to_vec());
4105 let tombstone = transport
4106 .get_next_signed_event()
4107 .unwrap()
4108 .expect("a retraction must publish even with discovery disabled");
4109 assert!(tombstone.message_id.starts_with(NOSTR_DISCOVERY_ID_PREFIX));
4110 }
4111
4112 /// Discovery requires cold contact as well as its own switch: a claim
4113 /// pointing at an address with no published key packages resolves and then
4114 /// dead-ends one hop later.
4115 #[test]
4116 fn test_discovery_requires_cold_contact() {
4117 let transport = NostrTransport::new(addr("alice")).unwrap();
4118 transport.set_discovery_enabled(true);
4119 assert!(transport.discovery_enabled());
4120
4121 transport.set_cold_contact_enabled(false);
4122 assert!(
4123 !transport.discovery_enabled(),
4124 "discovery must be off when cold contact is off, since the claim \
4125 would point at an address with no key packages to fetch"
4126 );
4127 }
4128
4129 /// Republishing a claim before it drains collapses to the newer payload:
4130 /// the older one is by definition superseded, and publishing both would
4131 /// briefly stand the stale one back up.
4132 #[test]
4133 fn test_requeued_discovery_claim_collapses_to_the_newest() {
4134 let transport = NostrTransport::new(addr("alice")).unwrap();
4135 transport.set_discovery_enabled(true);
4136 transport.start().unwrap();
4137 transport.on_status_changed(TransportStatus::Available);
4138
4139 transport.publish_discovery_record(user("alice"), b"first".to_vec());
4140 transport.publish_discovery_record(user("alice"), b"second".to_vec());
4141
4142 assert!(transport.get_next_signed_event().unwrap().is_some());
4143 assert!(
4144 transport.get_next_signed_event().unwrap().is_none(),
4145 "a re-queued claim must replace the pending one, not queue twice"
4146 );
4147 }
4148
4149 /// A discovery query must not accept a key-package record, and vice versa.
4150 /// The kind is checked against what *this query asked for*, so a relay
4151 /// answering the wrong REQ cannot route a record into the other handler.
4152 #[test]
4153 fn test_query_refuses_a_record_of_the_wrong_kind() {
4154 let alice = NostrTransport::new(addr("alice")).unwrap();
4155 alice.set_discovery_enabled(true);
4156
4157 let bob = NostrTransport::new(addr("bob")).unwrap();
4158 bob.install_signing_secret(&[9u8; 32]).unwrap();
4159 bob.start().unwrap();
4160 bob.on_status_changed(TransportStatus::Available);
4161
4162 // A genuine key-package record.
4163 bob.publish_key_package("slot-a", b"bob's key package".to_vec());
4164 let published = bob.get_next_signed_event().unwrap().unwrap();
4165 let event_json = serde_json::to_string(&event_object(&published)).unwrap();
4166
4167 // Delivered under a *discovery* query.
4168 alice.resolve_username(user("bob"));
4169 let query = alice.next_query().unwrap().unwrap();
4170 assert!(
4171 alice
4172 .open_query_event(&query.query_id, &event_json)
4173 .unwrap()
4174 .is_none(),
4175 "a key-package record must not open under a discovery query"
4176 );
4177 }
4178
4179 /// The query carries the username out so the engine can open an
4180 /// accumulator at mint time — which is what lets a name nobody claims still
4181 /// emit an empty answer rather than never completing.
4182 #[test]
4183 fn test_discovery_query_reports_its_username() {
4184 let transport = NostrTransport::new(addr("alice")).unwrap();
4185 transport.set_discovery_enabled(true);
4186
4187 assert_eq!(
4188 transport.resolve_username(user("bob")),
4189 ResolveRequest::Queued
4190 );
4191 let query = transport.next_query().unwrap().unwrap();
4192 assert_eq!(query.discovery_username, Some(user("bob")));
4193
4194 // Completing it hands the username back, so the engine knows which
4195 // accumulated set to flush.
4196 assert_eq!(transport.complete_query(&query.query_id), Some(user("bob")));
4197 }
4198
4199 /// A key-package query completing must not look like a discovery query
4200 /// completing, or the engine would flush a resolution that never existed.
4201 #[test]
4202 fn test_key_package_query_completion_reports_no_username() {
4203 let transport = NostrTransport::new(addr("alice")).unwrap();
4204 transport.request_peer_key_packages(&addr("bob"));
4205 let query = transport.next_query().unwrap().unwrap();
4206 assert_eq!(query.discovery_username, None);
4207 assert_eq!(transport.complete_query(&query.query_id), None);
4208 }
4209
4210 /// Resolution is refused outright while discovery is disabled: no query is
4211 /// minted, so nothing is published to a relay about what this install is
4212 /// looking up.
4213 ///
4214 /// Reported as `Disabled` rather than as a bare refusal, because the
4215 /// engine turns that into an error: nothing will ever answer this lookup,
4216 /// and a caller awaiting the resolution event must not be left waiting.
4217 #[test]
4218 fn test_resolution_is_refused_while_discovery_is_disabled() {
4219 let transport = NostrTransport::new(addr("alice")).unwrap();
4220 assert_eq!(
4221 transport.resolve_username(user("bob")),
4222 ResolveRequest::Disabled
4223 );
4224 assert!(transport.next_query().unwrap().is_none());
4225 }
4226
4227 /// **The attack the event-signature check exists to stop.**
4228 ///
4229 /// A tombstone body is a constant, so nothing inside it is signed and its
4230 /// entire meaning is "who published this". The discovery seal key is
4231 /// public, so an attacker who knows the username can derive the
4232 /// conversation key for the *victim's* author key and seal a retraction
4233 /// attributed to them. Every layer above this one would then honour it:
4234 /// the resolver keys retractions by author and keeps them sticky for the
4235 /// life of the resolution, precisely so a stale copy cannot stand a
4236 /// retracted claim back up.
4237 ///
4238 /// One hostile relay would therefore erase an honest claimant from the
4239 /// answer even while every other relay served their genuine record.
4240 ///
4241 /// The forgery is built exactly as an attacker would build it, and both
4242 /// halves of "exactly" are load-bearing:
4243 ///
4244 /// - the **id** is re-stamped over the forged fields, because an id is a
4245 /// hash of public fields and therefore free to recompute. A forgery
4246 /// carrying a stale id is refused one step early and proves nothing about
4247 /// the check that actually stops this attack;
4248 /// - the **signature** is a well-formed one taken from a genuinely signed
4249 /// event, because an unparseable signature is refused one step early too.
4250 /// Only a signature that parses reaches the verification that fails.
4251 ///
4252 /// Both wrong-shaped forgeries are covered by the case below and by
4253 /// `nostr_crypto`'s direct tests, so what is asserted here is the whole
4254 /// pipeline: sealed under the public key anyone can derive, stood up under
4255 /// the victim's name, and refused anyway.
4256 #[test]
4257 fn test_a_forged_tombstone_for_another_author_is_refused() {
4258 let alice = NostrTransport::new(addr("alice")).unwrap();
4259 alice.set_discovery_enabled(true);
4260
4261 // The victim's install, whose author key the attacker will impersonate.
4262 let victim = NostrTransport::new(addr("bob")).unwrap();
4263 victim.install_signing_secret(&[9u8; 32]).unwrap();
4264 let victim_author = victim.public_key_hex();
4265
4266 // The attacker seals a tombstone to the victim's author key, which the
4267 // public discovery-seal key lets anyone do. The event this produces is
4268 // signed by the seal key, so it also hands the attacker a *well-formed*
4269 // signature to dress the forgery in.
4270 let username = user("bob");
4271 let seal = nostr_crypto::discovery_seal_keypair_for_username(&username).unwrap();
4272 let tombstone = br#"{"v":1,"retracted":true}"#;
4273 let (forged_content, well_formed_sig) = {
4274 let event = nostr_crypto::NostrEvent::create_discovery_publication(
4275 &seal,
4276 &nostr_crypto::discovery_tag_for_username(&username).unwrap(),
4277 &victim_author,
4278 tombstone,
4279 )
4280 .unwrap();
4281 (event.content, event.sig)
4282 };
4283
4284 // Stood up as an event *claiming* the victim's author key, with the id
4285 // recomputed over these fields so it passes the cheap check. Only the
4286 // signature is beyond reach, because that is the one thing the victim's
4287 // key is needed for.
4288 let tags = serde_json::json!([["d", "x"], ["p", "x"]]);
4289 // A distinct `created_at` per case, so the two forgeries have distinct
4290 // ids. Sharing one would let the first consume the per-query duplicate
4291 // slot and make the second pass without being examined, which is
4292 // exactly the vacuity this test exists to avoid.
4293 let forge = |sig: &str, created_at: i64| {
4294 let forged_id = hex::encode(
4295 nostr_crypto::nip01_event_id(
4296 &victim_author,
4297 created_at,
4298 nostr_crypto::NOSTR_DISCOVERY_KIND as u64,
4299 &serde_json::to_string(&tags).expect("tags"),
4300 &forged_content,
4301 )
4302 .expect("id"),
4303 );
4304 serde_json::json!({
4305 "id": forged_id,
4306 "pubkey": victim_author,
4307 "created_at": created_at,
4308 "kind": nostr_crypto::NOSTR_DISCOVERY_KIND,
4309 "tags": tags,
4310 "content": forged_content,
4311 "sig": sig,
4312 })
4313 .to_string()
4314 };
4315
4316 alice.resolve_username(username);
4317 let query = alice.next_query().unwrap().unwrap();
4318
4319 for (label, sig, created_at) in [
4320 // The one that reaches the verification. A real signature, by a
4321 // real key, over a different event: it parses, so every cheap
4322 // screen passes and only checking it against the *claimed* author
4323 // refuses it.
4324 (
4325 "a well-formed signature by another key",
4326 &well_formed_sig,
4327 1_700_000_000i64,
4328 ),
4329 // And the shape that never parses, one step earlier.
4330 ("an unparseable signature", &"0".repeat(128), 1_700_000_001),
4331 ] {
4332 assert!(
4333 alice
4334 .open_query_event(&query.query_id, &forge(sig, created_at))
4335 .unwrap()
4336 .is_none(),
4337 "a tombstone carrying {label}, attributed to an author who did \
4338 not sign the event, must never reach the resolver: one hostile \
4339 relay would otherwise erase an honest claimant from the answer"
4340 );
4341 }
4342 }
4343
4344 /// The id is recomputed, not trusted. An event claiming a genuine record's
4345 /// id would otherwise consume its per-query dedup slot and have the real
4346 /// record dropped behind it as a duplicate.
4347 #[test]
4348 fn test_a_discovery_event_with_a_forged_id_is_refused() {
4349 let alice = NostrTransport::new(addr("alice")).unwrap();
4350 alice.set_discovery_enabled(true);
4351
4352 let publisher = NostrTransport::new(addr("bob")).unwrap();
4353 publisher.install_signing_secret(&[9u8; 32]).unwrap();
4354 publisher.set_discovery_enabled(true);
4355 publisher.start().unwrap();
4356 publisher.on_status_changed(TransportStatus::Available);
4357 publisher.publish_discovery_record(user("bob"), b"{\"v\":1}".to_vec());
4358 let published = publisher.get_next_signed_event().unwrap().unwrap();
4359
4360 let mut event = event_object(&published);
4361 // Genuine event, genuine signature, one field edited. The signature is
4362 // over the *recomputed* id, so any edit anywhere breaks it.
4363 event["created_at"] = serde_json::json!(1_700_000_001);
4364
4365 alice.resolve_username(user("bob"));
4366 let query = alice.next_query().unwrap().unwrap();
4367 assert!(
4368 alice
4369 .open_query_event(&query.query_id, &event.to_string())
4370 .unwrap()
4371 .is_none(),
4372 "an event whose id does not hash its own fields must be refused"
4373 );
4374 }
4375
4376 /// The negative control for the two tests above: a genuine discovery event
4377 /// must still open. A verifier that refused everything would pass both.
4378 #[test]
4379 fn test_a_genuine_discovery_event_still_opens() {
4380 let alice = NostrTransport::new(addr("alice")).unwrap();
4381 alice.set_discovery_enabled(true);
4382
4383 let publisher = NostrTransport::new(addr("bob")).unwrap();
4384 publisher.install_signing_secret(&[9u8; 32]).unwrap();
4385 publisher.set_discovery_enabled(true);
4386 publisher.start().unwrap();
4387 publisher.on_status_changed(TransportStatus::Available);
4388 publisher.publish_discovery_record(user("bob"), b"{\"v\":1}".to_vec());
4389 let published = publisher.get_next_signed_event().unwrap().unwrap();
4390 let event_json = serde_json::to_string(&event_object(&published)).unwrap();
4391
4392 alice.resolve_username(user("bob"));
4393 let query = alice.next_query().unwrap().unwrap();
4394 match alice
4395 .open_query_event(&query.query_id, &event_json)
4396 .unwrap()
4397 .expect("a genuine discovery event must open")
4398 {
4399 ResolvedRecord::Discovery {
4400 author, plaintext, ..
4401 } => {
4402 assert_eq!(plaintext, b"{\"v\":1}");
4403 assert_eq!(
4404 author,
4405 publisher.public_key_hex(),
4406 "the author is reported lower-cased, since the resolver keys on it"
4407 );
4408 }
4409 ResolvedRecord::KeyPackage { .. } => {
4410 panic!("a discovery query must not yield a key-package record")
4411 }
4412 }
4413 }
4414
4415 /// A second request for a name already queued reports `AlreadyQueued`,
4416 /// which is a *success* from the caller's side — the queued lookup answers
4417 /// both — and must stay distinguishable from the two refusals that answer
4418 /// nobody.
4419 #[test]
4420 fn test_a_duplicate_lookup_is_reported_as_already_queued() {
4421 let transport = NostrTransport::new(addr("alice")).unwrap();
4422 transport.set_discovery_enabled(true);
4423
4424 assert_eq!(
4425 transport.resolve_username(user("bob")),
4426 ResolveRequest::Queued
4427 );
4428 assert_eq!(
4429 transport.resolve_username(user("bob")),
4430 ResolveRequest::AlreadyQueued
4431 );
4432 }
4433
4434 /// The same rule in the other direction: a successful publication must not
4435 /// inflate the ratio and mask real message failures.
4436 #[test]
4437 fn test_publication_successes_do_not_mask_message_failures() {
4438 let transport = NostrTransport::new(addr("alice")).unwrap();
4439 transport.start().unwrap();
4440 transport.on_status_changed(TransportStatus::Available);
4441
4442 transport.send(&create_test_message()).unwrap();
4443 let msg = transport.get_next_signed_event().unwrap().unwrap();
4444 transport.report_send_failure(&msg.message_id);
4445
4446 for i in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
4447 transport.publish_key_package(&format!("slot-{}", i), b"kp".to_vec());
4448 }
4449 for _ in 0..crate::constants::NOSTR_KEY_PACKAGE_SLOTS {
4450 let published = transport.get_next_signed_event().unwrap().unwrap();
4451 transport.confirm_sent(&published.message_id);
4452 }
4453
4454 let metrics = transport.metrics();
4455 assert_eq!(metrics.success_count, 0);
4456 assert_eq!(metrics.failure_count, 1);
4457 assert_eq!(
4458 metrics.delivery_ratio,
4459 Some(0.0),
4460 "publications that reached a relay masked a message that did not"
4461 );
4462 }
4463
4464 /// A resolution query is broadcast to every connected relay, so the same
4465 /// record comes back once per relay. Opening it more than once re-runs the
4466 /// key-package handler — two durable secure-storage writes a time — to
4467 /// import a package we already hold.
4468 #[test]
4469 fn test_duplicate_records_from_several_relays_open_once() {
4470 let alice = NostrTransport::new(addr("alice")).unwrap();
4471 let bob = NostrTransport::new(addr("bob")).unwrap();
4472 bob.install_signing_secret(&[9u8; 32]).unwrap();
4473 bob.start().unwrap();
4474 bob.on_status_changed(TransportStatus::Available);
4475
4476 bob.publish_key_package("slot-a", b"bob's key package".to_vec());
4477 let published = bob.get_next_signed_event().unwrap().unwrap();
4478 let event_json = serde_json::to_string(&event_object(&published)).unwrap();
4479
4480 alice.request_peer_key_packages(&addr("bob"));
4481 let query = alice.next_query().unwrap().unwrap();
4482
4483 assert!(
4484 alice
4485 .open_query_event(&query.query_id, &event_json)
4486 .unwrap()
4487 .is_some(),
4488 "the first relay's copy must open"
4489 );
4490 for _ in 0..4 {
4491 assert!(
4492 alice
4493 .open_query_event(&query.query_id, &event_json)
4494 .unwrap()
4495 .is_none(),
4496 "the same record must be taken once per query, not once per relay"
4497 );
4498 }
4499 }
4500
4501 /// A relay is free to ignore the REQ's `limit` and stream whatever it
4502 /// likes, and every record that opens costs durable writes behind this
4503 /// call — so the ceiling has to be ours, not the relay's.
4504 #[test]
4505 fn test_a_query_stops_accepting_events_at_its_ceiling() {
4506 let alice = NostrTransport::new(addr("alice")).unwrap();
4507 let bob = NostrTransport::new(addr("bob")).unwrap();
4508 bob.install_signing_secret(&[9u8; 32]).unwrap();
4509 bob.start().unwrap();
4510 bob.on_status_changed(TransportStatus::Available);
4511 bob.publish_key_package("slot-a", b"bob's key package".to_vec());
4512 let published = bob.get_next_signed_event().unwrap().unwrap();
4513 let genuine = serde_json::to_string(&event_object(&published)).unwrap();
4514
4515 alice.request_peer_key_packages(&addr("bob"));
4516 let query = alice.next_query().unwrap().unwrap();
4517
4518 // Distinct ids, so it is the ceiling that stops this and not the dedup.
4519 for i in 0..MAX_QUERY_EVENTS {
4520 let junk = format!(
4521 r#"{{"id":"{:064x}","kind":30443,"pubkey":"aa","content":"AQID"}}"#,
4522 i
4523 );
4524 assert!(alice
4525 .open_query_event(&query.query_id, &junk)
4526 .unwrap()
4527 .is_none());
4528 }
4529
4530 assert!(
4531 alice
4532 .open_query_event(&query.query_id, &genuine)
4533 .unwrap()
4534 .is_none(),
4535 "the query kept accepting events past its ceiling"
4536 );
4537
4538 // The record itself is fine, and the ceiling is per query: a fresh one
4539 // opens it.
4540 let alice2 = NostrTransport::new(addr("alice")).unwrap();
4541 alice2.request_peer_key_packages(&addr("bob"));
4542 let fresh = alice2.next_query().unwrap().unwrap();
4543 assert!(alice2
4544 .open_query_event(&fresh.query_id, &genuine)
4545 .unwrap()
4546 .is_some());
4547 }
4548
4549 /// The same malformed key, by the route that was already shipping.
4550 ///
4551 /// `unseal_event_payload` takes the event's `pubkey` field verbatim off a
4552 /// public relay and hands it to the NIP-44 derive, which used to reach a
4553 /// fixed-size decoder that aborts the process on a wrong-length key. One
4554 /// hostile event was enough; it must be an unopenable frame instead.
4555 #[test]
4556 fn test_malformed_event_pubkey_does_not_abort_the_receive_path() {
4557 let transport = NostrTransport::new(addr("alice")).unwrap();
4558 let sealed = vec![0x02u8; 160];
4559
4560 for pubkey in ["", "aa", "abcd", &"ab".repeat(64)] {
4561 let out = transport.unseal_event_payload(pubkey, &sealed);
4562 assert_eq!(
4563 &*out,
4564 &sealed[..],
4565 "a malformed pubkey must leave the frame untouched, not abort"
4566 );
4567 }
4568 }
4569
4570 /// A request the queue refuses for capacity was never looked up, so it must
4571 /// not burn the retry interval — otherwise the overflow policy's promise
4572 /// that a dropped peer "is retried on the next send to them" is false, and
4573 /// the peer instead waits out `RESOLUTION_RETRY_INTERVAL`.
4574 #[test]
4575 fn test_a_resolution_refused_at_capacity_does_not_burn_the_rate_limit() {
4576 let transport = NostrTransport::new(addr("alice")).unwrap();
4577
4578 for i in 0..MAX_PENDING_RESOLUTIONS {
4579 assert!(transport.request_peer_key_packages(&addr(&format!("peer-{}", i))));
4580 }
4581
4582 assert!(
4583 !transport.request_peer_key_packages(&addr("bob")),
4584 "the queue is full, so this request is refused"
4585 );
4586
4587 // Draining one makes room; bob must be admitted straight away.
4588 transport.next_query().unwrap().unwrap();
4589 assert!(
4590 transport.request_peer_key_packages(&addr("bob")),
4591 "a request dropped at capacity burned the retry interval"
4592 );
4593 }
4594}