nostr: keep contact @usernames fresh; clear released/reassigned names
Cached names were verified once and never re-checked, so a contact who released or changed their username kept showing the stale name forever. Re-validate names on a 78s sweep (capped per tick to bound mixnet lookups): - nip05::check — tri-state Verified/Mismatch/Unreachable, so we only clear on a definitive server answer (released, or reassigned to a different key), never on a network blip. - resolve_contact_identity now re-checks names older than the freshness window and clears nip05 + nip05_verified_at on Mismatch (a user petname is kept); display falls back to the npub automatically. - A periodic sweep in run_service re-verifies the stalest due contacts. Tests for the tri-state parsing and the clear-keeps-petname logic.
This commit is contained in:
+150
-29
@@ -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<Self>, 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<NostrService>, wallet: Wallet) {
|
||||
// Publish the service runtime handle so worker-thread one-shots (profile
|
||||
@@ -804,6 +839,7 @@ async fn run_service(svc: Arc<NostrService>, 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<NostrService>, 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<NostrService>, 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));
|
||||
|
||||
@@ -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::<Value>(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<Nip05Resolution> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user