diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 7de639d..c2316bb 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -57,6 +57,13 @@ const RATE_CONTACT_PER_HOUR: usize = 30; const RATE_UNKNOWN_PER_HOUR: usize = 10; /// Auto-resend window for pending outgoing messages (days). const RESEND_WINDOW_SECS: i64 = 7 * 86_400; +/// How often a cached @username is re-validated against the identity server, so +/// a released or reassigned name stops being shown. Doubles as the freshness +/// gate in `resolve_contact_identity`. +const NAME_REVERIFY_INTERVAL_SECS: i64 = 78; +/// Cap on contacts re-verified per sweep, so a large contact list rolls through +/// instead of bursting dozens of simultaneous mixnet lookups at once. +const NAME_REVERIFY_MAX_PER_TICK: usize = 8; /// Per-wallet nostr service. pub struct NostrService { @@ -621,59 +628,87 @@ impl NostrService { } } - /// Best-effort: resolve a contact's published `@username`. Incoming messages - /// only carry the sender's key, so a fresh contact shows as a bare npub. This - /// fetches their kind-0 profile and, if it advertises a NIP-05 handle that - /// verifies back to their key, records it so the UI can show `@username`. - /// Spawns a worker; fail-open (any miss just leaves the npub). Skips contacts - /// that already carry a verified handle. + /// Best-effort: resolve and KEEP FRESH a contact's published `@username`. + /// Incoming messages only carry the sender's key, so a fresh contact shows as + /// a bare npub; this fetches their kind-0, and if it advertises a NIP-05 that + /// maps back to their key, records it so the UI shows `@username`. It also + /// re-validates an already-known name (older than the freshness window): if + /// the server says the name was released or reassigned, it CLEARS it so the + /// stale name stops showing; a transient network miss leaves it untouched. + /// Spawns a worker; fail-open. A user-set petname is never touched. pub fn resolve_contact_identity(self: &Arc, sender_hex: &str) { - if let Some(c) = self.store.contact(sender_hex) { - if c.nip05.is_some() && c.nip05_verified_at.is_some() { - return; + let existing = self.store.contact(sender_hex); + // Freshness gate: skip only if a name was verified recently. Older (or + // never-verified) contacts are (re-)checked so releases get caught. + if let Some(c) = &existing { + if let (Some(_), Some(at)) = (&c.nip05, c.nip05_verified_at) { + if unix_time() - at < NAME_REVERIFY_INTERVAL_SECS { + return; + } } } // Any DM relays we've already learned for them are the best hint for where // their profile lives (their messages came from there). - let hints = self - .store - .contact(sender_hex) - .map(|c| c.relays) + let hints = existing + .as_ref() + .map(|c| c.relays.clone()) .unwrap_or_default(); + let cached_nip05 = existing.and_then(|c| c.nip05); let svc = self.clone(); let hex = sender_hex.to_string(); thread::spawn(move || { - let Some(profile) = svc.fetch_profile_blocking(&hex, &hints) else { + let Ok(pk) = PublicKey::from_hex(&hex) else { return; }; - let Some(nip05) = profile.nip05 else { - return; + // Check the handle they currently advertise; if the kind-0 can't be + // fetched, fall back to the cached handle so a release is still caught. + let advertised = svc + .fetch_profile_blocking(&hex, &hints) + .and_then(|p| p.nip05); + let Some(nip05) = advertised.or(cached_nip05) else { + return; // anonymous and nothing cached — nothing to check }; let Some((name, domain)) = nip05.split_once('@') else { return; }; - let Ok(pk) = PublicKey::from_hex(&hex) else { - return; - }; - // Trust the handle only if it maps back to this key. - let verified = tokio::runtime::Builder::new_current_thread() + let Ok(rt) = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .ok() - .map(|rt| rt.block_on(crate::nostr::nip05::verify(&pk, name, domain))) - .unwrap_or(false); - if !verified { + else { return; - } + }; + let check = rt.block_on(crate::nostr::nip05::check(&pk, name, domain)); if let Some(mut c) = svc.store.contact(&hex) { - c.nip05 = Some(nip05.clone()); - c.nip05_verified_at = Some(unix_time()); - svc.store.save_contact(&c); + if apply_nip05_check(&mut c, &nip05, check) { + svc.store.save_contact(&c); + } } }); } } +/// Apply a name re-check outcome to a contact in place; returns true if it +/// changed and should be saved. `Verified` records/refreshes the handle; +/// `Mismatch` (released or reassigned) clears it so the npub takes over; +/// `Unreachable` leaves it alone. A user-set petname is never touched. +fn apply_nip05_check(c: &mut Contact, nip05: &str, check: crate::nostr::nip05::Nip05Check) -> bool { + use crate::nostr::nip05::Nip05Check; + match check { + Nip05Check::Verified => { + c.nip05 = Some(nip05.to_string()); + c.nip05_verified_at = Some(unix_time()); + true + } + Nip05Check::Mismatch => { + let had = c.nip05.is_some() || c.nip05_verified_at.is_some(); + c.nip05 = None; + c.nip05_verified_at = None; + had + } + Nip05Check::Unreachable => false, + } +} + /// Main service loop: connect, publish identity, catch up, listen. async fn run_service(svc: Arc, wallet: Wallet) { // Publish the service runtime handle so worker-thread one-shots (profile @@ -804,6 +839,7 @@ async fn run_service(svc: Arc, wallet: Wallet) { status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let mut last_heartbeat = unix_time(); let mut last_prune = unix_time(); + let mut last_name_sweep = unix_time(); loop { if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() { break; @@ -833,6 +869,28 @@ async fn run_service(svc: Arc, wallet: Wallet) { last_prune = now; } } + // Re-validate cached @usernames so a released/reassigned name + // stops showing. Only the stalest few per sweep (capped) to bound + // mixnet lookups; each worker re-checks against the identity server. + if now - last_name_sweep >= NAME_REVERIFY_INTERVAL_SECS { + last_name_sweep = now; + let mut due: Vec<_> = svc + .store + .all_contacts() + .into_iter() + .filter(|c| { + c.nip05.is_some() + && c.nip05_verified_at + .map(|at| now - at >= NAME_REVERIFY_INTERVAL_SECS) + .unwrap_or(true) + }) + .collect(); + // Stalest first (oldest verification), so a big list rolls through. + due.sort_by_key(|c| c.nip05_verified_at.unwrap_or(0)); + for c in due.into_iter().take(NAME_REVERIFY_MAX_PER_TICK) { + svc.resolve_contact_identity(&c.npub); + } + } } } } @@ -1351,6 +1409,69 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, mod tests { use super::*; + fn sample_contact() -> Contact { + Contact { + ver: 1, + npub: "abc".to_string(), + petname: Some("Mom".to_string()), + nip05: Some("ada@goblin.st".to_string()), + nip05_verified_at: Some(1000), + relays: vec![], + hue: 0, + unknown: false, + added_at: 1, + last_paid_at: None, + blocked: false, + } + } + + #[test] + fn name_recheck_clears_on_mismatch_keeps_petname() { + use crate::nostr::nip05::Nip05Check; + // Released/reassigned → clear the handle, but never the user's petname. + let mut c = sample_contact(); + assert!(apply_nip05_check( + &mut c, + "ada@goblin.st", + Nip05Check::Mismatch + )); + assert_eq!(c.nip05, None); + assert_eq!(c.nip05_verified_at, None); + assert_eq!(c.petname.as_deref(), Some("Mom")); + + // Unreachable → no change at all (don't drop a good name on a blip). + let mut c = sample_contact(); + assert!(!apply_nip05_check( + &mut c, + "ada@goblin.st", + Nip05Check::Unreachable + )); + assert_eq!(c.nip05.as_deref(), Some("ada@goblin.st")); + assert_eq!(c.nip05_verified_at, Some(1000)); + + // Verified → record the handle and refresh the timestamp. + let mut c = sample_contact(); + c.nip05 = None; + c.nip05_verified_at = None; + assert!(apply_nip05_check( + &mut c, + "bob@goblin.st", + Nip05Check::Verified + )); + assert_eq!(c.nip05.as_deref(), Some("bob@goblin.st")); + assert!(c.nip05_verified_at.is_some()); + + // Mismatch on an already-nameless contact → nothing to do. + let mut c = sample_contact(); + c.nip05 = None; + c.nip05_verified_at = None; + assert!(!apply_nip05_check( + &mut c, + "ada@goblin.st", + Nip05Check::Mismatch + )); + } + #[test] fn terminal_states_do_not_expire() { assert!(expiry_terminal(NostrSendStatus::Finalized)); diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index 7a71294..dfc1519 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -88,6 +88,52 @@ pub async fn verify(pubkey: &PublicKey, name: &str, domain: &str) -> bool { } } +/// Outcome of re-checking whether a name still belongs to a key — distinguishes +/// a definitive "no longer ours" from a transient network failure, so a cached +/// name is only cleared when the server actually says it's gone/reassigned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Nip05Check { + /// Server reachable; the name maps to this key. + Verified, + /// Server reachable and answered, but the name is absent or maps to a + /// DIFFERENT key (released, or reassigned to someone else). + Mismatch, + /// Couldn't reach/parse the server — unknown; keep what we have. + Unreachable, +} + +/// Freshness-aware NIP-05 check (see [`Nip05Check`]). Only returns `Mismatch` +/// when the server gives a well-formed answer that doesn't include this key — +/// any network error or non-well-known response is `Unreachable`. +pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check { + let url = format!( + "https://{}/.well-known/nostr.json?name={}", + domain, + urlencode(name) + ); + let Some(body) = nym::http_request("GET", url, None, vec![]).await else { + return Nip05Check::Unreachable; + }; + check_body(&body, pubkey, name) +} + +/// Decide a [`Nip05Check`] from a fetched well-known body (split out for tests). +fn check_body(body: &str, pubkey: &PublicKey, name: &str) -> Nip05Check { + // A reachable server that returns non-JSON, or a doc without a `names` map, + // is treated as Unreachable — never clear a good name on a server hiccup. + let Ok(doc) = serde_json::from_str::(body) else { + return Nip05Check::Unreachable; + }; + let Some(names) = doc.get("names").and_then(|n| n.as_object()) else { + return Nip05Check::Unreachable; + }; + match names.get(name).and_then(|v| v.as_str()) { + Some(hex) if PublicKey::from_hex(hex).ok().as_ref() == Some(pubkey) => Nip05Check::Verified, + // Name absent, or present but a different key → definitively not ours. + _ => Nip05Check::Mismatch, + } +} + /// Parse a .well-known/nostr.json document for a specific name. pub fn parse_well_known(body: &str, name: &str) -> Option { let doc: Value = serde_json::from_str(body).ok()?; @@ -403,4 +449,36 @@ mod tests { assert!(parse_well_known(body, "bob").is_none()); assert!(parse_well_known("not json", "ada").is_none()); } + + #[test] + fn check_body_classifies() { + let ada_hex = "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"; + let ada = PublicKey::from_hex(ada_hex).unwrap(); + let other = + PublicKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let body = format!(r#"{{"names":{{"ada":"{ada_hex}"}},"relays":{{}}}}"#); + + // Name maps to this key → Verified. + assert_eq!(check_body(&body, &ada, "ada"), Nip05Check::Verified); + // Name present but a DIFFERENT key (reassigned) → Mismatch. + assert_eq!(check_body(&body, &other, "ada"), Nip05Check::Mismatch); + // Name absent from a valid doc (released) → Mismatch. + assert_eq!(check_body(&body, &ada, "bob"), Nip05Check::Mismatch); + // Empty names map (the exact "released" shape) → Mismatch. + assert_eq!( + check_body(r#"{"names":{},"relays":{}}"#, &ada, "testuser"), + Nip05Check::Mismatch + ); + // Non-JSON / server error → Unreachable (never clears a good name). + assert_eq!( + check_body("503 Service Unavailable", &ada, "ada"), + Nip05Check::Unreachable + ); + // Valid JSON but no `names` map (unexpected response) → Unreachable. + assert_eq!( + check_body(r#"{"error":"oops"}"#, &ada, "ada"), + Nip05Check::Unreachable + ); + } }