From d4cc7dbe98dab495dd4e110d09a0c94f0c603b80 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:34:03 -0400 Subject: [PATCH] goblin: Authorize, one-shot site-requested event signing with per-event approval (Build 151) goblin:authorize?e=&d=&cb=&c=<64-hex nonce> (and the nostr: QR twin) asks the wallet to sign exactly one Nostr event. Fail-closed parser beside loginuri: strict three-key template (kind, content, tags; any pubkey/created_at/id/sig injection rejects), kind allowlist 1/6/7/30023 (22242 only via login), 4096/2048 size caps, unpadded base64url only, delegation tags rejected, and the new cb-host-must-belong-to-d binding. Approval modal mirrors the 150 login machinery: Authorize ? verb, plain-language kind rendering, 240-char escaped preview with truncation marker and show-full view, identity picker, wallet password gate, single pending request, 120 s expiry, single-use. Wallet alone sets pubkey/created_at/id/sig, NIP-01 canonical only; delivery POSTs {c, d, event} to cb with the shared HttpClient and 15 s timeout, consumed regardless of outcome, quiet toasts. Dispatchers check authorize before pay on both the deep-link and QR paths. goblin.authorize.* strings in all six locales; 29 new authuri tests. --- locales/de.yml | 24 + locales/en.yml | 24 + locales/fr.yml | 24 + locales/ru.yml | 24 + locales/tr.yml | 24 + locales/zh-CN.yml | 24 + src/gui/views/goblin/mod.rs | 537 ++++++++++++++- src/gui/views/goblin/send.rs | 11 + src/gui/views/wallets/content.rs | 11 + src/lib.rs | 22 + src/nostr/authuri.rs | 1068 ++++++++++++++++++++++++++++++ src/nostr/mod.rs | 1 + 12 files changed, 1792 insertions(+), 2 deletions(-) create mode 100644 src/nostr/authuri.rs diff --git a/locales/de.yml b/locales/de.yml index 640fb05e..cfda8e6b 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -692,6 +692,30 @@ goblin: confirm: "Anmelden" sent: "Bei %{domain} angemeldet" failed: "%{domain} war nicht erreichbar. Die Anmeldung wurde nicht zugestellt." + authorize: + title: "Autorisieren" + headline: "%{domain} autorisieren?" + kind_post: "Öffentlicher Beitrag" + kind_repost: "Teilen" + kind_reaction: "Reaktion" + kind_article: "Artikel (Langform)" + kind_unknown: "Art %{n} (unbekannt)" + unknown_caution: "Goblin kennt diesen Ereignistyp nicht. Nur bestätigen, wenn du %{domain} vertraust." + identity: "Signieren als" + explain: "Beim Bestätigen wird dieses eine Ereignis signiert und an %{domain} übergeben. Goblin veröffentlicht nichts, teilt keinen Schlüssel, und die Seite erhält keine Sitzung und keine künftige Berechtigung." + pass_prompt: "Gib dein Wallet-Passwort ein, um zu autorisieren." + confirm: "Autorisieren" + sent: "%{domain} autorisiert" + failed: "%{domain} war nicht erreichbar. Es wurde nichts zugestellt." + truncated: "gekürzt, %{n} weitere Zeichen" + show_full: "Vollständig anzeigen" + show_less: "Weniger anzeigen" + replying_to: "Antwort auf %{id}" + mentions: "erwähnt %{id}" + repost_of: "teilt %{id}" + by_author: "von %{id}" + reacts_to: "reagiert auf %{id}" + article_title: "Titel: %{title}" identities: title: "Identitäten" switch_hint: "Identität wechseln" diff --git a/locales/en.yml b/locales/en.yml index 14f8b84c..a6a39e6f 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -692,6 +692,30 @@ goblin: confirm: "Sign in" sent: "Signed in to %{domain}" failed: "Could not reach %{domain}. The sign-in was not delivered." + authorize: + title: "Authorize" + headline: "Authorize %{domain}?" + kind_post: "Public post" + kind_repost: "Repost" + kind_reaction: "Reaction" + kind_article: "Article (long-form)" + kind_unknown: "Kind %{n} (unrecognized)" + unknown_caution: "Goblin does not recognize this event type. Only approve if you trust %{domain}." + identity: "Signing as" + explain: "Approving signs this one event and hands it to %{domain}. Nothing is published by Goblin, no key is shared, and the site gets no session or future capability." + pass_prompt: "Enter your wallet password to authorize." + confirm: "Authorize" + sent: "Authorized %{domain}" + failed: "Could not reach %{domain}. Nothing was delivered." + truncated: "truncated, %{n} more characters" + show_full: "Show full" + show_less: "Show less" + replying_to: "replying to %{id}" + mentions: "mentions %{id}" + repost_of: "reposts %{id}" + by_author: "by %{id}" + reacts_to: "reacts to %{id}" + article_title: "Title: %{title}" identities: title: "Identities" switch_hint: "Switch identity" diff --git a/locales/fr.yml b/locales/fr.yml index deaf99c2..0f7f5fdb 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -692,6 +692,30 @@ goblin: confirm: "Se connecter" sent: "Connecté à %{domain}" failed: "Impossible de joindre %{domain}. La connexion n'a pas été transmise." + authorize: + title: "Autoriser" + headline: "Autoriser %{domain} ?" + kind_post: "Publication publique" + kind_repost: "Partage" + kind_reaction: "Réaction" + kind_article: "Article (format long)" + kind_unknown: "Type %{n} (non reconnu)" + unknown_caution: "Goblin ne reconnaît pas ce type d'événement. N'approuvez que si vous faites confiance à %{domain}." + identity: "Signer en tant que" + explain: "Approuver signe cet unique événement et le transmet à %{domain}. Goblin ne publie rien, aucune clé n'est partagée, et le site n'obtient ni session ni capacité future." + pass_prompt: "Saisissez le mot de passe de votre portefeuille pour autoriser." + confirm: "Autoriser" + sent: "%{domain} autorisé" + failed: "Impossible de joindre %{domain}. Rien n'a été transmis." + truncated: "tronqué, %{n} caractères de plus" + show_full: "Tout afficher" + show_less: "Afficher moins" + replying_to: "en réponse à %{id}" + mentions: "mentionne %{id}" + repost_of: "partage %{id}" + by_author: "par %{id}" + reacts_to: "réagit à %{id}" + article_title: "Titre : %{title}" identities: title: "Identités" switch_hint: "Changer d'identité" diff --git a/locales/ru.yml b/locales/ru.yml index 3ec33304..7b489909 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -692,6 +692,30 @@ goblin: confirm: "Войти" sent: "Выполнен вход на %{domain}" failed: "Не удалось связаться с %{domain}. Вход не был выполнен." + authorize: + title: "Авторизовать" + headline: "Авторизовать %{domain}?" + kind_post: "Публичная запись" + kind_repost: "Репост" + kind_reaction: "Реакция" + kind_article: "Статья (лонгрид)" + kind_unknown: "Тип %{n} (не распознан)" + unknown_caution: "Goblin не распознаёт этот тип события. Подтверждайте, только если доверяете %{domain}." + identity: "Подписать как" + explain: "Подтверждение подпишет это одно событие и передаст его %{domain}. Goblin ничего не публикует, ключ не передаётся, и сайт не получает ни сессии, ни будущих полномочий." + pass_prompt: "Введите пароль кошелька, чтобы авторизовать." + confirm: "Авторизовать" + sent: "%{domain} авторизован" + failed: "Не удалось связаться с %{domain}. Ничего не было передано." + truncated: "обрезано, ещё %{n} символов" + show_full: "Показать полностью" + show_less: "Свернуть" + replying_to: "в ответ на %{id}" + mentions: "упоминает %{id}" + repost_of: "репост %{id}" + by_author: "автор %{id}" + reacts_to: "реакция на %{id}" + article_title: "Заголовок: %{title}" identities: title: "Личности" switch_hint: "Сменить личность" diff --git a/locales/tr.yml b/locales/tr.yml index 98db99fa..b16e2789 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -692,6 +692,30 @@ goblin: confirm: "Giriş yap" sent: "%{domain} girişi yapıldı" failed: "%{domain} adresine ulaşılamadı. Giriş iletilemedi." + authorize: + title: "Yetkilendir" + headline: "%{domain} yetkilendirilsin mi?" + kind_post: "Herkese açık gönderi" + kind_repost: "Yeniden paylaşım" + kind_reaction: "Tepki" + kind_article: "Makale (uzun biçim)" + kind_unknown: "Tür %{n} (tanınmıyor)" + unknown_caution: "Goblin bu olay türünü tanımıyor. Yalnızca %{domain} adresine güveniyorsan onayla." + identity: "İmzalayan kimlik" + explain: "Onaylamak bu tek olayı imzalar ve %{domain} adresine verir. Goblin hiçbir şey yayımlamaz, hiçbir anahtar paylaşılmaz ve site oturum ya da gelecekteki bir yetki elde etmez." + pass_prompt: "Yetkilendirmek için cüzdan parolanı gir." + confirm: "Yetkilendir" + sent: "%{domain} yetkilendirildi" + failed: "%{domain} adresine ulaşılamadı. Hiçbir şey iletilmedi." + truncated: "kısaltıldı, %{n} karakter daha" + show_full: "Tamamını göster" + show_less: "Daha az göster" + replying_to: "%{id} yanıtlanıyor" + mentions: "%{id} anılıyor" + repost_of: "%{id} yeniden paylaşılıyor" + by_author: "yazan %{id}" + reacts_to: "%{id} tepki veriliyor" + article_title: "Başlık: %{title}" identities: title: "Kimlikler" switch_hint: "Kimlik değiştir" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 38a3d384..7c30f71f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -692,6 +692,30 @@ goblin: confirm: "登录" sent: "已登录 %{domain}" failed: "无法连接 %{domain},登录未送达。" + authorize: + title: "授权" + headline: "授权 %{domain}?" + kind_post: "公开帖子" + kind_repost: "转发" + kind_reaction: "回应" + kind_article: "文章(长文)" + kind_unknown: "类型 %{n}(无法识别)" + unknown_caution: "Goblin 无法识别此事件类型。仅在你信任 %{domain} 时才批准。" + identity: "签名身份" + explain: "批准将签署这一个事件并交给 %{domain}。Goblin 不会发布任何内容,不会分享密钥,网站也不会获得会话或未来的权限。" + pass_prompt: "输入钱包密码以授权。" + confirm: "授权" + sent: "已授权 %{domain}" + failed: "无法连接 %{domain},未送达任何内容。" + truncated: "已截断,还有 %{n} 个字符" + show_full: "显示全部" + show_less: "收起" + replying_to: "回复 %{id}" + mentions: "提及 %{id}" + repost_of: "转发 %{id}" + by_author: "作者 %{id}" + reacts_to: "回应 %{id}" + article_title: "标题:%{title}" identities: title: "身份" switch_hint: "切换身份" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 4fe617cb..15a4e79f 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -141,7 +141,13 @@ pub struct GoblinWalletView { /// 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. + /// An "Authorize with Goblin" request awaiting approval (deep link or scanned + /// QR): the wallet signs one arbitrary event and hands it to the site. While + /// this OR `login` is `Some`, every new incoming authorize/login URI is + /// ignored (one approval at a time). + authorize: Option, + /// Quiet toast for the login outcome: text and when it appeared. Reused for + /// the authorize outcome (same quiet pill, different strings). login_toast: Option<(String, std::time::Instant)>, } @@ -276,6 +282,7 @@ impl Default for GoblinWalletView { back_hint: None, batch_invoice: None, login: None, + authorize: None, login_toast: None, } } @@ -361,6 +368,12 @@ const BATCH_INVOICE_MODAL: &str = "goblin_batch_invoice_modal"; /// wallet password before the one-time challenge is signed. const LOGIN_MODAL: &str = "goblin_login_modal"; +/// Id of the "Authorize with Goblin" approval modal: the user reviews the +/// requesting domain and the exact event to be signed, picks the signing +/// identity, and confirms with the wallet password before the one event is +/// signed and handed to the site. Shares the login expiry and POST timeout. +const AUTHORIZE_MODAL: &str = "goblin_authorize_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; @@ -390,6 +403,32 @@ struct LoginState { result: std::sync::Arc>>>, } +/// One pending "Authorize with Goblin" approval. Mirrors [`LoginState`] +/// field-for-field, plus a `show_full` toggle for the complete-content view. +/// 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 AuthorizeState { + /// The validated request (challenge, domain, callback, event template). + uri: crate::nostr::authuri::AuthorizeUri, + /// 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, + /// Whether the complete (escaped) content is expanded in a scrollable view. + /// Approval is never blocked on it; it only reveals what truncation hid. + show_full: 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)] @@ -662,6 +701,7 @@ impl GoblinWalletView { self.advanced = AdvancedState::default(); self.identity_switch = IdentitySwitchState::default(); self.login = None; + self.authorize = None; self.login_toast = None; } @@ -721,7 +761,7 @@ impl GoblinWalletView { // 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 self.login.is_none() && self.authorize.is_none() { if let Some(active) = wallet.active_nostr_pubkey() { // The approval takes over from any open scan/send flow. self.send = None; @@ -786,6 +826,79 @@ impl GoblinWalletView { }); } + // A pending "Authorize with Goblin" request (deep link or scanned QR, + // already fully validated at the dispatch site: kind on the allowlist, + // template shape and domain binding checked). One approval at a time: + // while a login OR an authorize is pending, any new incoming request is + // dropped; re-triggering needs a fresh URI. + if let Some(authorize) = crate::take_pending_authorize() { + if self.login.is_none() && self.authorize.is_none() { + if let Some(active) = wallet.active_nostr_pubkey() { + // The approval takes over from any open scan/send flow. + self.send = None; + self.authorize = Some(AuthorizeState { + uri: authorize, + selected: active, + created: std::time::Instant::now(), + pass: String::new(), + wrong_pass: false, + posting: false, + show_full: false, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + }); + Modal::new(AUTHORIZE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.authorize.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 authorize_expired = matches!(&self.authorize, Some(st) + if !st.posting && st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS); + if authorize_expired { + self.authorize = None; + if Modal::opened() == Some(AUTHORIZE_MODAL) { + Modal::close(); + } + } + // Poll the callback POST; the request is consumed either way and the + // outcome shows as the same quiet toast (distinct success/failure + // strings). + let mut authorize_outcome = None; + if let Some(st) = &self.authorize { + if st.posting { + authorize_outcome = st + .result + .lock() + .unwrap() + .take() + .map(|res| (res, st.uri.domain.clone())); + if authorize_outcome.is_none() { + ui.ctx().request_repaint(); + } + } + } + if let Some((res, domain)) = authorize_outcome { + let text = match res { + Ok(()) => t!("goblin.authorize.sent", domain => domain).to_string(), + Err(e) => { + log::warn!("authorize callback failed: {e}"); + t!("goblin.authorize.failed", domain => domain).to_string() + } + }; + self.login_toast = Some((text, std::time::Instant::now())); + self.authorize = None; + } + // The authorize approval modal itself. + if Modal::opened() == Some(AUTHORIZE_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.authorize_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); @@ -6276,6 +6389,426 @@ impl GoblinWalletView { } } + /// The "Authorize with Goblin" approval modal: headline, the rendered event + /// (kind label, escaped content preview with an optional show-full view, and + /// per-kind key-tag summary), the identity picker, the password gate, and the + /// Cancel/Authorize buttons. Mirrors [`Self::login_modal_content`]; on + /// confirm it signs one event with the chosen identity and POSTs it off the + /// UI thread. Signed = consumed, whatever the POST outcome. + fn authorize_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + use crate::nostr::authuri; + let Some(st) = self.authorize.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(); + // Clone the small template once so the read-only render borrows nothing + // from `st` while its password/show-full fields are mutated below. + let template = st.uri.template.clone(); + let kind = template.kind; + let label = authuri::kind_label(kind); + let (preview, remaining) = authuri::content_preview(&template.content); + let preview_esc = authuri::escape_for_display(&preview); + let full_esc = authuri::escape_for_display(&template.content); + // Key tags, all escaped before display. + let e_tag = template + .first_tag_value("e") + .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); + let p_tag = template + .first_tag_value("p") + .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); + let title = template + .first_tag_value("title") + .map(authuri::escape_for_display); + 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.authorize.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(10.0); + // The plain-language kind label (and, for an off-allowlist kind that + // v1 cannot actually reach, a caution line). + let kind_text = match label { + authuri::KindLabel::Post => t!("goblin.authorize.kind_post"), + authuri::KindLabel::Repost => t!("goblin.authorize.kind_repost"), + authuri::KindLabel::Reaction => t!("goblin.authorize.kind_reaction"), + authuri::KindLabel::Article => t!("goblin.authorize.kind_article"), + authuri::KindLabel::Unknown => { + t!("goblin.authorize.kind_unknown", n => kind.to_string()) + } + }; + ui.label( + RichText::new(kind_text) + .size(15.0) + .color(Colors::text(false)), + ); + if label == authuri::KindLabel::Unknown { + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.authorize.unknown_caution", domain => domain.clone())) + .size(12.5) + .color(Colors::red()), + ); + } + ui.add_space(8.0); + // Per-kind rendering of the event body and its key tags. All + // requester-controlled text is escaped before it hits a label. + let show_preview = |ui: &mut egui::Ui| { + if !preview_esc.is_empty() { + ui.label(RichText::new(&preview_esc).size(13.5).color(Colors::gray())); + } + }; + match kind { + 7 => { + // Reaction: the reaction glyph (emoji or "+"), then target. + let reaction = if full_esc.is_empty() { + "+".to_string() + } else { + full_esc.clone() + }; + ui.label( + RichText::new(reaction) + .size(20.0) + .color(Colors::text(false)), + ); + ui.add_space(4.0); + if let Some(id) = &e_tag { + ui.label( + RichText::new(t!("goblin.authorize.reacts_to", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + if let Some(id) = &p_tag { + ui.label( + RichText::new(t!("goblin.authorize.by_author", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + } + 6 => { + // Repost: reposted event id and author. + if let Some(id) = &e_tag { + ui.label( + RichText::new(t!("goblin.authorize.repost_of", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + if let Some(id) = &p_tag { + ui.label( + RichText::new(t!("goblin.authorize.by_author", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + } + 30023 => { + // Article: title tag if present, then the content preview. + if let Some(t) = &title { + ui.label( + RichText::new(t!("goblin.authorize.article_title", title => t.clone())) + .size(14.0) + .color(Colors::text(false)), + ); + ui.add_space(4.0); + } + show_preview(ui); + } + _ => { + // Kind 1 (and the unreachable fallback): content preview, then + // the reply/mention tags. + show_preview(ui); + if let Some(id) = &e_tag { + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.authorize.replying_to", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + if let Some(id) = &p_tag { + ui.label( + RichText::new(t!("goblin.authorize.mentions", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + } + } + // Truncation marker plus the mandatory show-full affordance. Approval + // is never blocked on opening it. + if remaining > 0 { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.authorize.truncated", n => remaining.to_string())) + .size(12.0) + .color(Colors::gray()), + ); + let toggle = if st.show_full { + t!("goblin.authorize.show_less") + } else { + t!("goblin.authorize.show_full") + }; + let rect = ui + .label(RichText::new(toggle).size(13.0).color(Colors::green())) + .rect; + let hit = ui.interact( + rect, + egui::Id::from(modal.id).with("auth_showfull"), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.show_full = !st.show_full; + } + if st.show_full { + ui.add_space(6.0); + ScrollArea::vertical() + .max_height(160.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.label(RichText::new(&full_esc).size(13.0).color(Colors::gray())); + }); + } + } + 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. Defaults to the active identity. + ui.label( + RichText::new(t!("goblin.authorize.identity")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + if identities.len() > 1 { + for id in &identities { + let selected = st.selected == id.pubkey_hex; + let name = 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 name != short { + ui.label( + RichText::new(&name) + .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(("auth_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 name = id.display(); + let short = data::short_npub(&id.pubkey_hex); + if name != short { + ui.label(RichText::new(&name).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.authorize.explain", domain => domain.clone())) + .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.authorize.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("auth_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.authorize.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.authorize = None; + Modal::close(); + return; + } + if go { + let (pass, selected) = match self.authorize.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.authorize.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.authorize = None; + Modal::close(); + return; + }; + let st = self.authorize.as_mut().unwrap(); + st.pass.clear(); + st.wrong_pass = false; + match crate::nostr::authuri::build_authorize_event(&keys, &st.uri.template) { + 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 challenge = st.uri.challenge.clone(); + let domain = st.uri.domain.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::authuri::post_authorize_event( + &callback, &challenge, &domain, &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!("authorize event signing failed: {e}"); + self.login_toast = Some(( + t!("goblin.authorize.failed", domain => domain).to_string(), + std::time::Instant::now(), + )); + self.authorize = 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. diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 6c9c5ac1..0e472323 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -250,6 +250,17 @@ impl SendFlow { } return; } + // An "Authorize with Goblin" request is likewise grabbed BEFORE any pay + // parsing: it signs one arbitrary event, never a payment, and an + // authorize-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::authuri::is_authorize_shaped(text) { + if let Some(a) = crate::nostr::authuri::parse(text) { + crate::set_pending_authorize(a); + } + 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 d2b4ec50..48c51c94 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -497,6 +497,11 @@ impl WalletsContent { // always starts `npub1`/`nprofile1`). A login-shaped URI that fails // validation is dropped entirely: no modal, no send, no message. // + // Then the `authorize` keyword, checked BEFORE the pay path for the same + // reason: a `goblin:authorize?...` request signs one arbitrary event and + // is never a payment. An authorize-shaped URI that fails validation is + // likewise dropped entirely (no modal, no send, no fallthrough to pay). + // // 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 @@ -509,6 +514,12 @@ impl WalletsContent { } None } + Some(d) if crate::nostr::authuri::is_authorize_shaped(&d) => { + if let Some(a) = crate::nostr::authuri::parse(&d) { + crate::set_pending_authorize(a); + } + 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 921862da..68c1978f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -566,6 +566,14 @@ lazy_static! { /// site and never reach the UI. static ref PENDING_LOGIN: Arc>> = Arc::new(RwLock::new(None)); + /// A pending, already-VALIDATED "Authorize with Goblin" request + /// (`goblin:authorize?...` deep link or QR), waiting for the Goblin surface + /// to open its approval modal. Only a fully validated + /// [`nostr::authuri::AuthorizeUri`] (kind on the allowlist, template shape + /// and domain binding checked) is ever stashed here; rejected authorize URIs + /// are dropped at the dispatch site and never reach the UI. + static ref PENDING_AUTHORIZE: Arc>> = + Arc::new(RwLock::new(None)); } /// Stash a payment deep link for the Goblin surface to open (see @@ -593,6 +601,20 @@ pub fn take_pending_login() -> Option { PENDING_LOGIN.write().take() } +/// Stash a VALIDATED authorize request for the Goblin surface to open its +/// approval modal (see [`take_pending_authorize`]). The most recent request +/// wins here; the surface itself ignores new requests while one approval (login +/// or authorize) is already pending. +pub fn set_pending_authorize(authorize: nostr::authuri::AuthorizeUri) { + *PENDING_AUTHORIZE.write() = Some(authorize); +} + +/// Take (and clear) a pending authorize request, if any. The Goblin wallet view +/// polls this each frame and opens the one-shot event-signing approval modal. +pub fn take_pending_authorize() -> Option { + PENDING_AUTHORIZE.write().take() +} + /// Callback from Java code with passed data. #[allow(dead_code)] #[allow(non_snake_case)] diff --git a/src/nostr/authuri.rs b/src/nostr/authuri.rs new file mode 100644 index 00000000..de9f40bf --- /dev/null +++ b/src/nostr/authuri.rs @@ -0,0 +1,1068 @@ +// 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. + +//! "Authorize with Goblin" request parser and one-shot event signer. +//! +//! A site (magick.market) asks the wallet to sign exactly one Nostr event by +//! handing it a template over either the `goblin:` deep-link scheme or the +//! equivalent `nostr:` QR payload: +//! +//! ```text +//! goblin:authorize?e=&d=&cb=&c=<64-hex nonce> +//! nostr:authorize?... (same, for QR payloads, mirroring login) +//! ``` +//! +//! On approval the wallet composes the event itself, filling `pubkey` (the +//! CHOSEN identity), `created_at` (now), `id`, and `sig`; the requester only +//! ever supplies `kind`, `content`, and `tags`. The signed event is POSTed to +//! the callback as `{"c": , "d": , "event": }`. One +//! approval signs one event and hands it over: no key is shared, no session is +//! created, and nothing is published by the wallet. +//! +//! Parsing is PURE and fail-closed over UNTRUSTED input, mirroring +//! [`crate::nostr::loginuri`] and [`crate::nostr::payuri`] exactly (same scheme +//! handling, same percent-decoding, first occurrence of a duplicate param +//! wins, unknown params ignored). ANY single validation failure rejects the +//! whole URI: no modal, no partial handling, no fallthrough to the pay path. +//! The kind allowlist and the strict three-key template shape are enforced here +//! at parse time, before any modal can open, so the wallet never even considers +//! signing something outside the v1 policy. + +use super::payuri::{percent_decode, strip_pay_scheme}; +use base64::Engine; +use nostr_sdk::{Event, EventBuilder, Keys, Kind, Tag}; + +/// Total payload byte cap, same bar as the pay-URI and login-URI parsers. +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; +/// Decoded template-JSON byte cap: keeps the whole URI comfortably inside +/// practical QR capacity, and bounds the untrusted JSON we parse. +const MAX_TEMPLATE_LEN: usize = 2048; + +/// The v1 kind allowlist. Everything else, including kind 22242 (which routes +/// only through the login flow), is rejected at parse time. See the spec, +/// section 3, for the reasoning behind each exclusion. +const ALLOWED_KINDS: [u16; 4] = [1, 6, 7, 30023]; + +/// The requester-supplied event template: exactly the three fields a site is +/// allowed to choose. `pubkey`, `created_at`, `id`, and `sig` are NOT here on +/// purpose, the wallet fills those itself so no confusion is possible about who +/// owns which field. Only constructed by [`parse`], so holding one means the +/// kind is on the allowlist and every field already validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Template { + /// The event kind, guaranteed on [`ALLOWED_KINDS`]. + pub kind: u16, + /// The event content, verbatim from the requester. + pub content: String, + /// The event tags, each an array of strings, verbatim from the requester. + pub tags: Vec>, +} + +impl Template { + /// The value of the first tag whose name (position 0) equals `name` and + /// which carries at least a value at position 1. Used by the modal to pull + /// the key tags (`e`, `p`, `title`) it summarizes. Pure, no allocation. + pub fn first_tag_value(&self, name: &str) -> Option<&str> { + self.tags + .iter() + .find(|t| t.len() >= 2 && t.first().map(|k| k == name).unwrap_or(false)) + .map(|t| t[1].as_str()) + } +} + +/// A validated authorize request: the one-time nonce, the requesting domain the +/// user approves, the HTTPS callback the signed event is delivered to, and the +/// strictly-shaped event template. Only constructed by [`parse`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizeUri { + /// The one-time request 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, + /// The event template the site wants signed. + pub template: Template, +} + +/// True when `scanned` carries a Goblin scheme with the `authorize` keyword, +/// i.e. it is an authorize request (valid or not) and must NEVER be fed to the +/// pay path. The dispatcher checks this BEFORE [`super::payuri::is_pay_uri`] +/// (and after [`super::loginuri::is_login_shaped`]); a shaped-but-invalid +/// authorize URI is then dropped entirely (no modal, no send). +pub fn is_authorize_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("authorize") + } + None => false, + } +} + +/// Parse an authorize URI. `Some` only when EVERY field validates: `c` is +/// exactly 64 hex chars, `d` is a shaped domain, `cb` is an https (or dev +/// localhost) URL bound to `d`, and `e` decodes to a strictly-shaped template +/// whose kind is on the allowlist. 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("authorize") { + return None; + } + let mut challenge = None; + let mut domain = None; + let mut callback = None; + let mut template = 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 value 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), + "e" if template.is_none() => template = Some(val), + // Unknown params are ignored for forward-compat. + _ => {} + } + } + let challenge = validate_challenge(challenge?)?; + let domain = validate_domain(domain?)?; + let callback = validate_callback(callback?)?; + // Domain binding hardens on login: an arbitrary authorized event carries no + // server-verified `domain` tag, so the wallet closes the gap itself by + // requiring the callback host to belong to the displayed domain. + if !domain_bound(&callback, &domain) { + return None; + } + let template = validate_template(template?)?; + Some(AuthorizeUri { + challenge, + domain, + callback, + template, + }) +} + +/// The challenge nonce must be exactly 64 hex chars (a 32-byte value): wrong +/// length or any non-hex char rejects the whole URI (same rule as login). +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) plus one +/// binding check, never a route, so a shape check is enough (same as login). +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 (same rule as login). +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) + } + } +} + +/// The new domain binding: the callback host must equal `domain` or be a +/// subdomain of it, matched on a label boundary (case-insensitive, ports +/// ignored). The `http://localhost` dev callback is exempt, since a local dev +/// server legitimately serves any domain's flow. This is what makes the +/// attacker-supplied `d` trustworthy: the signed event can only ever travel to +/// a host that belongs to the domain the user approved. +fn domain_bound(callback: &str, domain: &str) -> bool { + // The localhost dev callback is validated already and exempt from binding. + if strip_prefix_ignore_case(callback, "http://").is_some() { + return true; + } + let Some(rest) = strip_prefix_ignore_case(callback, "https://") else { + return false; + }; + let authority_end = rest + .find(|c| c == '/' || c == '?' || c == '#') + .unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + // Drop any userinfo (`user:pass@`), keep the host:port that follows. + let hostport = authority.rsplit('@').next().unwrap_or(authority); + // Drop the port, if any (a bare host has no colon). + let host = match hostport.rfind(':') { + Some(i) => &hostport[..i], + None => hostport, + }; + let host = host.trim().to_ascii_lowercase(); + let domain = domain.trim().to_ascii_lowercase(); + if host.is_empty() || domain.is_empty() { + return false; + } + host == domain || host.ends_with(&format!(".{domain}")) +} + +/// Decode and validate the `e` template. Unpadded base64url only (any `=` +/// padding or non-`A-Za-z0-9-_` char rejects), decoding to a UTF-8 JSON object +/// with EXACTLY the three keys `kind`, `content`, `tags` and nothing else. The +/// kind must be an actual integer on the allowlist (a JSON float like `1.0` or +/// `1e0` is not an integer and rejects); content a string; tags an array of +/// arrays of strings. A `delegation` tag is rejected outright (defense in depth, +/// even though delegation tokens are unreachable here). Any deviation rejects. +fn validate_template(raw: &str) -> Option