From f79bf9d926718b185659f0adbb03febb69e0a088 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:51:37 -0400 Subject: [PATCH] Goblin Build 123 - fast relay connect, nostr-app login, scan amounts, name outline --- .github/actions/fetch-nip44/action.yml | 21 ++ .github/workflows/release.yml | 3 + build.rs | 4 +- locales/de.yml | 6 + locales/en.yml | 6 + locales/fr.yml | 6 + locales/ru.yml | 6 + locales/tr.yml | 6 + locales/zh-CN.yml | 6 + src/gui/views/goblin/avatars.rs | 9 - src/gui/views/goblin/identicon.rs | 10 - src/gui/views/goblin/mod.rs | 91 +++++++ src/gui/views/goblin/send.rs | 25 +- src/gui/views/goblin/widgets.rs | 105 +++----- src/nostr/avatar.rs | 95 +------ src/nostr/config.rs | 17 +- src/nostr/mod.rs | 2 + src/nostr/payuri.rs | 351 +++++++++++++++++++++++++ src/nostr/pool.rs | 15 +- src/nostr/store.rs | 11 - src/nym/dns.rs | 102 +------ src/nym/mod.rs | 45 ++-- src/nym/nymproc.rs | 39 +-- src/nym/streamexit.rs | 44 +++- src/nym/transport.rs | 17 +- src/wallet/wallet.rs | 21 ++ wallet | 2 +- 27 files changed, 677 insertions(+), 388 deletions(-) create mode 100644 .github/actions/fetch-nip44/action.yml create mode 100644 src/nostr/payuri.rs diff --git a/.github/actions/fetch-nip44/action.yml b/.github/actions/fetch-nip44/action.yml new file mode 100644 index 00000000..5745da65 --- /dev/null +++ b/.github/actions/fetch-nip44/action.yml @@ -0,0 +1,21 @@ +name: Fetch nip44 crate +description: > + Clone the nip44 crate (v2 + v3 encryption) from our mirror + (github.com/2ro/nip44, branch `v3`) into ../nip44, so the + `nip44 = { path = "../nip44" }` dependency resolves. + +runs: + using: composite + steps: + - name: Clone nip44 + shell: bash + run: | + set -euo pipefail + DEST="$(dirname "$GITHUB_WORKSPACE")/nip44" + if [ -e "$DEST/Cargo.toml" ]; then + echo "nip44 already present at $DEST" + exit 0 + fi + rm -rf "$DEST" + git clone --branch v3 --depth 1 https://github.com/2ro/nip44.git "$DEST" + echo "nip44 cloned from 2ro/nip44@v3 -> $DEST" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28ad5853..fb5f11d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,7 @@ jobs: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive - uses: ./.github/actions/fetch-nym + - uses: ./.github/actions/fetch-nip44 - name: Build shell: bash run: GOBLIN_BUILD="${TAG#build}" cargo build --release @@ -65,6 +66,7 @@ jobs: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive - uses: ./.github/actions/fetch-nym + - uses: ./.github/actions/fetch-nip44 - name: Build shell: bash run: GOBLIN_BUILD="${TAG#build}" cargo build --release @@ -112,6 +114,7 @@ jobs: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive - uses: ./.github/actions/fetch-nym + - uses: ./.github/actions/fetch-nip44 - name: Build both architectures run: | export GOBLIN_BUILD="${TAG#build}" diff --git a/build.rs b/build.rs index 3ec3e0fb..029cff6e 100644 --- a/build.rs +++ b/build.rs @@ -46,12 +46,12 @@ fn main() { if cfg!(target_os = "windows") { Command::new("cmd") - .args(&["/C", &git_hooks]) + .args(["/C", &git_hooks]) .output() .expect("failed to execute git config for hooks"); } else { Command::new("sh") - .args(&["-c", &git_hooks]) + .args(["-c", &git_hooks]) .output() .expect("failed to execute git config for hooks"); } diff --git a/locales/de.yml b/locales/de.yml index a3934e47..2e55b43c 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -609,6 +609,12 @@ goblin: repair_confirm: "Ja, jetzt reparieren" repair_confirm_note: "Die Reparatur scannt die Chain neu und kann einige Minuten dauern." restore_confirm_note: "Dies löscht lokale Daten und baut sie aus deinem Seed neu auf — das kann einige Minuten dauern." + nostr_key: "Nostr-Schlüssel" + nostr_key_desc: "Dein nsec, der geheime Schlüssel deiner Nostr-Identität. Kopiere ihn oder zeige den QR-Code, um dich bei Nostr-Apps wie magick.market anzumelden. Wer ihn hat, kontrolliert deine Identität, also halte ihn geheim." + reveal_nsec: "Schlüssel anzeigen" + copy_nsec: "nsec kopieren" + show_qr: "QR anzeigen" + hide_qr: "QR ausblenden" privacy: title: "Netzwerk-Privatsphäre" intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann." diff --git a/locales/en.yml b/locales/en.yml index e43a9ec2..49336e47 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -609,6 +609,12 @@ goblin: repair_confirm: "Yes, repair now" repair_confirm_note: "Repair re-scans the chain and can take a few minutes." restore_confirm_note: "This erases local data and rebuilds it from your seed — it can take several minutes." + nostr_key: "Nostr key" + nostr_key_desc: "Your nsec, the secret key to your nostr identity. Copy it or show its QR to log in to nostr apps like magick.market. Anyone who has it controls your identity, so keep it private." + reveal_nsec: "Show key" + copy_nsec: "Copy nsec" + show_qr: "Show QR" + hide_qr: "Hide QR" privacy: title: "Network privacy" intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you." diff --git a/locales/fr.yml b/locales/fr.yml index 0dd7d61f..78531738 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -609,6 +609,12 @@ goblin: repair_confirm: "Oui, réparer maintenant" repair_confirm_note: "La réparation réanalyse la chaîne et peut prendre quelques minutes." restore_confirm_note: "Cela efface les données locales et les reconstruit depuis votre seed — cela peut prendre plusieurs minutes." + nostr_key: "Clé Nostr" + nostr_key_desc: "Votre nsec, la clé secrète de votre identité Nostr. Copiez-la ou affichez son QR pour vous connecter à des applis Nostr comme magick.market. Quiconque la possède contrôle votre identité, gardez-la privée." + reveal_nsec: "Afficher la clé" + copy_nsec: "Copier le nsec" + show_qr: "Afficher le QR" + hide_qr: "Masquer le QR" privacy: title: "Confidentialité réseau" intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous." diff --git a/locales/ru.yml b/locales/ru.yml index 0933ebd3..8ea3dbb3 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -609,6 +609,12 @@ goblin: repair_confirm: "Да, восстановить сейчас" repair_confirm_note: "Восстановление повторно сканирует цепочку и может занять несколько минут." restore_confirm_note: "Это стирает локальные данные и восстанавливает их из seed-фразы — может занять несколько минут." + nostr_key: "Ключ Nostr" + nostr_key_desc: "Ваш nsec, секретный ключ вашей личности Nostr. Скопируйте его или покажите QR-код, чтобы войти в приложения Nostr, такие как magick.market. Любой, у кого он есть, управляет вашей личностью, держите его в секрете." + reveal_nsec: "Показать ключ" + copy_nsec: "Копировать nsec" + show_qr: "Показать QR" + hide_qr: "Скрыть QR" privacy: title: "Сетевая приватность" intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами." diff --git a/locales/tr.yml b/locales/tr.yml index c558b100..6e8d4fec 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -609,6 +609,12 @@ goblin: repair_confirm: "Evet, şimdi onar" repair_confirm_note: "Onarım zinciri yeniden tarar ve birkaç dakika sürebilir." restore_confirm_note: "Bu, yerel verileri siler ve seed'inizden yeniden oluşturur — birkaç dakika sürebilir." + nostr_key: "Nostr anahtarı" + nostr_key_desc: "nsec'iniz, Nostr kimliğinizin gizli anahtarı. magick.market gibi Nostr uygulamalarında oturum açmak için kopyalayın veya QR kodunu gösterin. Ona sahip olan herkes kimliğinizi kontrol eder, gizli tutun." + reveal_nsec: "Anahtarı göster" + copy_nsec: "nsec'i kopyala" + show_qr: "QR göster" + hide_qr: "QR gizle" privacy: title: "Ağ gizliliği" intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index a0cb5265..0b132ff3 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -609,6 +609,12 @@ goblin: repair_confirm: "是的,立即修复" repair_confirm_note: "修复会重新扫描链,可能需要几分钟。" restore_confirm_note: "这会清除本地数据并从助记词重建——可能需要几分钟。" + nostr_key: "Nostr 密钥" + nostr_key_desc: "您的 nsec,即 Nostr 身份的私钥。复制它或显示二维码,即可登录 magick.market 等 Nostr 应用。持有它的人即可控制您的身份,请妥善保管。" + reveal_nsec: "显示密钥" + copy_nsec: "复制 nsec" + show_qr: "显示二维码" + hide_qr: "隐藏二维码" privacy: title: "网络隐私" intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。" diff --git a/src/gui/views/goblin/avatars.rs b/src/gui/views/goblin/avatars.rs index cefdd860..e0996ee6 100644 --- a/src/gui/views/goblin/avatars.rs +++ b/src/gui/views/goblin/avatars.rs @@ -115,15 +115,6 @@ impl AvatarTextures { None } - /// Install the just-uploaded avatar without waiting for a round-trip. - pub fn set_own(&mut self, ctx: &egui::Context, name: &str, hash: &str, png: &[u8]) { - let name = name.trim_start_matches('@').to_lowercase(); - self.cache.store(&name, hash, png); - let tex = decode(png) - .map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default())); - self.textures.insert(name, tex); - } - /// Forget a name (released or rotated away). pub fn invalidate(&mut self, name: &str) { let name = name.trim_start_matches('@').to_lowercase(); diff --git a/src/gui/views/goblin/identicon.rs b/src/gui/views/goblin/identicon.rs index 794a1e04..aba46978 100644 --- a/src/gui/views/goblin/identicon.rs +++ b/src/gui/views/goblin/identicon.rs @@ -60,16 +60,6 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String { format!("#{r:02x}{g:02x}{b:02x}") } -/// Conic-ring hue path for a custom-image avatar, seeded by the USERNAME (not -/// the pubkey, per the design): base hue from the first two hash bytes, sweep -/// width (60°–180°) from the third. Deterministic, like the gradients. -pub(super) fn ring_params(name: &str) -> (f64, f64) { - let hash = Sha256::digest(name.as_bytes()); - let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0; - let sweep = 60.0 + (hash[2] as f64 / 255.0) * 120.0; - (base, sweep) -} - /// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical /// lowercase hex pubkey used as the seed everywhere. pub fn to_hex_seed(id: &str) -> String { diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index bc09822e..00112975 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -153,6 +153,15 @@ struct AdvancedState { revealed: Option, /// Set when the entered password didn't decrypt the seed. wrong_pass: bool, + /// Password typed to reveal the nostr secret key (nsec). + nsec_pass: String, + /// The revealed nsec, held only while shown (cleared on hide/back). + nsec_revealed: Option, + /// Set when the entered password didn't unlock the nostr identity. + nsec_wrong: bool, + /// Whether the nsec QR is expanded (so it can be scanned to log in + /// elsewhere, e.g. magick.market's private-key login). + nsec_qr: bool, /// Armed "really restore?" confirm. confirm_restore: bool, /// Armed "really repair?" confirm (repair takes a few minutes). @@ -3139,6 +3148,88 @@ impl GoblinWalletView { }); ui.add_space(12.0); + // Nostr key (nsec). Password-gated reveal, then Copy + a QR + // so it can be carried into a nostr app's private-key login + // (e.g. magick.market) without retyping. Same gate as the + // recovery phrase above. + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + advanced_head(ui, &t!("goblin.advanced.nostr_key"), t.surface_text); + advanced_desc(ui, &t!("goblin.advanced.nostr_key_desc")); + ui.add_space(10.0); + if let Some(nsec) = adv.nsec_revealed.clone() { + w::field_well(ui, |ui| { + ui.label( + RichText::new(&nsec) + .font(FontId::new(14.0, fonts::medium())) + .color(t.surface_text), + ); + }); + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.advanced.copy_nsec")).clicked() + { + cb.copy_string_to_buffer(nsec.clone()); + } + ui.add_space(8.0); + let qr_label = if adv.nsec_qr { + t!("goblin.advanced.hide_qr") + } else { + t!("goblin.advanced.show_qr") + }; + if w::big_action_on_card(ui, &qr_label).clicked() { + adv.nsec_qr = !adv.nsec_qr; + } + if adv.nsec_qr { + ui.add_space(10.0); + ui.vertical_centered(|ui| { + w::qr_code(ui, &nsec, 220.0); + }); + } + ui.add_space(10.0); + if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() { + adv.nsec_revealed = None; + adv.nsec_qr = false; + adv.nsec_pass.clear(); + } + } else { + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("advanced_nsec_pass")) + .focus(false) + .hint_text(t!("goblin.advanced.password")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut adv.nsec_pass, cb); + }); + if adv.nsec_wrong { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.advanced.wrong_password")) + .font(FontId::new(13.0, fonts::medium())) + .color(t.neg), + ); + } + ui.add_space(10.0); + ui.add_enabled_ui(!adv.nsec_pass.is_empty(), |ui| { + if w::big_action_on_card(ui, &t!("goblin.advanced.reveal_nsec")) + .clicked() + { + match wallet.get_nostr_nsec(adv.nsec_pass.clone()) { + Ok(nsec) => { + adv.nsec_revealed = Some(nsec); + adv.nsec_wrong = false; + adv.nsec_pass.clear(); + } + Err(_) => { + adv.nsec_wrong = true; + } + } + } + }); + } + }); + ui.add_space(12.0); + // Delete. w::card(ui, |ui| { ui.set_min_width(ui.available_width()); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index db1db31f..6d637fe6 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -680,17 +680,30 @@ impl SendFlow { // seed words or slatepack contents into the search box. match &result { QrScanResult::Text(text) => { - let text = text.trim(); - let text = text - .strip_prefix("nostr:") - .or_else(|| text.strip_prefix("NOSTR:")) - .unwrap_or(text); + // Parse as a (possibly amount-bearing) pay-URI. UNTRUSTED + // input: the parser is pure, fail-closed, and only ever + // PREFILLS — the recipient still resolves + verifies via the + // picker and the amount/review screens still gate the send. + // A bad amount/memo is dropped; a bare `nostr:` + // behaves exactly as before. + let pay = crate::nostr::payuri::parse(text); // Drop the scanned key into the search box; the picker's // debounced lookup resolves + verifies it like typed input. - self.search = text.to_string(); + self.search = pay.recipient; self.input_changed_at = ui.input(|i| i.time); self.lookup_query.clear(); self.net_candidate = None; + // Prefill the amount only when the wallet's own parser + // accepted it (strictly positive). We stay on the normal + // picker -> amount/review flow; nothing auto-advances. + if let Some(amount) = pay.amount { + self.amount = amount; + } + // Prefill the send note from the (already sanitized) memo; + // it rides along into the tx message via `dispatch`. + if let Some(memo) = pay.memo { + self.note = memo; + } let _ = wallet; } _ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()), diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 79a3a79e..b02750eb 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -27,67 +27,42 @@ pub fn amount_str(atomic: u64) -> String { grin_core::core::amount_to_hr_string(atomic, true) } -/// A custom-picture avatar: the texture drawn in a circle, wrapped by a thin -/// conic-gradient ring derived deterministically from the username (the name, -/// not the npub). The image is inset so the ring sits at the perimeter. +/// A custom-picture avatar: the texture drawn to fill the circle, with a thin +/// light-yellow outline hugging its edge when the identity is a claimed name. pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, name: &str, size: f32) -> Response { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let thickness = (size * 0.045).max(1.0); - let gap = (size * 0.07).max(2.0); - let img_rect = rect.shrink(thickness + gap); - let rounding = eframe::epaint::CornerRadius::same((img_rect.width() / 2.0) as u8); + let rounding = eframe::epaint::CornerRadius::same((rect.width() / 2.0) as u8); egui::Image::new(tex) .corner_radius(rounding) - .fit_to_exact_size(img_rect.size()) - .paint_at(ui, img_rect); - conic_ring( - ui, - rect.center(), - size / 2.0 - thickness / 2.0, - thickness, - name, - ); + .fit_to_exact_size(rect.size()) + .paint_at(ui, rect); + if is_named(name) { + name_ring(ui, rect.center(), size); + } resp } -/// Thin conic-gradient ring at the avatar perimeter, hue path seeded by the -/// username (see `identicon::ring_params`). Drawn as a feathered triangle mesh -/// (~64 segments, per-vertex color) so edges stay smooth; a triangle-wave hue -/// sweep keeps the gradient seamless where the circle closes. No new deps. -fn conic_ring(ui: &Ui, center: egui::Pos2, r_mid: f32, thickness: f32, name: &str) { - use eframe::epaint::{Mesh, Shape, Vertex, WHITE_UV}; - let (base_hue, sweep) = super::identicon::ring_params(name); - const SEGS: u32 = 64; - const FEATHER: f32 = 0.75; - let r_in = r_mid - thickness / 2.0; - let r_out = r_mid + thickness / 2.0; - let radii = [r_out + FEATHER, r_out, r_in, (r_in - FEATHER).max(0.0)]; - let alphas = [0u8, 255, 255, 0]; - let mut mesh = Mesh::default(); - for i in 0..=SEGS { - let frac = i as f32 / SEGS as f32; - let theta = frac * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2; - let wave = 1.0 - (2.0 * frac - 1.0).abs(); - let hue = (base_hue + sweep * f64::from(wave)) % 360.0; - let (r, g, b) = super::identicon::hsl_rgb8(hue, 0.62, 0.55); - let dir = Vec2::new(theta.cos(), theta.sin()); - for (radius, alpha) in radii.iter().zip(alphas) { - mesh.vertices.push(Vertex { - pos: center + dir * *radius, - uv: WHITE_UV, - color: Color32::from_rgba_unmultiplied(r, g, b, alpha), - }); - } - } - for i in 0..SEGS { - let a = i * 4; - let b = a + 4; - for ring in 0..3 { - let (p0, p1, p2, p3) = (a + ring, a + ring + 1, b + ring, b + ring + 1); - mesh.indices.extend_from_slice(&[p0, p1, p2, p1, p3, p2]); - } - } - ui.painter().add(Shape::mesh(mesh)); +/// A thin light-yellow outline on the avatar's edge — the marker of a claimed +/// name. Just an outline that touches the circle (no gap, no gradient); the +/// yellow reads as "this identity has a name" and nothing more. +fn name_ring(ui: &Ui, center: egui::Pos2, size: f32) { + let thickness = (size * 0.03).max(1.0); + // Goblin accent yellow, lightened toward white so the outline stays soft. + let c = super::theme::tokens().accent; + let light = Color32::from_rgb( + c.r().saturating_add((255 - c.r()) / 3), + c.g().saturating_add((255 - c.g()) / 3), + c.b().saturating_add((255 - c.b()) / 3), + ); + // Sit the stroke just inside the perimeter so it stays fully on-canvas. + let radius = size / 2.0 - thickness / 2.0; + ui.painter() + .circle_stroke(center, radius, egui::Stroke::new(thickness, light)); +} + +/// Whether a display name is a real claimed name (not empty, not a bare npub). +fn is_named(name: &str) -> bool { + !name.is_empty() && !name.starts_with("npub") } /// Deterministic gradient avatar (a pubkey-seeded two-tone tile with the Grin @@ -100,23 +75,15 @@ pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response { resp } -/// The UNCHANGED npub-seeded grinmark gradient with a thin conic ring seeded by -/// the USERNAME simply added around its edge — the gradient stays exactly as a -/// ring-less avatar draws it; the ring is the only thing the username adds. +/// The UNCHANGED npub-seeded grinmark gradient orb with a thin light-yellow +/// outline hugging its edge to mark a claimed name — the orb fills the circle +/// exactly as a ring-less avatar draws it; the outline is all the name adds. pub fn gradient_avatar_ringed(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let thickness = (size * 0.045).max(1.0); - let gap = (size * 0.07).max(2.0); - // Inset the orb so the username ring sits AROUND it with a clear gap, - // rather than overlapping its edge. - paint_gradient(ui, id, rect.shrink(thickness + gap)); - conic_ring( - ui, - rect.center(), - size / 2.0 - thickness / 2.0, - thickness, - name, - ); + paint_gradient(ui, id, rect); + if is_named(name) { + name_ring(ui, rect.center(), size); + } resp } diff --git a/src/nostr/avatar.rs b/src/nostr/avatar.rs index be98fb96..544dd937 100644 --- a/src/nostr/avatar.rs +++ b/src/nostr/avatar.rs @@ -12,77 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Client-side avatar handling: local preprocessing of a picked picture -//! (mirrors the server pipeline so uploads over the mixnet stay small and previews -//! are instant — the server still re-validates everything), plus a small -//! disk cache of fetched avatars keyed by username. +//! Client-side avatar handling: a small disk cache of fetched avatars keyed +//! by username. -use image::codecs::png::PngEncoder; -use image::metadata::Orientation; -use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::io::Cursor; use std::path::PathBuf; -/// Output dimensions (square), matching the server. -pub const SIZE: u32 = 256; -/// Raw picked files larger than this are rejected before decoding. -const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; - -/// Identify the image format from magic bytes alone (PNG/JPEG/WebP). -fn sniff(raw: &[u8]) -> Option { - if raw.len() >= 8 && raw.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { - return Some(ImageFormat::Png); - } - if raw.len() >= 3 && raw.starts_with(&[0xFF, 0xD8, 0xFF]) { - return Some(ImageFormat::Jpeg); - } - if raw.len() >= 12 && &raw[0..4] == b"RIFF" && &raw[8..12] == b"WEBP" { - return Some(ImageFormat::WebP); - } - None -} - -/// Read a picked picture file and normalize it to the canonical 256×256 -/// PNG (EXIF orientation applied, every byte of metadata destroyed). -pub fn process_avatar_file(path: &str) -> Result, String> { - let meta = std::fs::metadata(path).map_err(|_| "Couldn't read that file".to_string())?; - if meta.len() > MAX_FILE_BYTES { - return Err("That picture is too large (10 MB max)".to_string()); - } - let raw = std::fs::read(path).map_err(|_| "Couldn't read that file".to_string())?; - process_avatar_bytes(&raw) -} - -/// Normalize raw image bytes to the canonical avatar PNG. -pub fn process_avatar_bytes(raw: &[u8]) -> Result, String> { - let err = || "That file doesn't look like a usable picture".to_string(); - let format = sniff(raw).ok_or_else(err)?; - let mut reader = ImageReader::with_format(Cursor::new(raw), format); - let mut limits = Limits::default(); - limits.max_image_width = Some(8192); - limits.max_image_height = Some(8192); - limits.max_alloc = Some(128 * 1024 * 1024); - reader.limits(limits); - let mut decoder = reader.into_decoder().map_err(|_| err())?; - let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); - let mut img = DynamicImage::from_decoder(decoder).map_err(|_| err())?; - img.apply_orientation(orientation); - let (w, h) = (img.width(), img.height()); - if w == 0 || h == 0 { - return Err(err()); - } - let side = w.min(h); - let img = img.crop_imm((w - side) / 2, (h - side) / 2, side, side); - let img = img.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3); - let rgba = img.to_rgba8(); - let mut out = Vec::new(); - rgba.write_with_encoder(PngEncoder::new(&mut out)) - .map_err(|_| err())?; - Ok(out) -} - /// One cached profile probe. #[derive(Serialize, Deserialize, Clone)] pub struct CacheEntry { @@ -196,33 +132,6 @@ impl AvatarCache { #[cfg(test)] mod tests { use super::*; - use image::RgbaImage; - - fn png_bytes(w: u32, h: u32) -> Vec { - let img = RgbaImage::from_fn(w, h, |x, y| { - image::Rgba([(x % 256) as u8, (y % 256) as u8, 7, 255]) - }); - let mut out = Vec::new(); - image::DynamicImage::ImageRgba8(img) - .write_with_encoder(PngEncoder::new(&mut out)) - .unwrap(); - out - } - - #[test] - fn processes_to_canonical_png() { - let out = process_avatar_bytes(&png_bytes(500, 300)).unwrap(); - assert!(out.starts_with(&[0x89, b'P', b'N', b'G'])); - let img = image::load_from_memory(&out).unwrap(); - assert_eq!((img.width(), img.height()), (SIZE, SIZE)); - } - - #[test] - fn rejects_non_images() { - assert!(process_avatar_bytes(b"").is_err()); - assert!(process_avatar_bytes(b"GIF89a....").is_err()); - assert!(process_avatar_bytes(&[]).is_err()); - } #[test] fn cache_round_trip_and_remove() { diff --git a/src/nostr/config.rs b/src/nostr/config.rs index dda84e2f..4f818780 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -32,7 +32,7 @@ pub enum AcceptPolicy { } /// Per-wallet nostr configuration. -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Default)] pub struct NostrConfig { /// Whether the nostr subsystem runs for this wallet. enabled: Option, @@ -59,21 +59,6 @@ pub struct NostrConfig { path: Option, } -impl Default for NostrConfig { - fn default() -> Self { - Self { - enabled: None, - relays: None, - accept_from: None, - nip05_server: None, - expiry_secs: None, - cancel_grace_secs: None, - allow_incoming_requests: None, - path: None, - } - } -} - impl NostrConfig { /// Nostr configuration file name inside the wallet directory. pub const FILE_NAME: &'static str = "nostr.toml"; diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 0cc7dad9..4f5f8fb1 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -44,3 +44,5 @@ pub use client::{NostrProfile, NostrService, send_phase}; pub mod avatar; pub mod nip05; + +pub mod payuri; diff --git a/src/nostr/payuri.rs b/src/nostr/payuri.rs new file mode 100644 index 00000000..2850d27a --- /dev/null +++ b/src/nostr/payuri.rs @@ -0,0 +1,351 @@ +// 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. + +//! Pay-URI parser for scanned payment QRs. +//! +//! A GoblinPay checkout QR extends the plain `nostr:` URI with an optional +//! amount (and memo): +//! +//! ```text +//! nostr:?amount=&memo= +//! ``` +//! +//! This module is a PURE, side-effect-free parser over UNTRUSTED scan input. +//! It never sends, never resolves — it only extracts a recipient string to +//! feed the existing recipient resolver plus a validated amount/memo to +//! prefill. Every failure mode degrades to "recipient only, manual amount" +//! (fail-closed): a bad amount is dropped, a bad memo is dropped, and a +//! non-`nostr:` payload is returned verbatim exactly as the scanner treated it +//! before this URI existed. +//! +//! Trust model: the recipient bech32 is the ONLY trust anchor (verified later +//! by the resolver). Amount, memo and any relay hints are untrusted hints. + +use grin_core::core::amount_from_hr_string; + +/// Total scanned-payload byte cap. Anything larger is abuse, not an address. +const MAX_URI_LEN: usize = 4096; +/// Memo byte cap (post control-strip), display / tx-message only. +const MAX_MEMO_BYTES: usize = 256; + +/// A parsed pay-URI. `recipient` is fed to the existing resolver as-is (the +/// bech32/name that used to go straight into the search box). `amount` is the +/// raw decimal-GRIN string, present only when `amount_from_hr_string` accepted +/// it and it is strictly positive. `memo` is already control-stripped and +/// length-capped. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PayUri { + pub recipient: String, + pub amount: Option, + pub memo: Option, +} + +impl PayUri { + /// A recipient-only result with no prefilled amount/memo (today's behavior). + fn bare(recipient: String) -> Self { + PayUri { + recipient, + amount: None, + memo: None, + } + } +} + +/// Parse a scanned payload into a [`PayUri`]. Pure and total: never panics, +/// never performs I/O, always returns a value. On any problem it falls back to +/// recipient-only (fail-closed). +pub fn parse(scanned: &str) -> PayUri { + let text = scanned.trim(); + + // Fail closed on clear abuse: oversize payload or an embedded NUL. Return + // nothing usable rather than feeding a hostile blob to the resolver. + if text.len() > MAX_URI_LEN || text.as_bytes().contains(&0) { + return PayUri::bare(String::new()); + } + + // Strict scheme: only the `nostr:` prefix (case-insensitive) unlocks + // amount/memo parsing, matching the scanner's existing strip logic. Any + // other payload (a bare npub, or some other scheme) is returned verbatim, + // exactly as the scanner treated it before pay-URIs existed. + let rest = match strip_nostr_prefix(text) { + Some(rest) => rest, + None => return PayUri::bare(text.to_string()), + }; + + // Split `?`. A bare `nostr:` has no `?`, so the + // whole remainder is the recipient — identical to the pre-URI behavior. + let (recipient, query) = match rest.split_once('?') { + Some((r, q)) => (r.to_string(), Some(q)), + None => (rest.to_string(), None), + }; + + let mut amount = None; + let mut memo = None; + if let Some(query) = query { + for pair in query.split('&') { + let Some((key, val)) = pair.split_once('=') else { + continue; // valueless / malformed segment — ignore + }; + match key { + // First occurrence wins; later duplicates are ignored so a + // second `amount=` can't override a validated one. + "amount" if amount.is_none() => amount = validate_amount(val), + "memo" if memo.is_none() => memo = validate_memo(val), + // Unknown params are ignored for forward-compat. + _ => {} + } + } + } + + PayUri { + recipient, + amount, + memo, + } +} + +/// Strip a case-insensitive `nostr:` scheme prefix, returning the remainder. +/// Byte-safe against a leading multibyte char (no `[..6]` slice panic). +fn strip_nostr_prefix(text: &str) -> Option<&str> { + let head = text.get(..6)?; + if head.eq_ignore_ascii_case("nostr:") { + Some(&text[6..]) + } else { + None + } +} + +/// Validate an `amount` value: percent-decode, then accept it ONLY if the +/// wallet's own `amount_from_hr_string` parses it to a strictly positive +/// atomic amount. Never custom float parsing; any error → `None` (fall back to +/// manual entry). Returns the clean decoded decimal string on success. +fn validate_amount(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + match amount_from_hr_string(&decoded) { + Ok(atomic) if atomic > 0 => Some(decoded), + _ => None, + } +} + +/// Validate a `memo` value: percent-decode, strip ASCII control chars and +/// newlines (untrusted free text — display / tx-message only, never a path or +/// route), then hard-cap at [`MAX_MEMO_BYTES`] on a UTF-8 boundary. Empty → +/// `None`. +fn validate_memo(raw: &str) -> Option { + let decoded = percent_decode(raw); + // Drop ASCII control bytes (< 0x20, covering NUL / newline / tab) and DEL. + let cleaned: Vec = decoded + .into_iter() + .filter(|&b| b >= 0x20 && b != 0x7f) + .collect(); + let text = String::from_utf8_lossy(&cleaned).into_owned(); + let text = truncate_on_char_boundary(text, MAX_MEMO_BYTES); + let text = text.trim().to_string(); + if text.is_empty() { None } else { Some(text) } +} + +/// Truncate a string to at most `max` bytes without splitting a UTF-8 char. +fn truncate_on_char_boundary(s: String, max: usize) -> String { + if s.len() <= max { + return s; + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +/// Minimal, correct RFC-3986 percent-decode over bytes. `%XX` (hex) becomes one +/// byte; a stray `%` or a non-hex escape is passed through literally. No new +/// dependency — the wallet has no direct percent-encoding crate and this is a +/// few lines. `+` is left literal (RFC-3986 query, not form-encoding). +fn percent_decode(s: &str) -> Vec { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) { + out.push((hi << 4) | lo); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + out +} + +/// Hex-nibble value for an ASCII hex digit, or `None`. +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NPROFILE: &str = + "nprofile1qqsw3v0m5v6h9q8n0hkxg6l4l5xk2z7z0n6f6q9m8x0q5v4l3k2j1h0gpz3mhxue69uhhyetvv9uju"; + + #[test] + fn bare_nprofile_unchanged() { + let uri = format!("nostr:{NPROFILE}"); + let out = parse(&uri); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount, None); + assert_eq!(out.memo, None); + } + + #[test] + fn bare_npub_no_scheme_is_verbatim() { + // No scheme at all → returned exactly as today (fed to the resolver). + let out = parse("npub1abcdef"); + assert_eq!(out.recipient, "npub1abcdef"); + assert_eq!(out.amount, None); + assert_eq!(out.memo, None); + } + + #[test] + fn uppercase_scheme_accepted() { + let out = parse(&format!("NOSTR:{NPROFILE}?amount=2")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("2")); + } + + #[test] + fn with_amount() { + let out = parse(&format!("nostr:{NPROFILE}?amount=1.5")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("1.5")); + assert_eq!(out.memo, None); + } + + #[test] + fn with_amount_and_memo() { + let out = parse(&format!("nostr:{NPROFILE}?amount=0.25&memo=Coffee")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("0.25")); + assert_eq!(out.memo.as_deref(), Some("Coffee")); + } + + #[test] + fn negative_amount_rejected() { + let out = parse(&format!("nostr:{NPROFILE}?amount=-1")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount, None); + } + + #[test] + fn zero_and_empty_amount_rejected() { + assert_eq!(parse(&format!("nostr:{NPROFILE}?amount=0")).amount, None); + assert_eq!(parse(&format!("nostr:{NPROFILE}?amount=")).amount, None); + } + + #[test] + fn garbage_amount_rejected() { + for bad in ["abc", "1.5xyz", "1,5", "0x10", "1 5", " 1"] { + let out = parse(&format!("nostr:{NPROFILE}?amount={bad}")); + assert_eq!(out.amount, None, "expected {bad:?} to be rejected"); + } + } + + #[test] + fn overlong_memo_truncated() { + let long = "a".repeat(500); + let out = parse(&format!("nostr:{NPROFILE}?memo={long}")); + let memo = out.memo.expect("memo present"); + assert_eq!(memo.len(), 256); + assert!(memo.bytes().all(|b| b == b'a')); + } + + #[test] + fn memo_control_chars_stripped() { + // Percent-encoded NUL, newline, tab and a raw CR are all removed. + let out = parse(&format!("nostr:{NPROFILE}?memo=A%00B%0AC%09D\rE")); + assert_eq!(out.memo.as_deref(), Some("ABCDE")); + } + + #[test] + fn memo_percent_decoded() { + // "Hi there & co =2" with reserved chars percent-encoded. + let out = parse(&format!( + "nostr:{NPROFILE}?memo=Hi%20there%20%26%20co%20%3D2" + )); + assert_eq!(out.memo.as_deref(), Some("Hi there & co =2")); + } + + #[test] + fn non_nostr_scheme_treated_as_today() { + // A different scheme is NOT parsed for amount/memo; returned verbatim. + let out = parse("bitcoin:bc1qxyz?amount=1.5"); + assert_eq!(out.recipient, "bitcoin:bc1qxyz?amount=1.5"); + assert_eq!(out.amount, None); + assert_eq!(out.memo, None); + } + + #[test] + fn unknown_params_ignored() { + let out = parse(&format!( + "nostr:{NPROFILE}?lightning=zzz&amount=3&foo=bar&memo=Hey" + )); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("3")); + assert_eq!(out.memo.as_deref(), Some("Hey")); + } + + #[test] + fn over_length_rejected() { + let huge = format!("nostr:{}", "a".repeat(5000)); + let out = parse(&huge); + assert_eq!(out.recipient, ""); + assert_eq!(out.amount, None); + assert_eq!(out.memo, None); + } + + #[test] + fn embedded_nul_rejected() { + let out = parse(&format!("nostr:{NPROFILE}\0?amount=1")); + assert_eq!(out.recipient, ""); + assert_eq!(out.amount, None); + } + + #[test] + fn duplicate_amount_first_wins() { + let out = parse(&format!("nostr:{NPROFILE}?amount=1&amount=999")); + assert_eq!(out.amount.as_deref(), Some("1")); + } + + #[test] + fn leading_trailing_whitespace_trimmed() { + let out = parse(&format!(" nostr:{NPROFILE}?amount=1.5 ")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("1.5")); + } + + #[test] + fn empty_input_is_bare_empty() { + let out = parse(""); + assert_eq!(out.recipient, ""); + assert_eq!(out.amount, None); + assert_eq!(out.memo, None); + } +} diff --git a/src/nostr/pool.rs b/src/nostr/pool.rs index bb21b57a..526c9535 100644 --- a/src/nostr/pool.rs +++ b/src/nostr/pool.rs @@ -203,6 +203,12 @@ pub fn load() -> RelayPool { std::fs::read_to_string(cache_path()) .ok() .and_then(|raw| RelayPool::parse(&raw)) + // A cache written by a pre-exit build parses fine but hides the + // scoped-exit money path (and the current primary relay) for up to + // CACHE_MAX_AGE_SECS after an app update — relay connects then ride + // the slow public-IPR path for days. The pinned pool is newer than + // any exit-less file, so prefer it until the next gist refresh. + .filter(RelayPool::has_exit) .unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses")) } @@ -220,7 +226,14 @@ pub async fn refresh_if_stale() { .and_then(|m| m.modified().ok()) .and_then(|t| t.elapsed().ok()) .map(|age| age.as_secs() < CACHE_MAX_AGE_SECS) - .unwrap_or(false); + .unwrap_or(false) + // An exit-less cache predates the current pool shape (see `load`, + // which already ignores it) — replace it now instead of serving the + // pinned fallback for the rest of the file's 7 days. + && std::fs::read_to_string(&path) + .ok() + .and_then(|raw| RelayPool::parse(&raw)) + .is_some_and(|p| p.has_exit()); if fresh { return; } diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 53dcc554..10c43ade 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -170,10 +170,6 @@ impl NostrStore { self.put_json(&self.contacts, &contact.npub, contact); } - pub fn delete_contact(&self, npub_hex: &str) { - self.delete(&self.contacts, npub_hex); - } - pub fn all_contacts(&self) -> Vec { self.all_json(&self.contacts) } @@ -312,11 +308,4 @@ impl NostrStore { self.clear(&self.requests); self.clear(&self.processed); } - - /// Wipe everything including contacts. - pub fn wipe_all(&self) { - self.wipe_archive(); - self.clear(&self.contacts); - self.clear(&self.settings); - } } diff --git a/src/nym/dns.rs b/src/nym/dns.rs index f200b957..6ad89f95 100644 --- a/src/nym/dns.rs +++ b/src/nym/dns.rs @@ -38,13 +38,9 @@ //! Answers land in a TTL-respecting in-memory cache and hosts are prewarmed at //! startup, so a warm entry (not a fresh mixnet round trip) serves the common //! case. IPv4-only, like the rest of the app (GRIM audit). -//! -//! A legacy UDP path is retained behind `GOBLIN_DNS_UDP=1` for measuring the -//! regression this replaced; it is never used in shipped builds. use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -82,12 +78,6 @@ const DOT_RESOLVERS: [DotResolver; 2] = [ }, ]; -/// Legacy UDP resolvers (port 53) — only used when `GOBLIN_DNS_UDP=1`. -const UDP_RESOLVERS: [SocketAddr; 2] = [ - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53), - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53), -]; - /// A DoH resolver: the IP:443 to dial through the tunnel, its SNI/cert + Host /// name, and the RFC 8484 query path. DoH is the FALLBACK for an exit whose /// policy blocks DoT (:853) — 443 is guaranteed reachable (relays + HTTPS ride @@ -121,8 +111,6 @@ enum DnsMode { Dot, /// DoH — DNS-over-HTTPS on :443 (fallback when an exit blocks :853). Doh, - /// Legacy UDP-over-mixnet (`GOBLIN_DNS_UDP=1`, measurement only). - Udp, } /// Sticky: set once an exit is found to block DoT (:853), so we stop paying the @@ -134,15 +122,11 @@ static PREFER_DOH: AtomicBool = AtomicBool::new(false); /// (a few seconds of deliberate per-hop delay), so allow more headroom than the /// UDP path did; a round that exceeds this is retried rather than waited out. const DOT_QUERY_TIMEOUT: Duration = Duration::from_secs(8); -/// UDP per-query wait (legacy path). -const UDP_QUERY_TIMEOUT: Duration = Duration::from_secs(4); /// Quick race-both-resolvers rounds before giving up. DoT is TCP-reliable within /// a round, so two rounds is plenty (the second only matters if a whole -/// connection was dropped); the UDP path needs one more because it loses -/// datagrams. +/// connection was dropped). const DOT_ROUNDS: usize = 2; -const UDP_ROUNDS: usize = 3; /// DoH per-query wait (TCP + TLS + one HTTP round trip over the mixnet) and its /// round count. Same reliability as DoT (TCP), a touch more per-request overhead @@ -159,24 +143,6 @@ lazy_static! { /// host → (addresses, expiry). static ref CACHE: RwLock, Instant)>> = RwLock::new(HashMap::new()); - - /// Shared rustls client config for DoT (webpki roots; ring provider installed - /// at startup — the Build 65/66 rule), reused for every resolver handshake. - static ref DOT_TLS: Arc = { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - Arc::new( - rustls::ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(), - ) - }; -} - -/// Restore the pre-Build-98 UDP mix-dns path. Measurement/debug only (reproduce -/// the lossy-UDP regression DoT replaced); default OFF. -fn use_legacy_udp() -> bool { - matches!(std::env::var("GOBLIN_DNS_UDP").as_deref(), Ok("1")) } /// Resolve `host` to a socket address for `tcp_connect`, entirely over the @@ -192,10 +158,6 @@ pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> Option Option Option { let (proto, rounds) = match mode { DnsMode::Dot => ("dot-dns", DOT_ROUNDS), DnsMode::Doh => ("doh-dns", DOH_ROUNDS), - DnsMode::Udp => ("udp-dns", UDP_ROUNDS), }; let start = Instant::now(); for round in 0..rounds { let answer = match mode { DnsMode::Dot => race_dot(tunnel, host).await, DnsMode::Doh => race_doh(tunnel, host).await, - DnsMode::Udp => race_udp(tunnel, host).await, }; if let Some((resolver, ips, ttl)) = answer { let ttl = ttl.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS); @@ -295,7 +255,7 @@ async fn query_dot( .map_err(|e| debug!("dot-dns: connect to {} failed: {e}", resolver.addr)) .ok()?; let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?; - let mut tls = tokio_rustls::TlsConnector::from(DOT_TLS.clone()) + let mut tls = tokio_rustls::TlsConnector::from(super::tls_config()) .connect(server_name, tcp) .await .map_err(|e| debug!("dot-dns: tls handshake with {} failed: {e}", resolver.sni)) @@ -372,7 +332,7 @@ async fn query_doh( .map_err(|e| debug!("doh-dns: connect to {} failed: {e}", resolver.ip)) .ok()?; let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?; - let tls = tokio_rustls::TlsConnector::from(DOT_TLS.clone()) + let tls = tokio_rustls::TlsConnector::from(super::tls_config()) .connect(server_name, tcp) .await .map_err(|e| debug!("doh-dns: tls handshake with {} failed: {e}", resolver.sni)) @@ -408,22 +368,6 @@ async fn query_doh( parse_response(id, &body) } -/// One legacy-UDP round (only reached with `GOBLIN_DNS_UDP=1`). -async fn race_udp(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec, u32)> { - let mut inflight = FuturesUnordered::new(); - for resolver in UDP_RESOLVERS { - inflight.push(async move { (resolver, query_udp(tunnel, host, resolver).await) }); - } - while let Some((resolver, answer)) = inflight.next().await { - if let Some((ips, ttl)) = answer - && !ips.is_empty() - { - return Some((resolver, ips, ttl)); - } - } - None -} - /// Resolve a batch of hosts concurrently to populate the cache, so the first /// real use (relay dial, NIP-05 name claim, price fetch) hits a warm entry /// instead of paying the mixnet DoT round trip inline. Best-effort; the port is @@ -475,44 +419,6 @@ pub async fn probe(tunnel: &Tunnel) -> bool { } } -/// One legacy-UDP A query/response round trip over the tunnel against `resolver`. -async fn query_udp( - tunnel: &Tunnel, - host: &str, - resolver: SocketAddr, -) -> Option<(Vec, u32)> { - let udp = match tunnel.udp_socket().await { - Ok(s) => s, - Err(e) => { - warn!("udp-dns: udp socket failed: {e}"); - return None; - } - }; - let id = rand::random::(); - let query = encode_query(id, host)?; - if let Err(e) = udp.send_to(&query, resolver).await { - warn!("udp-dns: send to {resolver} failed: {e}"); - return None; - } - let mut buf = vec![0u8; 1500]; - let (n, from) = match tokio::time::timeout(UDP_QUERY_TIMEOUT, udp.recv_from(&mut buf)).await { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - warn!("udp-dns: recv from {resolver} failed: {e}"); - return None; - } - Err(_) => { - debug!("udp-dns: query to {resolver} timed out (will retry)"); - return None; - } - }; - if from != resolver { - warn!("udp-dns: dropping answer from unexpected source {from}"); - return None; - } - parse_response(id, &buf[..n]) -} - /// Encode a recursive A query for `host` with transaction id `id`. fn encode_query(id: u16, host: &str) -> Option> { let name = Name::from_ascii(host).ok()?; diff --git a/src/nym/mod.rs b/src/nym/mod.rs index 95e45b77..b843aaf8 100644 --- a/src/nym/mod.rs +++ b/src/nym/mod.rs @@ -30,8 +30,7 @@ //! timeouts (~10s measured), tipping relay connects past the exit-condemnation //! grace and driving a 2-3 minute reselect loop. Build 98 moves DNS to DoT //! (TCP+TLS through the tunnel): TCP retransmits (no packet-loss stalls) and TLS -//! encrypts the query from the exit — reliable AND private. (`GOBLIN_DNS_UDP=1` -//! restores the old UDP path for measuring the regression.) +//! encrypts the query from the exit — reliable AND private. pub mod dns; pub mod nymproc; @@ -255,11 +254,32 @@ async fn exit_connect(host: &str, exit: &str) -> Option> { } } -/// Everything hyper needs from the tunneled stream, boxable for the plain -/// http / https split. -trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} +/// Everything hyper (and the TLS/websocket layers) needs from a mixnet-carried +/// stream, boxable for the plain http / https / scoped-exit split. Shared with +/// the scoped-exit egress ([`streamexit::BoxedStream`]). +pub(crate) trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} impl Stream for T {} +lazy_static::lazy_static! { + /// Shared rustls client config (webpki roots; ring provider installed at + /// startup — the Build 65/66 rule), reused by every in-tunnel TLS handshake + /// (HTTPS here, DoT/DoH in [`dns`]). + static ref TLS_CONFIG: Arc = { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) + }; +} + +/// The shared rustls client config (cheap `Arc` bump). +pub(crate) fn tls_config() -> Arc { + TLS_CONFIG.clone() +} + /// TLS-wrap a tunneled TCP stream with rustls + webpki roots (never the /// platform verifier — it panics on Android outside a full app context). The /// certificate is validated against the HOSTNAME even though the dial went to a @@ -268,21 +288,8 @@ async fn tls_connect(host: &str, stream: S) -> Option = { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - Arc::new( - rustls::ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(), - ) - }; - } let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?; - tokio_rustls::TlsConnector::from(TLS_CONFIG.clone()) + tokio_rustls::TlsConnector::from(tls_config()) .connect(server_name, stream) .await .map_err(|e| warn!("nym http: tls handshake with {host} failed: {e}")) diff --git a/src/nym/nymproc.rs b/src/nym/nymproc.rs index 31569a69..fdca45d3 100644 --- a/src/nym/nymproc.rs +++ b/src/nym/nymproc.rs @@ -200,6 +200,11 @@ fn run_tunnel() { // exit in the pool → no wait. Cold start only: on a later reselect the // exit is long-ready, so `is_ready()` returns instantly. if crate::nostr::pool::load().has_exit() { + // Kick the exit client's bootstrap NOW — nothing else touches it + // until the first relay dial (after a wallet opens), so waiting + // without this would just burn the head start and the grant race + // would happen anyway. + tokio::spawn(super::streamexit::prewarm()); let head_start = Instant::now(); while !super::streamexit::is_ready() && head_start.elapsed() < EXIT_HEAD_START { tokio::time::sleep(Duration::from_millis(200)).await; @@ -342,11 +347,11 @@ fn run_tunnel() { *TUNNEL.write() = None; tunnel.shutdown().await; // Rebuild floor: never re-select faster than once per - // MIN_EXIT_LIFETIME. In the legacy path (and any future bug) - // this is the hard guarantee that a condemnation can't thrash - // the mixnet into a tight reselect loop. + // MIN_EXIT_LIFETIME. Whatever condemned the exit (or any + // future bug), this is the hard guarantee that a condemnation + // can't thrash the mixnet into a tight reselect loop. let alive = published.elapsed(); - if !legacy_watchdog() && alive < MIN_EXIT_LIFETIME { + if alive < MIN_EXIT_LIFETIME { let floor = MIN_EXIT_LIFETIME - alive; info!( "[timing] nym: rebuild floor — waiting {}ms before next exit select", @@ -440,14 +445,6 @@ pub(crate) const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(20); /// tunnel more than briefly; the exit typically readies well inside it. const EXIT_HEAD_START: Duration = Duration::from_secs(12); -/// Restore the pre-Build-98 watchdog (condemn on RELAY_GRACE of no-relay alone, -/// no connectivity gate, no rebuild floor). Debug/measurement only — lets a cold -/// run reproduce the old reselect loop for a BEFORE/AFTER comparison. Default -/// OFF. -fn legacy_watchdog() -> bool { - matches!(std::env::var("GOBLIN_LEGACY_WATCHDOG").as_deref(), Ok("1")) -} - /// Watchdog poll cadence. The relay-reachability check is a bare atomic load /// (free), so a short cadence costs nothing and never touches the network; the /// DNS keepalive still only fires every [`KEEPALIVE_PERIOD`], preserving the @@ -468,7 +465,6 @@ const WATCH_TICK: Duration = Duration::from_secs(5); /// Returns once either signal declares the current exit dead, whereupon /// `run_tunnel` rebuilds on a fresh auto-selected exit. async fn watch_tunnel(tunnel: &smolmix::Tunnel, generation: u64) { - let legacy = legacy_watchdog(); let published = Instant::now(); let mut dns_fails = 0u32; let mut since_dns = Duration::ZERO; @@ -481,21 +477,8 @@ async fn watch_tunnel(tunnel: &smolmix::Tunnel, generation: u64) { if relay_consumer() && !relay_live_for(generation) { let lost = *relay_lost.get_or_insert_with(Instant::now); let absent = lost.elapsed(); - if legacy { - // Pre-Build-98: condemn on RELAY_GRACE of no-relay alone. Kept for - // BEFORE/AFTER measurement; this is the branch that produced the - // reselect loop when mix-dns made relays slow to connect. - if absent >= RELAY_GRACE { - warn!( - "[timing] nym: CONDEMN gen {generation} reason=no-relay-{}s (legacy watchdog); \ - exit lived {}s, re-selecting", - RELAY_GRACE.as_secs(), - published.elapsed().as_secs() - ); - return; - } - } else if published.elapsed() >= MIN_EXIT_LIFETIME && absent >= RELAY_GRACE { - // Robust: past the settle floor AND relays absent for the grace. + if published.elapsed() >= MIN_EXIT_LIFETIME && absent >= RELAY_GRACE { + // Past the settle floor AND relays absent for the grace. // Don't condemn on "no relay yet" alone — first prove the exit // itself has NO connectivity (a genuine blackhole). If the probe // SUCCEEDS the exit reaches the internet, so relays are merely slow diff --git a/src/nym/streamexit.rs b/src/nym/streamexit.rs index 4824aa42..17cfe71c 100644 --- a/src/nym/streamexit.rs +++ b/src/nym/streamexit.rs @@ -35,16 +35,12 @@ use std::time::Duration; use log::{info, warn}; use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient}; -use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::Mutex; -/// Everything the TLS/websocket layer needs from the egress stream. -pub trait ExitStream: AsyncRead + AsyncWrite + Send + Unpin {} -impl ExitStream for T {} - /// The boxed transport stream handed to the TLS/websocket layer — the same -/// seat the smolmix tunnel's TCP stream occupies on the fallback path. -pub type BoxedStream = Box; +/// seat the smolmix tunnel's TCP stream occupies on the fallback path +/// (everything that layer needs is the shared [`super::Stream`] trait). +pub(crate) type BoxedStream = Box; /// After the Open is SENT, wait this long before handing back a writable /// stream. `open_stream` returns once the Open message leaves the client, NOT @@ -98,7 +94,7 @@ pub fn is_ready() -> bool { /// mixnet — a DEAD exit still hands back a stream, and its death surfaces at /// the caller's (timeout-bounded) TLS handshake, which doubles as the /// liveness probe: no ServerHello through the pipe → fall back. -pub async fn open_stream(exit: &str, timeout: Duration) -> Result { +pub(crate) async fn open_stream(exit: &str, timeout: Duration) -> Result { let recipient: Recipient = exit .trim() .parse() @@ -113,8 +109,22 @@ pub async fn open_stream(exit: &str, timeout: Duration) -> Result Result { +/// Bootstrap the shared client ahead of the first dial. The cold-start +/// sequencer in [`super::nymproc`] spawns this when the pool advertises an +/// exit: without it the client would only bootstrap on the first relay dial — +/// which happens after a wallet opens, so the tunnel's bounded head-start wait +/// would just expire and both clients would race for their bandwidth grants +/// anyway. Failure is non-fatal (the first real dial retries the bootstrap). +pub async fn prewarm() { + if let Err(e) = ensure_client().await { + warn!("nym: streamexit prewarm failed: {e}"); + } +} + +/// Ensure the shared client is connected (bootstrapping it when absent or +/// dead) and READY reflects reality. Holds the client lock across the +/// bootstrap so concurrent callers coalesce onto one connect. +async fn ensure_client() -> Result<(), String> { let mut guard = CLIENT.lock().await; // A dead client (gateway dropped, hosting runtime gone) is discarded and // rebuilt — the auto-reconnect-on-drop rule. @@ -138,7 +148,19 @@ async fn open(recipient: Recipient) -> Result { *guard = Some(client); READY.store(true, Ordering::Relaxed); } - let client = guard.as_mut().expect("client ensured above"); + Ok(()) +} + +/// Ensure the shared client is connected, then open a stream on it. +async fn open(recipient: Recipient) -> Result { + ensure_client().await?; + let mut guard = CLIENT.lock().await; + // Re-acquired the lock after ensure_client — a concurrent failed dial may + // have dropped the client in between; error into the caller's fallback + // rather than panic. + let Some(client) = guard.as_mut() else { + return Err("exit client lost before dial".to_string()); + }; match client.open_stream(recipient, None).await { Ok(stream) => Ok(stream), Err(e) => { diff --git a/src/nym/transport.rs b/src/nym/transport.rs index 93e7df06..53eadf77 100644 --- a/src/nym/transport.rs +++ b/src/nym/transport.rs @@ -24,7 +24,6 @@ //! payload + in-flight destination never touch the clear, and an exit failure //! only ever falls back — never a lockout. -use std::fmt; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; @@ -37,20 +36,10 @@ use nostr_sdk::Url; use nostr_sdk::util::BoxedFuture; use tokio_tungstenite::tungstenite::Message as TgMessage; -/// Error type for transport failures outside the websocket layer. -#[derive(Debug)] -struct NymTransportError(String); - -impl fmt::Display for NymTransportError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl std::error::Error for NymTransportError {} - +/// A backend transport error (failures outside the websocket layer) carrying +/// `msg` as its display text. fn terr(msg: impl Into) -> TransportError { - TransportError::backend(NymTransportError(msg.into())) + TransportError::backend(std::io::Error::other(msg.into())) } /// Nostr websocket transport over the in-process Nym mixnet tunnel. diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index b96350fd..51bbf238 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -488,6 +488,27 @@ impl Wallet { /// shares nothing with it), atomically moving the registered username /// (if any) to the new key via the name server. Blocking (network I/O): /// call from a worker thread. Returns the new bech32 npub. + /// The nostr secret key (nsec, bech32) for this wallet, gated on the wallet + /// password. Used by Advanced → "Nostr key" so the user can copy it or show + /// it as a QR to log in to nostr apps (e.g. magick.market). Unlocking the + /// stored identity both verifies the password and yields the keys, so a + /// wrong password can never leak the key. The value is derived on demand and + /// never persisted. + pub fn get_nostr_nsec(&self, password: String) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr identity not ready".to_string())?; + use nostr_sdk::ToBech32; + let keys = svc + .identity + .read() + .unlock(&password) + .map_err(|_| "wrong password".to_string())?; + keys.secret_key() + .to_bech32() + .map_err(|e| format!("nsec encode failed: {e}")) + } + pub fn rotate_nostr_identity(&self, password: String) -> Result { let svc = self .nostr_service() diff --git a/wallet b/wallet index 906dc55b..c2db7545 160000 --- a/wallet +++ b/wallet @@ -1 +1 @@ -Subproject commit 906dc55b9513ba60f76cc33de1372ea652be2a53 +Subproject commit c2db754552b9e5c57c4a843c68744df0cc744ff8