c701f0f480
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.
440 lines
15 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|