mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-14 02:38:55 +00:00
goblin: sign in with Goblin, one-time login approval + 22242 signer (Build 150)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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é"
|
||||
|
||||
@@ -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: "Сменить личность"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "切换身份"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user