1
0
forked from GRIN/grim

goblin: Authorize, one-shot site-requested event signing with per-event approval (Build 151)

goblin:authorize?e=<base64url template>&d=<domain>&cb=<https callback>&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 <domain>? 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.
This commit is contained in:
2ro
2026-07-06 07:34:03 -04:00
parent 4b0071fa91
commit d4cc7dbe98
12 changed files with 1792 additions and 2 deletions
+24
View File
@@ -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"
+24
View File
@@ -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"
+24
View File
@@ -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é"
+24
View File
@@ -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: "Сменить личность"
+24
View File
@@ -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"
+24
View File
@@ -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: "切换身份"
+535 -2
View File
@@ -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<LoginState>,
/// 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<AuthorizeState>,
/// 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<std::sync::Mutex<Option<Result<(), String>>>>,
}
/// 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<std::sync::Mutex<Option<Result<(), String>>>>,
}
/// 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.
+11
View File
@@ -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
+11
View File
@@ -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
+22
View File
@@ -566,6 +566,14 @@ lazy_static! {
/// site and never reach the UI.
static ref PENDING_LOGIN: Arc<RwLock<Option<nostr::loginuri::LoginUri>>> =
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<RwLock<Option<nostr::authuri::AuthorizeUri>>> =
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<nostr::loginuri::LoginUri> {
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<nostr::authuri::AuthorizeUri> {
PENDING_AUTHORIZE.write().take()
}
/// Callback from Java code with passed data.
#[allow(dead_code)]
#[allow(non_snake_case)]
+1068
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -48,5 +48,6 @@ pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase};
pub mod avatar;
pub mod nip05;
pub mod authuri;
pub mod loginuri;
pub mod payuri;