1
0
forked from GRIN/grim
Files
goblin/src/nostr/nip05.rs
T
2ro c701f0f480 Floonet scoped Nym exit + NIP-44 v3 + wallet polish
Money path:
- Scoped, unbonded Nym exit for the money-path relay: the wallet dials a
  relay operator's co-located exit over a MixnetStream (src/nym/streamexit.rs)
  which pipes to its one relay; hostname-validated TLS end to end, no public
  DNS. Anchor + fallback (never pin-only): any exit failure degrades to the
  smolmix tunnel. relay.goblin.st's exit address is pinned in the relay pool
  (src/nostr/pool.rs) and the maintainer gist so it bootstraps offline.
- STREAM_SETTLE bridges the open-before-accept gap so the first TLS byte is
  not dropped into a stalled handshake.
- Verified end to end: two wallets complete a real gift-wrapped Grin payment
  through relay.goblin.st over the exit, finalized + posted on mainnet
  (src/wallet/e2e.rs, ignored live test).

Encryption:
- Adopt NIP-44 v3 for the NIP-17 gift-wrap path (G4): src/nostr/wrapv3.rs,
  nip44 path dep; v3<->v3 and v3->v2 interop.

Also: mix-DNS (src/nym/dns.rs), full localization pass, GUI polish,
avatar-ring example, Android icon/script updates, GRIM deviation notes,
xrelay + connect-timing tests.
2026-07-02 04:17:59 -04:00

440 lines
15 KiB
Rust

// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! NIP-05 username resolution/verification and goblin.st registration,
//! all HTTP routed through the Nym mixnet (the in-process smolmix tunnel). Nothing
//! here touches clearnet.
use base64::Engine;
use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::nostr::relays::HOME_NIP05_DOMAIN;
use crate::nym;
use parking_lot::RwLock;
/// The active name-authority "home" domain, mirrored here from the wallet config
/// once per frame so resolution + display (some on worker threads) can read it
/// without threading the config through every call site. `None` = the default
/// (goblin.st). Federation: set this to another authority and bare names resolve
/// there and own-domain names display without a domain suffix.
static HOME_DOMAIN: RwLock<Option<String>> = RwLock::new(None);
/// Mirror the configured name authority's host (e.g. `goblin.st`). Empty resets
/// to the default.
pub fn set_home_domain(domain: &str) {
*HOME_DOMAIN.write() = if domain.trim().is_empty() {
None
} else {
Some(domain.trim().to_lowercase())
};
}
/// The current name-authority home domain (configured or the goblin.st default).
pub fn home_domain() -> String {
HOME_DOMAIN
.read()
.clone()
.unwrap_or_else(|| HOME_NIP05_DOMAIN.to_string())
}
/// Result of resolving a NIP-05 identifier.
#[derive(Debug, Clone)]
pub struct Nip05Resolution {
pub pubkey: PublicKey,
pub relays: Vec<String>,
}
/// Parse `user@domain` into (name, domain). A bare `@user` or `user`
/// resolves against the home domain (goblin.st).
pub fn split_identifier(input: &str) -> Option<(String, String)> {
let trimmed = input.trim().trim_start_matches('@');
if trimmed.is_empty() {
return None;
}
match trimmed.split_once('@') {
Some((name, domain)) if !name.is_empty() => {
let domain = domain.to_lowercase();
if !is_valid_hostname(&domain) {
return None;
}
Some((name.to_lowercase(), domain))
}
Some(_) => None,
None => Some((trimmed.to_lowercase(), home_domain())),
}
}
/// A bare DNS hostname: dotted ASCII labels only — no path, query, port,
/// userinfo or whitespace. Stops a `user@domain` from smuggling an
/// attacker-chosen path/host into the `/.well-known/nostr.json` URL.
fn is_valid_hostname(d: &str) -> bool {
if d.len() > 253 || !d.contains('.') || d.contains("..") {
return false;
}
d.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
/// Resolve a NIP-05 identifier (user@domain) to a pubkey + relay hints.
pub async fn resolve(name: &str, domain: &str) -> Option<Nip05Resolution> {
let url = format!(
"https://{}/.well-known/nostr.json?name={}",
domain,
urlencode(name)
);
let body = nym::http_request("GET", url, None, vec![]).await?;
parse_well_known(&body, name)
}
/// Reverse lookup against an authority: the active `@name` a pubkey holds, if
/// any. Authoritative and single-request — unlike fetching the peer's kind-0 off
/// a relay and verifying the NIP-05 it advertises, this needs no profile fetch,
/// so a contact's name resolves even when their profile can't be retrieved.
/// `Some(name)` = server-confirmed; `None` = the key has no name on this
/// authority OR the server was unreachable (the two are indistinguishable here,
/// so callers must NOT treat `None` as "released" — fall back to the kind-0 +
/// verify path, which can tell a definitive miss from a network blip).
pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option<String> {
let url = format!(
"https://{}/api/v1/by-pubkey/{}",
domain,
urlencode(pubkey_hex)
);
let body = nym::http_request("GET", url, None, vec![]).await?;
let doc: Value = serde_json::from_str(&body).ok()?;
doc.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
/// Verify that a pubkey matches its claimed NIP-05 identifier.
pub async fn verify(pubkey: &PublicKey, name: &str, domain: &str) -> bool {
match resolve(name, domain).await {
Some(res) => res.pubkey == *pubkey,
None => false,
}
}
/// 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()?;
let pk_hex = doc.get("names")?.get(name)?.as_str()?;
let pubkey = PublicKey::from_hex(pk_hex).ok()?;
let relays = doc
.get("relays")
.and_then(|r| r.get(pk_hex))
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Some(Nip05Resolution { pubkey, relays })
}
/// Availability result from the registration server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Availability {
Available,
Taken,
Reserved,
Invalid,
Quarantined,
Unknown,
}
/// Check name availability against the identity server.
pub async fn check_availability(server: &str, name: &str) -> Availability {
let url = format!(
"{}/api/v1/name/{}",
server.trim_end_matches('/'),
urlencode(name)
);
let body = match nym::http_request("GET", url, None, vec![]).await {
Some(b) => b,
None => return Availability::Unknown,
};
let Ok(doc) = serde_json::from_str::<Value>(&body) else {
return Availability::Unknown;
};
if doc.get("available").and_then(|v| v.as_bool()) == Some(true) {
return Availability::Available;
}
match doc.get("reason").and_then(|v| v.as_str()) {
Some("taken") => Availability::Taken,
Some("reserved") => Availability::Reserved,
Some("invalid") => Availability::Invalid,
Some("quarantined") => Availability::Quarantined,
_ => Availability::Unknown,
}
}
/// Build a NIP-98 Authorization header value for a request.
fn nip98_auth(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> Option<String> {
let mut tags = vec![
Tag::custom(TagKind::custom("u"), [url.to_string()]),
Tag::custom(TagKind::custom("method"), [method.to_string()]),
];
if let Some(body) = body {
let hash = hex::encode(Sha256::digest(body));
tags.push(Tag::custom(TagKind::custom("payload"), [hash]));
}
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.ok()?;
let encoded = base64::engine::general_purpose::STANDARD.encode(event.as_json());
Some(format!("Nostr {}", encoded))
}
/// Registration outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegisterResult {
/// Registered (or already owned): full nip05 identifier.
Ok(String),
/// Name conflict (taken/quarantined/pubkey already has a name).
Conflict(String),
/// Request rejected (invalid/reserved/unauthorized).
Rejected(String),
/// Network failure.
Network,
}
/// Register `name` for our keys at the identity server (NIP-98 authed).
pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/register", server);
let body = serde_json::json!({
"name": name.to_lowercase(),
"pubkey": keys.public_key().to_hex(),
})
.to_string();
let Some(auth) = nip98_auth(keys, &url, "POST", Some(body.as_bytes())) else {
return RegisterResult::Rejected("auth event build failed".into());
};
let headers = vec![
("Authorization".to_string(), auth),
("Content-Type".to_string(), "application/json".to_string()),
];
let Some(resp) = nym::http_request("POST", url, Some(body), headers).await else {
return RegisterResult::Network;
};
let Ok(doc) = serde_json::from_str::<Value>(&resp) else {
return RegisterResult::Rejected(format!("bad response: {}", resp));
};
if let Some(nip05) = doc.get("nip05").and_then(|v| v.as_str()) {
return RegisterResult::Ok(nip05.to_string());
}
let err = doc
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error")
.to_string();
if err.contains("taken") || err.contains("quarantined") || err.contains("already has") {
RegisterResult::Conflict(err)
} else {
RegisterResult::Rejected(err)
}
}
/// Release a registered name (NIP-98 authed by the owner).
pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), String> {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/register/{}", server, urlencode(name));
let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else {
return Err("couldn't sign the request".to_string());
};
let headers = vec![("Authorization".to_string(), auth)];
match nym::http_request("DELETE", url, None, headers).await {
Some(resp) if resp.contains("\"released\":true") => Ok(()),
Some(resp) => Err(serde_json::from_str::<serde_json::Value>(&resp)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
.unwrap_or_else(|| "server refused the release".to_string())),
None => Err("network unreachable".to_string()),
}
}
/// Public profile probe: `None` = network failure, `Some(None)` = name has
/// no avatar (or no such name), `Some(Some(hash))` = avatar content hash.
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/profile/{}", server, urlencode(name));
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
if code == 404 {
return Some(None);
}
if code != 200 {
return None;
}
let v: serde_json::Value = serde_json::from_slice(&raw).ok()?;
Some(v.get("avatar").and_then(|h| h.as_str()).map(String::from))
}
/// Download a processed avatar by content hash. Verifies size and PNG
/// magic before returning — a misbehaving server can't feed the UI junk.
pub async fn fetch_avatar(server: &str, hash: &str) -> Option<Vec<u8>> {
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let server = server.trim_end_matches('/');
let url = format!("{}/api/v1/avatar/{}.png", server, hash);
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) {
return None;
}
Some(raw)
}
/// Minimal percent-encoding for name path/query segments.
fn urlencode(s: &str) -> String {
s.chars()
.flat_map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
vec![c]
} else {
format!("%{:02X}", c as u32).chars().collect()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_identifiers() {
assert_eq!(
split_identifier("@ada"),
Some(("ada".to_string(), "goblin.st".to_string()))
);
assert_eq!(
split_identifier("ada"),
Some(("ada".to_string(), "goblin.st".to_string()))
);
assert_eq!(
split_identifier("Ada@Example.COM"),
Some(("ada".to_string(), "example.com".to_string()))
);
assert_eq!(split_identifier("ada@"), None);
assert_eq!(split_identifier(""), None);
// Reject anything that isn't a bare hostname (SSRF / path smuggling).
assert_eq!(split_identifier("a@evil.tld/.well-known/x?u="), None);
assert_eq!(split_identifier("a@1.2.3.4:8080"), None);
assert_eq!(split_identifier("a@nodot"), None);
}
#[test]
fn parses_well_known() {
let body = r#"{
"names": {"ada": "91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05"},
"relays": {"91cf9dbbea5e6511fd2bbb190b112055ee4131c5d2bbb9faedf3ee8cbeac0d05": ["wss://relay.goblin.st"]}
}"#;
let res = parse_well_known(body, "ada").unwrap();
assert_eq!(res.relays, vec!["wss://relay.goblin.st".to_string()]);
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
);
}
}