goblin: sign in with Goblin, one-time login approval + 22242 signer (Build 150)

This commit is contained in:
2ro
2026-07-06 03:24:09 -04:00
parent 3c09baf25a
commit b7adae89c7
13 changed files with 975 additions and 7 deletions
+9
View File
@@ -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"
+9
View File
@@ -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"
+9
View File
@@ -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é"
+9
View File
@@ -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: "Сменить личность"
+9
View File
@@ -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"
+9
View File
@@ -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: "切换身份"
+421
View File
@@ -137,6 +137,12 @@ pub struct GoblinWalletView {
back_hint: Option<std::time::Instant>,
/// A batch invoice request awaiting approval (count=N deep link).
batch_invoice: Option<BatchInvoiceState>,
/// 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<LoginState>,
/// 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<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)]
@@ -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();
+11
View File
@@ -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
+17 -4
View File
@@ -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
+20
View File
@@ -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<RwLock<Option<String>>> = 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<RwLock<Option<nostr::loginuri::LoginUri>>> =
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<String> {
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<nostr::loginuri::LoginUri> {
PENDING_LOGIN.write().take()
}
/// Callback from Java code with passed data.
#[allow(dead_code)]
#[allow(non_snake_case)]
+445
View File
@@ -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=<domain>&cb=<https callback URL>
//! nostr:login?c=<64-hex nonce>&d=<domain>&cb=<https callback URL>
//! ```
//!
//! 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": <event-json>}`. 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<LoginUri> {
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<String> {
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<String> {
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<String> {
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::<u16>().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<Event, String> {
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": <event-json>}`.
/// 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<Vec<String>> = 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());
}
}
+1
View File
@@ -48,4 +48,5 @@ pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase};
pub mod avatar;
pub mod nip05;
pub mod loginuri;
pub mod payuri;
+6 -3
View File
@@ -189,8 +189,10 @@ fn validate_count(raw: &str) -> Option<u32> {
/// 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<u8> {
/// Crate-visible so the login-URI parser decodes identically.
pub(crate) fn percent_decode(s: &str) -> Vec<u8> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;