diff --git a/locales/de.yml b/locales/de.yml index 9204e4ae..82b5d4eb 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -683,6 +683,15 @@ goblin: title: "%{n} Rechnungen bestätigen" blurb: "Stelle %{n} Zahlungsanforderungen über je %{amount} an %{name} — insgesamt %{total}. Jede Anforderung erhält ihre eigene neue Zahlungsadresse." approve: "Bestätigen" + login: + title: "Anmelden" + headline: "Bei %{domain} anmelden?" + identity: "Anmelden als" + explain: "Dies signiert eine einmalige Anmeldeanfrage. Dein Schlüssel wird nicht geteilt, und die Seite kann nicht in deinem Namen handeln." + pass_prompt: "Gib dein Wallet-Passwort ein, um dich anzumelden." + confirm: "Anmelden" + sent: "Bei %{domain} angemeldet" + failed: "%{domain} war nicht erreichbar. Die Anmeldung wurde nicht zugestellt." identities: title: "Identitäten" switch_hint: "Identität wechseln" diff --git a/locales/en.yml b/locales/en.yml index 0f7fe7b2..b39d994b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -683,6 +683,15 @@ goblin: title: "Approve %{n} invoices" blurb: "Issue %{n} payment requests of %{amount} each to %{name} — %{total} in total. Each request gets its own fresh payment address." approve: "Approve" + login: + title: "Sign in" + headline: "Sign in to %{domain}?" + identity: "Signing in as" + explain: "This signs a one-time login request. It does not share your key or let the site act as you." + pass_prompt: "Enter your wallet password to sign in." + confirm: "Sign in" + sent: "Signed in to %{domain}" + failed: "Could not reach %{domain}. The sign-in was not delivered." identities: title: "Identities" switch_hint: "Switch identity" diff --git a/locales/fr.yml b/locales/fr.yml index 4ee1f455..4cd7091e 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -683,6 +683,15 @@ goblin: title: "Approuver %{n} factures" blurb: "Émettre %{n} demandes de paiement de %{amount} chacune à %{name} — %{total} au total. Chaque demande reçoit sa propre adresse de paiement." approve: "Approuver" + login: + title: "Connexion" + headline: "Se connecter à %{domain} ?" + identity: "Se connecter en tant que" + explain: "Ceci signe une demande de connexion à usage unique. Votre clé n'est pas partagée et le site ne peut pas agir en votre nom." + pass_prompt: "Saisissez le mot de passe de votre portefeuille pour vous connecter." + confirm: "Se connecter" + sent: "Connecté à %{domain}" + failed: "Impossible de joindre %{domain}. La connexion n'a pas été transmise." identities: title: "Identités" switch_hint: "Changer d'identité" diff --git a/locales/ru.yml b/locales/ru.yml index 5b0460dd..827d98af 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -683,6 +683,15 @@ goblin: title: "Подтвердить %{n} счетов" blurb: "Выставить %{n} запросов на оплату по %{amount} каждый для %{name} — всего %{total}. Каждый запрос получает свой новый платёжный адрес." approve: "Подтвердить" + login: + title: "Вход" + headline: "Войти на %{domain}?" + identity: "Войти как" + explain: "Будет подписан одноразовый запрос на вход. Ваш ключ не передаётся, и сайт не сможет действовать от вашего имени." + pass_prompt: "Введите пароль кошелька, чтобы войти." + confirm: "Войти" + sent: "Выполнен вход на %{domain}" + failed: "Не удалось связаться с %{domain}. Вход не был выполнен." identities: title: "Личности" switch_hint: "Сменить личность" diff --git a/locales/tr.yml b/locales/tr.yml index 35d7cbf3..2cb6eb62 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -683,6 +683,15 @@ goblin: title: "%{n} faturayı onayla" blurb: "%{name} için her biri %{amount} olan %{n} ödeme isteği düzenle — toplam %{total}. Her istek kendi yeni ödeme adresini alır." approve: "Onayla" + login: + title: "Giriş yap" + headline: "%{domain} sitesine giriş yapılsın mı?" + identity: "Giriş yapılacak kimlik" + explain: "Bu, tek seferlik bir giriş isteğini imzalar. Anahtarın paylaşılmaz ve site senin adına işlem yapamaz." + pass_prompt: "Giriş yapmak için cüzdan parolanı gir." + confirm: "Giriş yap" + sent: "%{domain} girişi yapıldı" + failed: "%{domain} adresine ulaşılamadı. Giriş iletilemedi." identities: title: "Kimlikler" switch_hint: "Kimlik değiştir" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 379156e9..cbdb174f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -683,6 +683,15 @@ goblin: title: "批准 %{n} 张发票" blurb: "向 %{name} 发出 %{n} 个付款请求,每个 %{amount},共 %{total}。每个请求都有自己全新的收款地址。" approve: "批准" + login: + title: "登录" + headline: "登录 %{domain}?" + identity: "登录身份" + explain: "这将签署一次性的登录请求。不会分享你的密钥,网站也无法以你的身份行事。" + pass_prompt: "输入钱包密码以登录。" + confirm: "登录" + sent: "已登录 %{domain}" + failed: "无法连接 %{domain},登录未送达。" identities: title: "身份" switch_hint: "切换身份" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 9b91f773..7283081f 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -137,6 +137,12 @@ pub struct GoblinWalletView { back_hint: Option, /// A batch invoice request awaiting approval (count=N deep link). batch_invoice: Option, + /// A "Sign in with Goblin" login request awaiting approval (deep link or + /// scanned QR), including the callback POST once approved. While this is + /// `Some`, every new incoming login URI is ignored (one at a time). + login: Option, + /// Quiet toast for the login outcome: text and when it appeared. + login_toast: Option<(String, std::time::Instant)>, } /// Whether the per-identity cue is drawn on activity rows (owner-approved). The @@ -269,6 +275,8 @@ impl Default for GoblinWalletView { min_conf_edit: String::new(), back_hint: None, batch_invoice: None, + login: None, + login_toast: None, } } } @@ -348,6 +356,40 @@ const MIN_CONF_MODAL: &str = "goblin_min_conf_modal"; /// one approval for N payment requests, each on its own fresh proof address. const BATCH_INVOICE_MODAL: &str = "goblin_batch_invoice_modal"; +/// Id of the "Sign in with Goblin" approval modal: the user reviews the +/// requesting domain, picks the signing identity, and confirms with the +/// wallet password before the one-time challenge is signed. +const LOGIN_MODAL: &str = "goblin_login_modal"; + +/// A pending, untouched login approval expires after this many seconds (the +/// modal closes and the request is dropped). +const LOGIN_EXPIRY_SECS: u64 = 120; + +/// The login callback POST gives up after this many seconds. +const LOGIN_POST_TIMEOUT_SECS: u64 = 15; + +/// One pending "Sign in with Goblin" approval. Single-use by construction: +/// once the event is signed (`posting`), or on cancel/expiry, the state is +/// dropped and only a fresh URI can start another. +struct LoginState { + /// The validated request (challenge, domain, callback). + uri: crate::nostr::loginuri::LoginUri, + /// The CHOSEN signing identity (pubkey hex); defaults to the active one. + selected: String, + /// When the request arrived, for the [`LOGIN_EXPIRY_SECS`] deadline. + created: std::time::Instant, + /// Wallet password typed into the modal; cleared as soon as consumed. + pass: String, + /// The typed password did not verify: show the wrong-password line. The + /// request itself stays pending (a typo never consumes it). + wrong_pass: bool, + /// The event is signed and the callback POST is in flight; the request is + /// consumed either way once the worker reports back. + posting: bool, + /// Result slot the POST worker thread fills. + result: std::sync::Arc>>>, +} + /// A password-gated identity action the modal executes. Switching no longer uses /// the modal (it is instant and local); only these need the wallet password. #[derive(Clone)] @@ -619,8 +661,14 @@ impl GoblinWalletView { self.settings_page = SettingsPage::Main; self.advanced = AdvancedState::default(); self.identity_switch = IdentitySwitchState::default(); + self.login = None; + self.login_toast = None; } + // Transient login-outcome toast (drawn as a Foreground area, so it rides + // above whatever surface or overlay is showing). + self.login_toast_ui(ui.ctx()); + // A pending payment deep link (`goblin:` / `nostr:` pay URI, routed here // from an OS launch/open) opens a prefilled send-review flow — the exact // destination a scanned checkout QR lands on. A BATCH invoice-request @@ -668,6 +716,76 @@ impl GoblinWalletView { }); } + // A pending "Sign in with Goblin" request (deep link or scanned QR, + // already fully validated at the dispatch site). One approval at a + // time: while one is pending (modal open or POST in flight), any new + // incoming login request is dropped; re-triggering needs a fresh URI. + if let Some(login) = crate::take_pending_login() { + if self.login.is_none() { + if let Some(active) = wallet.active_nostr_pubkey() { + // The approval takes over from any open scan/send flow. + self.send = None; + self.login = Some(LoginState { + uri: login, + selected: active, + created: std::time::Instant::now(), + pass: String::new(), + wrong_pass: false, + posting: false, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + }); + Modal::new(LOGIN_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.login.title")) + .show(); + } + } + } + // An untouched approval dies after 2 minutes: modal closed, request + // dropped. The signed-and-posting phase is governed by the POST + // timeout instead, so it is exempt here. + let login_expired = matches!(&self.login, Some(st) + if !st.posting && st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS); + if login_expired { + self.login = None; + if Modal::opened() == Some(LOGIN_MODAL) { + Modal::close(); + } + } + // Poll the callback POST; the request is consumed either way and the + // outcome shows as a quiet toast (distinct success/failure strings). + let mut login_outcome = None; + if let Some(st) = &self.login { + if st.posting { + login_outcome = st + .result + .lock() + .unwrap() + .take() + .map(|res| (res, st.uri.domain.clone())); + if login_outcome.is_none() { + ui.ctx().request_repaint(); + } + } + } + if let Some((res, domain)) = login_outcome { + let text = match res { + Ok(()) => t!("goblin.login.sent", domain => domain).to_string(), + Err(e) => { + log::warn!("sign-in callback failed: {e}"); + t!("goblin.login.failed", domain => domain).to_string() + } + }; + self.login_toast = Some((text, std::time::Instant::now())); + self.login = None; + } + // The sign-in approval modal itself. + if Modal::opened() == Some(LOGIN_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.login_modal_content(ui, modal, wallet, cb); + }); + } + // Send flow takes the full surface when active. if let Some(send) = &mut self.send { let done = send.ui(ui, wallet, cb, &mut self.avatars); @@ -5881,6 +5999,309 @@ impl GoblinWalletView { } } + /// Content of the "Sign in with Goblin" approval modal: the requesting + /// domain up top, the signing identity (a picker when several are held; + /// the truncated npub is always visible as the anchor), a one-line plain + /// explanation, the wallet password, and uniform paired Cancel / Sign in + /// buttons. Approving signs the one-time kind-22242 challenge with the + /// CHOSEN identity's key and POSTs it to the callback off the UI thread; + /// a wrong password stays in the modal without consuming the request. + fn login_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let Some(st) = self.login.as_mut() else { + Modal::close(); + return; + }; + if st.posting { + // Already signed and in flight; nothing left to gate here. + Modal::close(); + return; + } + let domain = st.uri.domain.clone(); + let identities = wallet.nostr_identities(); + let mut go = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + // Headline with the requesting domain prominent. + ui.label( + RichText::new(t!("goblin.login.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(10.0); + // The signing identity. Display precedence is the switcher's: + // private tag, else bare claimed name, else truncated npub — and + // the truncated npub is ALWAYS shown as the anchor. + ui.label( + RichText::new(t!("goblin.login.identity")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + if identities.len() > 1 { + // Identity picker: the held-identities list, tap to choose + // which one signs. Defaults to the active identity. + for id in &identities { + let selected = st.selected == id.pubkey_hex; + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + let row = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + RichText::new(if selected { + crate::gui::icons::CHECK_CIRCLE + } else { + crate::gui::icons::CIRCLE + }) + .size(18.0) + .color(if selected { + Colors::green() + } else { + Colors::gray() + }), + ); + ui.add_space(8.0); + ui.vertical(|ui| { + if title != short { + ui.label( + RichText::new(&title) + .size(15.0) + .color(Colors::text(false)), + ); + } + ui.label( + RichText::new(&short).size(12.5).color(Colors::gray()), + ); + }); + }); + }) + .response + .rect; + let hit = ui.interact( + row, + egui::Id::from(modal.id).with(("login_id", id.pubkey_hex.as_str())), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.selected = id.pubkey_hex.clone(); + } + ui.add_space(4.0); + } + } else if let Some(id) = identities.first() { + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + if title != short { + ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); + } + ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); + } + ui.add_space(10.0); + // One plain line on what approving does (and does not do). + ui.label( + RichText::new(t!("goblin.login.explain")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + // The wallet password gates the signature, mirroring the identity + // password modal (masked field, same wrong-password line). + ui.label( + RichText::new(t!("goblin.login.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("login_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if field.enter_pressed { + go = true; + } + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.wrong_pass { + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.advanced.wrong_password")) + .size(16.0) + .color(Colors::red()), + ); + } + ui.add_space(12.0); + }); + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + cancel = true; + }, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.login.confirm"), + Colors::white_or_black(false), + || { + go = true; + }, + ); + }); + }); + ui.add_space(6.0); + }); + if cancel { + // Cancel drops the request: it is single-use, no retry. + self.login = None; + Modal::close(); + return; + } + if go { + let (pass, selected) = match self.login.as_ref() { + Some(st) => (st.pass.clone(), st.selected.clone()), + None => return, + }; + if pass.is_empty() { + return; + } + if !wallet.verify_nostr_password(&pass) { + // Wrong password: stay in the modal, request NOT consumed. + if let Some(st) = self.login.as_mut() { + st.wrong_pass = true; + } + return; + } + // The chosen identity's unlocked in-memory keys, from the running + // service (the Build-145 model: every held identity is unlocked). + let keys = wallet.nostr_service().and_then(|s| { + s.recv_snapshot() + .into_iter() + .find(|h| h.keys.public_key().to_hex() == selected) + .map(|h| h.keys) + }); + let Some(keys) = keys else { + // No running service / identity gone: drop the request. + self.login = None; + Modal::close(); + return; + }; + let st = self.login.as_mut().unwrap(); + st.pass.clear(); + st.wrong_pass = false; + match crate::nostr::loginuri::build_login_event( + &keys, + &st.uri.challenge, + &st.uri.domain, + ) { + Ok(event) => { + // Signed: the request is consumed from here on, whatever + // the POST outcome. Deliver off the UI thread with the + // shared HTTP client and a hard timeout. + st.posting = true; + let callback = st.uri.callback.clone(); + let slot = st.result.clone(); + std::thread::spawn(move || { + let res = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(async { + let post = + crate::nostr::loginuri::post_login_event(&callback, &event); + match tokio::time::timeout( + std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), + post, + ) + .await + { + Ok(r) => r, + Err(_) => Err("timeout".to_string()), + } + }), + Err(e) => Err(e.to_string()), + }; + *slot.lock().unwrap() = Some(res); + }); + Modal::close(); + } + Err(e) => { + // Signing failed (never expected): consume the request and + // surface the quiet failure toast. + log::error!("login event signing failed: {e}"); + self.login_toast = Some(( + t!("goblin.login.failed", domain => domain).to_string(), + std::time::Instant::now(), + )); + self.login = None; + Modal::close(); + } + } + } + } + + /// Draw the transient login-outcome toast: the same quiet pill as the back + /// hint (solid soft pill, no border, small dim text), bottom-anchored, + /// non-blocking, fading out. + fn login_toast_ui(&mut self, ctx: &egui::Context) { + const SHOW_SECS: f32 = 3.5; + let Some((text, at)) = &self.login_toast else { + return; + }; + let elapsed = at.elapsed().as_secs_f32(); + if elapsed >= SHOW_SECS { + self.login_toast = None; + return; + } + let text = text.clone(); + let t = theme::tokens(); + // Fade over the final 0.5s. + let alpha = ((SHOW_SECS - elapsed) / 0.5).clamp(0.0, 1.0); + let font = FontId::new(13.0, fonts::regular()); + egui::Area::new(egui::Id::new("goblin_login_toast")) + .order(egui::Order::Foreground) + .anchor( + egui::Align2::CENTER_BOTTOM, + Vec2::new(0.0, -(View::get_bottom_inset() + 92.0)), + ) + .interactable(false) + .show(ctx, |ui| { + let galley = ui + .painter() + .layout_no_wrap(text, font.clone(), t.surface_text_dim); + let pad = Vec2::new(16.0, 10.0); + let size = galley.size() + pad * 2.0; + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter().rect( + rect, + CornerRadius::same((size.y / 2.0) as u8), + t.surface2.gamma_multiply(alpha), + Stroke::NONE, + egui::StrokeKind::Inside, + ); + ui.painter().galley( + rect.min + pad, + galley, + t.surface_text_dim.gamma_multiply(alpha), + ); + }); + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + /// Inline username-claim widget (availability check + registration). fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 05605091..6c9c5ac1 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -239,6 +239,17 @@ impl SendFlow { /// handler so both spell the same destination. `now` seeds the search-box /// debounce. fn apply_scan(&mut self, text: &str, wallet: &Wallet, now: f64) { + // A "Sign in with Goblin" login URI is grabbed BEFORE any pay parsing + // (mirroring the deep-link dispatcher): it is never a payment, and a + // login-shaped payload that fails validation is dropped entirely. A + // valid one is stashed for the Goblin surface, whose per-frame router + // closes this flow and opens the approval modal. + if crate::nostr::loginuri::is_login_shaped(text) { + if let Some(login) = crate::nostr::loginuri::parse(text) { + crate::set_pending_login(login); + } + return; + } // Proof-on-request context (frozen contract 4.1) rides alongside the // recipient/amount/memo routing and is independent of whether we land on // Review or Search, so pull it from the same pure parse. `order` is a diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index bbfe733e..d2b4ec50 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -491,11 +491,24 @@ impl WalletsContent { if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() { Content::toggle_network_panel(); } - // Route a Goblin payment deep link (`goblin:` / `nostr:` pay URI) to the - // send-review flow instead of the slatepack message handler: stash it for - // the Goblin surface to open, and select/open the wallet with NO message - // so the surface shows. Same destination as scanning a checkout QR. + // Dispatch order is explicit: the `login` keyword is checked FIRST, so a + // `goblin:login?...` deep link can never fall through to the pay path + // (and a pay URI is never login-shaped: an npub/nprofile recipient + // always starts `npub1`/`nprofile1`). A login-shaped URI that fails + // validation is dropped entirely: no modal, no send, no message. + // + // Then a Goblin payment deep link (`goblin:` / `nostr:` pay URI) routes + // to the send-review flow instead of the slatepack message handler: + // stash it for the Goblin surface to open, and select/open the wallet + // with NO message so the surface shows. Same destination as scanning a + // checkout QR. let data = match data { + Some(d) if crate::nostr::loginuri::is_login_shaped(&d) => { + if let Some(login) = crate::nostr::loginuri::parse(&d) { + crate::set_pending_login(login); + } + None + } Some(d) if crate::nostr::payuri::is_pay_uri(&d) => { crate::set_pending_pay_uri(d); None diff --git a/src/lib.rs b/src/lib.rs index 6e025d42..921862da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -559,6 +559,13 @@ lazy_static! { /// slatepack message handler. Consumed by the Goblin view once a wallet is /// open and showing. static ref PENDING_PAY_URI: Arc>> = Arc::new(RwLock::new(None)); + /// A pending, already-VALIDATED "Sign in with Goblin" login request + /// (`goblin:login?...` deep link or QR), waiting for the Goblin surface to + /// open its approval modal. Only a fully validated [`nostr::loginuri::LoginUri`] + /// is ever stashed here; rejected login URIs are dropped at the dispatch + /// site and never reach the UI. + static ref PENDING_LOGIN: Arc>> = + Arc::new(RwLock::new(None)); } /// Stash a payment deep link for the Goblin surface to open (see @@ -573,6 +580,19 @@ pub fn take_pending_pay_uri() -> Option { PENDING_PAY_URI.write().take() } +/// Stash a VALIDATED login request for the Goblin surface to open its approval +/// modal (see [`take_pending_login`]). The most recent request wins here; the +/// surface itself ignores new requests while one approval is already pending. +pub fn set_pending_login(login: nostr::loginuri::LoginUri) { + *PENDING_LOGIN.write() = Some(login); +} + +/// Take (and clear) a pending login request, if any. The Goblin wallet view +/// polls this each frame and opens the sign-in approval modal for it. +pub fn take_pending_login() -> Option { + PENDING_LOGIN.write().take() +} + /// Callback from Java code with passed data. #[allow(dead_code)] #[allow(non_snake_case)] diff --git a/src/nostr/loginuri.rs b/src/nostr/loginuri.rs new file mode 100644 index 00000000..556692ec --- /dev/null +++ b/src/nostr/loginuri.rs @@ -0,0 +1,445 @@ +// 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. + +//! "Sign in with Goblin" login-URI parser and one-time login-event signer. +//! +//! A site (magick.market) asks the wallet to approve a one-time login by +//! handing it a challenge over either the `goblin:` deep-link scheme or the +//! equivalent `nostr:` QR payload: +//! +//! ```text +//! goblin:login?c=<64-hex nonce>&d=&cb= +//! nostr:login?c=<64-hex nonce>&d=&cb= +//! ``` +//! +//! On approval the wallet signs a kind-22242 event (content empty, tags +//! `[["challenge", c], ["domain", d]]`) with the CHOSEN identity's key and +//! POSTs it to the callback as `{"event": }`. The signed event +//! proves control of the key for this one nonce; it shares no secret and +//! grants the site no capability to act as the user. +//! +//! Parsing is PURE and fail-closed over UNTRUSTED input, mirroring +//! [`crate::nostr::payuri`] (same scheme handling, same percent-decoding, +//! first occurrence of a duplicate param wins). A login-shaped URI that fails +//! validation is REJECTED as a whole — it is never fed to the pay path and it +//! never opens an approval modal. The `login` keyword cannot collide with a +//! pay recipient: a bech32 npub/nprofile always starts `npub1`/`nprofile1`. + +use super::payuri::{percent_decode, strip_pay_scheme}; +use nostr_sdk::{Event, EventBuilder, Keys, Kind, Tag, TagKind}; + +/// Total payload byte cap, same bar as the pay-URI parser. +const MAX_URI_LEN: usize = 4096; +/// Domain byte cap (a DNS name is at most 253 bytes). +const MAX_DOMAIN_LEN: usize = 253; +/// Callback URL byte cap. +const MAX_CALLBACK_LEN: usize = 2048; + +/// The nostr event kind of a signed login challenge (NIP-42 client auth). +pub const LOGIN_EVENT_KIND: u16 = 22242; + +/// A validated login request: the one-time challenge nonce, the requesting +/// domain the user approves, and the HTTPS callback the signed event is +/// POSTed to. Only constructed by [`parse`], so holding one means every field +/// already passed validation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoginUri { + /// The one-time challenge nonce, exactly 64 hex chars. + pub challenge: String, + /// The requesting domain, shown to the user for approval. + pub domain: String, + /// The callback URL the signed event is delivered to: `https://...`, or + /// `http://localhost[:port]...` for development. + pub callback: String, +} + +/// True when `scanned` carries a Goblin scheme with the `login` keyword, i.e. +/// it is a login request (valid or not) and must NEVER be fed to the pay +/// path. The dispatcher checks this BEFORE [`super::payuri::is_pay_uri`]; a +/// shaped-but-invalid login URI is then dropped entirely (no modal, no send). +pub fn is_login_shaped(scanned: &str) -> bool { + let text = scanned.trim(); + if text.len() > MAX_URI_LEN { + return false; + } + match strip_pay_scheme(text) { + Some(rest) => { + let head = rest.split('?').next().unwrap_or(""); + head.eq_ignore_ascii_case("login") + } + None => false, + } +} + +/// Parse a login URI. `Some` only when EVERY field validates: `c` is exactly +/// 64 hex chars, `d` is non-empty, and `cb` is `https://` (or +/// `http://localhost[:port]` for dev). Anything else is `None` and the whole +/// request is ignored. Pure, total, no I/O. +pub fn parse(scanned: &str) -> Option { + let text = scanned.trim(); + if text.len() > MAX_URI_LEN || text.as_bytes().contains(&0) { + return None; + } + let rest = strip_pay_scheme(text)?; + let (head, query) = rest.split_once('?')?; + if !head.eq_ignore_ascii_case("login") { + return None; + } + let mut challenge = None; + let mut domain = None; + let mut callback = None; + for pair in query.split('&') { + let Some((key, val)) = pair.split_once('=') else { + continue; // valueless / malformed segment, ignore + }; + match key { + // First occurrence wins, matching the pay-URI convention, so a + // second `cb=` can never override a validated one. + "c" if challenge.is_none() => challenge = Some(val), + "d" if domain.is_none() => domain = Some(val), + "cb" if callback.is_none() => callback = Some(val), + // Unknown params are ignored for forward-compat. + _ => {} + } + } + let challenge = validate_challenge(challenge?)?; + let domain = validate_domain(domain?)?; + let callback = validate_callback(callback?)?; + Some(LoginUri { + challenge, + domain, + callback, + }) +} + +/// The challenge nonce must be exactly 64 hex chars (a 32-byte value): wrong +/// length or any non-hex char rejects the whole URI. +fn validate_challenge(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + if decoded.len() == 64 && decoded.chars().all(|c| c.is_ascii_hexdigit()) { + Some(decoded) + } else { + None + } +} + +/// The domain must be non-empty, printable ASCII without spaces, and within +/// DNS length bounds. It is DISPLAY data (the user approves it by eye) and a +/// tag value, never a route, so a shape check is enough. +fn validate_domain(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.is_empty() + || decoded.len() > MAX_DOMAIN_LEN + || !decoded.chars().all(|c| c.is_ascii_graphic()) + { + return None; + } + Some(decoded.to_string()) +} + +/// The callback must be `https://...`, or `http://localhost[:port]...` as the +/// one development exception. Everything else (plain http, ftp, garbage) +/// rejects the whole URI so a signed event can never be posted somewhere +/// unexpected in the clear. +fn validate_callback(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.len() > MAX_CALLBACK_LEN || decoded.bytes().any(|b| b < 0x20 || b == 0x7f) { + return None; + } + if let Some(rest) = strip_prefix_ignore_case(decoded, "https://") { + if !rest.is_empty() { + return Some(decoded.to_string()); + } + return None; + } + if let Some(rest) = strip_prefix_ignore_case(decoded, "http://") { + if is_localhost_authority(rest) { + return Some(decoded.to_string()); + } + } + None +} + +/// Strip a case-insensitive ASCII prefix. +fn strip_prefix_ignore_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let n = prefix.len(); + match s.get(..n) { + Some(head) if head.eq_ignore_ascii_case(prefix) => Some(&s[n..]), + _ => None, + } +} + +/// True when the URL remainder after `http://` names exactly `localhost`, +/// optionally with a `:port` (a valid non-zero u16), followed by nothing or a +/// `/ ? #` delimiter. `localhost.evil.com` and friends do NOT pass. +fn is_localhost_authority(rest: &str) -> bool { + let authority_end = rest + .find(|c| c == '/' || c == '?' || c == '#') + .unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let (host, port) = match authority.split_once(':') { + Some((h, p)) => (h, Some(p)), + None => (authority, None), + }; + if !host.eq_ignore_ascii_case("localhost") { + return false; + } + match port { + None => true, + Some(p) => { + !p.is_empty() + && p.chars().all(|c| c.is_ascii_digit()) + && p.parse::().map(|n| n > 0).unwrap_or(false) + } + } +} + +/// Build and sign the one-time login event with the CHOSEN identity's keys: +/// kind 22242, empty content, tags exactly +/// `[["challenge", challenge], ["domain", domain]]`, `created_at` now. The +/// signature proves control of the key for this one nonce; nothing secret +/// leaves the wallet. +pub fn build_login_event(keys: &Keys, challenge: &str, domain: &str) -> Result { + EventBuilder::new(Kind::Custom(LOGIN_EVENT_KIND), "") + .tags(vec![ + Tag::custom(TagKind::custom("challenge"), [challenge.to_string()]), + Tag::custom(TagKind::custom("domain"), [domain.to_string()]), + ]) + .sign_with_keys(keys) + .map_err(|e| e.to_string()) +} + +/// POST the signed login event to the callback as `{"event": }`. +/// Goes through the app's shared [`crate::http::HttpClient`], so it follows +/// the exact same transport policy (proxy settings included) as every other +/// clearnet call. The caller wraps this in its own timeout. +pub async fn post_login_event(callback: &str, event: &Event) -> Result<(), String> { + let body = serde_json::json!({ "event": event }).to_string(); + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(callback) + .header("Content-Type", "application/json") + .header("User-Agent", "goblin-wallet") + .body(http_body_util::Full::new(bytes::Bytes::from(body))) + .map_err(|e| e.to_string())?; + let resp = crate::http::HttpClient::send(req) + .await + .map_err(|e| e.to_string())?; + let status = resp.status().as_u16(); + if (200..300).contains(&status) { + Ok(()) + } else { + Err(format!("callback returned status {status}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A well-formed 64-hex challenge nonce. + const C: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + /// A real, valid bech32 npub (the Goblin news key), for the pay-vs-login + /// dispatch tests. + const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely"; + + fn valid_uri(scheme: &str) -> String { + format!("{scheme}login?c={C}&d=magick.market&cb=https://magick.market/api/login") + } + + #[test] + fn valid_goblin_and_nostr_login_accepted() { + for scheme in ["goblin:", "nostr:"] { + let out = parse(&valid_uri(scheme)).expect("valid login URI must parse"); + assert_eq!(out.challenge, C); + assert_eq!(out.domain, "magick.market"); + assert_eq!(out.callback, "https://magick.market/api/login"); + } + } + + #[test] + fn scheme_and_keyword_case_insensitive() { + let uri = format!("GOBLIN:LOGIN?c={C}&d=magick.market&cb=https://magick.market/cb"); + let out = parse(&uri).expect("uppercase scheme/keyword must parse"); + assert_eq!(out.domain, "magick.market"); + assert!(is_login_shaped(&uri)); + } + + #[test] + fn challenge_must_be_exactly_64_hex() { + // Wrong length: 63, 65, empty. + for bad in [&C[..63], &format!("{C}0")[..], ""] { + let uri = format!("goblin:login?c={bad}&d=magick.market&cb=https://m.m/cb"); + assert_eq!(parse(&uri), None, "expected c={bad:?} to be rejected"); + } + // Right length, non-hex chars. + let bad = format!("{}zz", &C[..62]); + let uri = format!("goblin:login?c={bad}&d=magick.market&cb=https://m.m/cb"); + assert_eq!(parse(&uri), None, "non-hex challenge must be rejected"); + } + + #[test] + fn empty_domain_rejected() { + let uri = format!("goblin:login?c={C}&d=&cb=https://magick.market/cb"); + assert_eq!(parse(&uri), None); + // Whitespace-only after decode is also empty. + let uri = format!("goblin:login?c={C}&d=%20%20&cb=https://magick.market/cb"); + assert_eq!(parse(&uri), None); + } + + #[test] + fn missing_params_rejected() { + assert_eq!( + parse(&format!("goblin:login?d=m.m&cb=https://m.m/cb")), + None + ); + assert_eq!( + parse(&format!("goblin:login?c={C}&cb=https://m.m/cb")), + None + ); + assert_eq!(parse(&format!("goblin:login?c={C}&d=m.m")), None); + assert_eq!(parse("goblin:login"), None); + assert_eq!(parse("goblin:login?"), None); + } + + #[test] + fn non_localhost_http_callback_rejected() { + for bad in [ + "http://magick.market/cb", + "http://localhost.evil.com/cb", + "http://evillocalhost/cb", + "http://localhost:0/cb", + "http://localhost:99999/cb", + "http://localhost:12a/cb", + ] { + let uri = format!("goblin:login?c={C}&d=magick.market&cb={bad}"); + assert_eq!(parse(&uri), None, "expected cb={bad:?} to be rejected"); + } + } + + #[test] + fn ftp_and_garbage_callback_rejected() { + for bad in [ + "ftp://magick.market/cb", + "javascript:alert(1)", + "m.m/cb", + "", + ] { + let uri = format!("goblin:login?c={C}&d=magick.market&cb={bad}"); + assert_eq!(parse(&uri), None, "expected cb={bad:?} to be rejected"); + } + } + + #[test] + fn localhost_callback_accepted_for_dev() { + for ok in [ + "http://localhost:3000/api/login", + "http://localhost/cb", + "http://localhost:3000", + ] { + let uri = format!("goblin:login?c={C}&d=magick.market&cb={ok}"); + let out = parse(&uri).unwrap_or_else(|| panic!("expected cb={ok:?} accepted")); + assert_eq!(out.callback, ok); + } + } + + #[test] + fn percent_encoded_callback_decoded() { + let cb = "https%3A%2F%2Fmagick.market%2Fapi%2Flogin%3Fs%3D1"; + let uri = format!("goblin:login?c={C}&d=magick.market&cb={cb}"); + let out = parse(&uri).expect("encoded cb must parse"); + assert_eq!(out.callback, "https://magick.market/api/login?s=1"); + } + + #[test] + fn duplicate_params_first_wins() { + let uri = format!( + "goblin:login?c={C}&c={}&d=magick.market&d=evil.com&cb=https://magick.market/cb&cb=https://evil.com/cb", + "f".repeat(64) + ); + let out = parse(&uri).expect("must parse"); + assert_eq!(out.challenge, C); + assert_eq!(out.domain, "magick.market"); + assert_eq!(out.callback, "https://magick.market/cb"); + } + + #[test] + fn pay_uri_is_not_login_and_login_is_not_pay() { + // Dispatch contract: the router checks is_login_shaped() FIRST, then + // falls through to the pay path. A pay URI must never look + // login-shaped, and a login URI must never be classified as anything + // but login (valid or dropped). + let pay = format!("goblin:{NPUB}?amount=1.5"); + assert!(!is_login_shaped(&pay)); + assert_eq!(parse(&pay), None); + // The pay parser still handles it exactly as before. + let parsed = crate::nostr::payuri::parse(&pay); + assert_eq!(parsed.recipient, NPUB); + assert_eq!(parsed.amount.as_deref(), Some("1.5")); + + // A login URI IS login-shaped (so the router grabs it before the pay + // path can see it), whether or not its params validate. + let login = valid_uri("goblin:"); + assert!(is_login_shaped(&login)); + assert!(parse(&login).is_some()); + let broken = "goblin:login?c=nothex&d=m.m&cb=https://m.m/cb"; + assert!(is_login_shaped(broken)); + assert_eq!(parse(broken), None); + // Non-goblin schemes and bare payloads are not login-shaped at all. + assert!(!is_login_shaped("bitcoin:login?c=x")); + assert!(!is_login_shaped("login?c=x")); + assert!(!is_login_shaped("")); + } + + #[test] + fn whitespace_trimmed_and_oversize_rejected() { + let uri = format!(" {} ", valid_uri("nostr:")); + assert!(parse(&uri).is_some()); + let huge = format!( + "goblin:login?c={C}&d=magick.market&cb=https://m.m/{}", + "a".repeat(5000) + ); + assert_eq!(parse(&huge), None); + assert!(!is_login_shaped(&huge)); + } + + #[test] + fn login_event_signed_by_the_chosen_identity() { + // Two held identities; the user picks the NON-active one. The event + // must verify against exactly that key. + let active = Keys::generate(); + let chosen = Keys::generate(); + let before = nostr_sdk::Timestamp::now().as_u64(); + let event = build_login_event(&chosen, C, "magick.market").expect("sign"); + let after = nostr_sdk::Timestamp::now().as_u64(); + + assert_eq!(event.kind.as_u16(), LOGIN_EVENT_KIND); + assert_eq!(event.content, ""); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!( + tags, + vec![ + vec!["challenge".to_string(), C.to_string()], + vec!["domain".to_string(), "magick.market".to_string()], + ] + ); + let ts = event.created_at.as_u64(); + assert!(ts >= before && ts <= after, "created_at must be now"); + assert!(event.verify().is_ok(), "signature must verify"); + assert_eq!(event.pubkey, chosen.public_key()); + assert_ne!(event.pubkey, active.public_key()); + } +} diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 53b4bde8..3f2c820e 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -48,4 +48,5 @@ pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase}; pub mod avatar; pub mod nip05; +pub mod loginuri; pub mod payuri; diff --git a/src/nostr/payuri.rs b/src/nostr/payuri.rs index 7428e4af..1b441677 100644 --- a/src/nostr/payuri.rs +++ b/src/nostr/payuri.rs @@ -189,8 +189,10 @@ fn validate_count(raw: &str) -> Option { /// Strip a case-insensitive payment scheme prefix (`goblin:` or `nostr:`), /// returning the remainder. Byte-safe against a leading multibyte char (the -/// `text.get(..n)` guards against a `[..n]` slice panic). -fn strip_pay_scheme(text: &str) -> Option<&str> { +/// `text.get(..n)` guards against a `[..n]` slice panic). Crate-visible so the +/// login-URI parser (see [`crate::nostr::loginuri`]) shares the exact same +/// scheme handling. +pub(crate) fn strip_pay_scheme(text: &str) -> Option<&str> { for scheme in PAY_SCHEMES { let n = scheme.len(); if let Some(head) = text.get(..n) { @@ -348,7 +350,8 @@ fn truncate_on_char_boundary(s: String, max: usize) -> String { /// 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 { +/// Crate-visible so the login-URI parser decodes identically. +pub(crate) fn percent_decode(s: &str) -> Vec { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0;