upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/lib/login/fresh.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-11-21 16:53:17 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2024-11-21 16:53:17 +0000
commitf79014235e85554e3661b3f2a02b8fa88bc192ff (patch)
treefceec3ff2df212148a3420af7cef81a3f818463e /src/lib/login/fresh.rs
parent91b0eac4daf92b7b740267ef203a1a8ba591974b (diff)
feat(login): overhaul login experience
* simplify login menu, making it more accessable to newcomers and easier to select remote signer options * enable `ngit login` to work from anywhere (not just a git repo) * assume fresh login details saved to global git config but fallback to local repository * maintain local repository login via `ngit login --local` * maintain login via CLI arguments eg `ngit send --nsec nsec123` * nudge users to remember nsec when pasting in ncryptsec for a better UX, whilst maintaining the option to be prompted for password everytime * create placeholder menu items for help menu and create account
Diffstat (limited to 'src/lib/login/fresh.rs')
-rw-r--r--src/lib/login/fresh.rs595
1 files changed, 595 insertions, 0 deletions
diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs
new file mode 100644
index 0000000..3e88f68
--- /dev/null
+++ b/src/lib/login/fresh.rs
@@ -0,0 +1,595 @@
1use std::{str::FromStr, sync::Arc, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use console::{Style, Term};
5use dialoguer::theme::{ColorfulTheme, Theme};
6use nostr::nips::{nip05, nip46::NostrConnectURI};
7use nostr_connect::client::NostrConnect;
8use nostr_sdk::{Keys, NostrSigner, PublicKey, ToBech32, Url};
9use qrcode::QrCode;
10use tokio::sync::{oneshot, Mutex};
11
12use super::{
13 key_encryption::decrypt_key,
14 print_logged_in_as,
15 user::{get_user_details, UserRef},
16 SignerInfo, SignerInfoSource,
17};
18#[cfg(not(test))]
19use crate::client::Client;
20#[cfg(test)]
21use crate::client::MockConnect;
22use crate::{
23 cli_interactor::{
24 Interactor, InteractorPrompt, Printer, PromptChoiceParms, PromptConfirmParms,
25 PromptInputParms, PromptPasswordParms,
26 },
27 client::Connect,
28 git::{remove_git_config_item, save_git_config_item, Repo, RepoActions},
29};
30
31pub async fn fresh_login_or_signup(
32 git_repo: &Option<&Repo>,
33 #[cfg(test)] client: Option<&MockConnect>,
34 #[cfg(not(test))] client: Option<&Client>,
35 save_local: bool,
36) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> {
37 let (signer, public_key, signer_info, source) = loop {
38 match Interactor::default().choice(
39 PromptChoiceParms::default()
40 .with_prompt("login to nostr")
41 .with_default(0)
42 .with_choices(vec![
43 "secret key (nsec / ncryptsec)".to_string(),
44 "nostr connect (remote signer)".to_string(),
45 "create account".to_string(),
46 "help".to_string(),
47 ])
48 .dont_report(),
49 )? {
50 0 => match get_fresh_nsec_signer().await {
51 Ok(Some(res)) => break res,
52 Ok(None) => continue,
53 Err(e) => {
54 eprintln!("error getting fresh signer from nsec: {e}");
55 continue;
56 }
57 },
58 1 => match get_fresh_nip46_signer(client).await {
59 Ok(Some(res)) => break res,
60 Ok(None) => continue,
61 Err(e) => {
62 eprintln!("error getting fresh nip46 signer: {e}");
63 continue;
64 }
65 },
66 2 => {
67 eprintln!("TODO create account...");
68 continue;
69 }
70 _ => {
71 display_login_help_content();
72 continue;
73 }
74 }
75 };
76 let _ = save_to_git_config(git_repo, &signer_info, !save_local);
77 let user_ref = get_user_details(
78 &public_key,
79 client,
80 if let Some(git_repo) = git_repo {
81 Some(git_repo.get_path()?)
82 } else {
83 None
84 },
85 false,
86 )
87 .await?;
88 print_logged_in_as(&user_ref, client.is_none(), &source)?;
89 Ok((signer, user_ref, source))
90}
91
92pub async fn get_fresh_nsec_signer() -> Result<
93 Option<(
94 Arc<dyn NostrSigner>,
95 PublicKey,
96 SignerInfo,
97 SignerInfoSource,
98 )>,
99> {
100 loop {
101 let input = Interactor::default()
102 .input(
103 PromptInputParms::default()
104 .with_prompt("nsec")
105 .optional()
106 .dont_report(),
107 )
108 .context("failed to get nsec input from interactor")?;
109 let (keys, signer_info) = if input.contains("ncryptsec") {
110 let password = Interactor::default()
111 .password(
112 PromptPasswordParms::default()
113 .with_prompt("password")
114 .dont_report(),
115 )
116 .context("failed to get password input from interactor.password")?;
117 let keys = if let Ok(keys) = decrypt_key(&input, password.clone().as_str())
118 .context("failed to decrypt ncryptsec with provided password")
119 {
120 keys
121 } else {
122 show_prompt_error(
123 "invalid ncryptsec and password combination",
124 &shorten_string(&input),
125 );
126 match Interactor::default().choice(
127 PromptChoiceParms::default()
128 .with_default(0)
129 .with_choices(vec!["try again with nsec".to_string(), "back".to_string()])
130 .dont_report(),
131 )? {
132 0 => continue,
133 _ => break Ok(None),
134 }
135 };
136 let npub = Some(keys.public_key().to_bech32()?);
137 let signer_info = if Interactor::default()
138 .confirm(PromptConfirmParms::default().with_prompt("remember details?"))?
139 || !Interactor::default().confirm(PromptConfirmParms::default().with_prompt(
140 "you will be prompted for password to decrypt your ncryptsec at every git push. are you sure?",
141 ))? {
142 SignerInfo::Nsec {
143 nsec: keys.secret_key().to_bech32()?,
144 password: None,
145 npub,
146 }
147 } else {
148 show_prompt_success("nsec", &shorten_string(&input));
149 SignerInfo::Nsec {
150 nsec: input,
151 password: Some(password),
152 npub,
153 }
154 };
155 (keys, signer_info)
156 } else if let Ok(keys) = nostr::Keys::from_str(&input) {
157 let nsec = keys.secret_key().to_bech32()?;
158 show_prompt_success("nsec", &shorten_string(&nsec));
159 let signer_info = SignerInfo::Nsec {
160 nsec,
161 password: None,
162 npub: Some(keys.public_key().to_bech32()?),
163 };
164 (keys, signer_info)
165 } else {
166 show_prompt_error("invalid nsec", &shorten_string(&input));
167 match Interactor::default().choice(
168 PromptChoiceParms::default()
169 .with_default(0)
170 .with_choices(vec!["try again with nsec".to_string(), "back".to_string()])
171 .dont_report(),
172 )? {
173 0 => continue,
174 _ => break Ok(None),
175 }
176 };
177
178 let public_key = keys.public_key();
179
180 break Ok(Some((
181 Arc::new(keys),
182 public_key,
183 signer_info,
184 // TODO factor in source
185 SignerInfoSource::GitGlobal,
186 )));
187 }
188}
189
190fn show_prompt_success(label: &str, value: &str) {
191 eprintln!("{}", {
192 let mut s = String::new();
193 let _ = ColorfulTheme::default().format_input_prompt_selection(&mut s, label, value);
194 s
195 });
196}
197
198fn show_prompt_error(label: &str, value: &str) {
199 eprintln!("{}", {
200 let mut s = String::new();
201 let _ = ColorfulTheme::default().format_error(
202 &mut s,
203 &format!(
204 "{label}: {}",
205 if value.is_empty() {
206 "empty".to_string()
207 } else {
208 shorten_string(&format!("\"{}\"", &value))
209 }
210 ),
211 );
212 s
213 });
214}
215
216fn shorten_string(s: &str) -> String {
217 if s.len() < 15 {
218 s.to_string()
219 } else {
220 format!("{}...", &s[..15])
221 }
222}
223
224pub async fn get_fresh_nip46_signer(
225 #[cfg(test)] client: Option<&MockConnect>,
226 #[cfg(not(test))] client: Option<&Client>,
227) -> Result<
228 Option<(
229 Arc<dyn NostrSigner>,
230 PublicKey,
231 SignerInfo,
232 SignerInfoSource,
233 )>,
234> {
235 let (app_key, nostr_connect_url) = generate_nostr_connect_app(client)?;
236 let printer = Arc::new(Mutex::new(Printer::default()));
237 let signer_choice = Interactor::default().choice(
238 PromptChoiceParms::default()
239 .with_prompt("login to nostr with remote signer")
240 .with_default(0)
241 .with_choices(vec![
242 "show QR code to scan in signer app".to_string(),
243 "show nostrconnect:// url to paste into signer".to_string(),
244 "use NIP-05 address to connect to signer".to_string(),
245 "paste in bunker:// url from signer app".to_string(),
246 "back".to_string(),
247 ])
248 .dont_report(),
249 )?;
250 let url = match signer_choice {
251 0 | 1 => nostr_connect_url,
252 2 => {
253 let mut error = None;
254 loop {
255 let input = Interactor::default()
256 .input(
257 PromptInputParms::default().with_prompt(if let Some(error) = error {
258 format!("error: {}. try again with NIP-05 address", error)
259 } else {
260 "NIP-05 address".to_string()
261 }),
262 )
263 .context("failed to get NIP-05 address input from interactor")?;
264 match fetch_nip46_uri_from_nip05(&input).await {
265 Ok(url) => break url,
266 Err(e) => error = Some(e),
267 }
268 }
269 }
270 3 => {
271 let mut error = None;
272 loop {
273 let input = Interactor::default()
274 .input(
275 PromptInputParms::default().with_prompt(if let Some(error) = error {
276 format!("error: {}. try again with bunker url", error)
277 } else {
278 "bunker url".to_string()
279 }),
280 )
281 .context("failed to get bunker url input from interactor")?;
282 match NostrConnectURI::parse(&input) {
283 Ok(url) => break url,
284 Err(e) => error = Some(e),
285 }
286 }
287 }
288 _ => return Ok(None),
289 };
290
291 {
292 let printer_clone = Arc::clone(&printer);
293 let mut printer_locked = printer_clone.lock().await;
294 match signer_choice {
295 0 => {
296 printer_locked
297 .println("login to nostr with remote signer via nostr connect".to_string());
298 printer_locked.println("scan QR code in signer app (eg Amber):".to_string());
299 printer_locked.printlns(generate_qr(&url.to_string())?);
300 printer_locked
301 .println("scan QR code in signer app or press any key to abort...".to_string());
302 }
303 1 => {
304 printer_locked
305 .println("login to nostr with remote signer via nostr connect".to_string());
306 printer_locked.println("".to_string());
307 printer_locked.println_with_custom_formatting(
308 format!("{}", Style::new().bold().apply_to(url.to_string()),),
309 url.to_string(),
310 );
311 printer_locked.println("".to_string());
312 printer_locked
313 .println("paste url into signer app or press any key to abort...".to_string());
314 }
315 _ => {
316 printer_locked.println(
317 "add / approve in your signer or press any key to abort... ".to_string(),
318 );
319 }
320 }
321 }
322
323 let (signer, user_public_key, bunker_url) =
324 listen_for_remote_signer(&app_key, &url, printer).await?;
325 let signer_info = SignerInfo::Bunker {
326 bunker_uri: bunker_url.to_string(),
327 bunker_app_key: app_key.secret_key().to_secret_hex(),
328 npub: Some(user_public_key.to_bech32()?),
329 };
330 Ok(Some((
331 signer,
332 user_public_key,
333 signer_info,
334 SignerInfoSource::GitGlobal,
335 )))
336}
337
338pub fn generate_nostr_connect_app(
339 #[cfg(test)] client: Option<&MockConnect>,
340 #[cfg(not(test))] client: Option<&Client>,
341) -> Result<(Keys, NostrConnectURI)> {
342 let app_key = Keys::generate();
343 let relays = if let Some(client) = client {
344 client
345 .get_fallback_signer_relays()
346 .iter()
347 .flat_map(|s| Url::parse(s))
348 .collect::<Vec<Url>>()
349 } else {
350 vec![]
351 };
352 let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit");
353 Ok((app_key, nostr_connect_url))
354}
355
356pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> {
357 let term = console::Term::stderr();
358 term.write_line("contacting login service provider...")?;
359 let res = nip05::profile(&nip05, None).await;
360 term.clear_last_lines(1)?;
361 match res {
362 Ok(profile) => {
363 if profile.nip46.is_empty() {
364 eprintln!("nip05 provider isn't configured for remote login");
365 bail!("nip05 provider isn't configured for remote login")
366 }
367 Ok(NostrConnectURI::Bunker {
368 remote_signer_public_key: profile.public_key,
369 relays: profile.nip46,
370 secret: None,
371 })
372 }
373 Err(error) => {
374 eprintln!("error contacting login service provider: {error}");
375 Err(error).context("error contacting login service provider")
376 }
377 }
378}
379
380pub async fn listen_for_remote_signer(
381 app_key: &Keys,
382 nostr_connect_url: &NostrConnectURI,
383 printer: Arc<Mutex<Printer>>,
384) -> Result<(Arc<dyn NostrSigner>, PublicKey, NostrConnectURI)> {
385 let (tx, rx) = oneshot::channel();
386 let printer_clone = Arc::clone(&printer);
387 let app_key = app_key.clone();
388 let nostr_connect_url_clone = nostr_connect_url.clone();
389 let qr_listener = tokio::spawn(async move {
390 if let Ok(nostr_connect) = NostrConnect::new(
391 nostr_connect_url_clone,
392 app_key,
393 Duration::from_secs(10 * 60),
394 None,
395 ) {
396 let signer: Arc<dyn NostrSigner> = Arc::new(nostr_connect);
397 if let Ok(pub_key) = signer.get_public_key().await {
398 let mut printer_locked = printer_clone.lock().await;
399 printer_locked.clear_all();
400
401 printer_locked.println_with_custom_formatting(
402 format!(
403 "{}",
404 Style::new().bold().apply_to("connected to remote signer"),
405 ),
406 "connected to remote signer".to_string(),
407 );
408 printer_locked.println("press any key to continue...".to_string());
409 let _ = tx.send(Some((signer, pub_key)));
410 } else {
411 let _ = tx.send(None);
412 }
413 }
414 });
415 let _ = console::Term::stderr().read_char();
416 qr_listener.abort();
417 let printer_clone = Arc::clone(&printer);
418 let mut printer = printer_clone.lock().await;
419 printer.clear_all();
420
421 if let Some((signer, public_key)) = rx.await? {
422 let bunker_url = NostrConnectURI::Bunker {
423 // TODO the remote signer pubkey may not be the user pubkey
424 remote_signer_public_key: public_key,
425 relays: nostr_connect_url.relays(),
426 secret: nostr_connect_url.secret(),
427 };
428 Ok((signer, public_key, bunker_url))
429 } else {
430 bail!("failed to get signer")
431 }
432}
433
434fn generate_qr(data: &str) -> Result<Vec<String>> {
435 let mut lines = vec![];
436 let qr =
437 QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?;
438 let colors = qr.to_colors();
439 let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect();
440 for (row, data) in rows.iter().enumerate() {
441 let odd = row % 2 != 0;
442 if odd {
443 continue;
444 }
445 let mut line = String::new();
446 for (col, color) in data.iter().enumerate() {
447 let top = color;
448 let mut bottom = qrcode::Color::Light;
449 if let Some(next_row_data) = rows.get(row + 1) {
450 if let Some(color) = next_row_data.get(col) {
451 bottom = *color;
452 }
453 }
454 line.push(if *top == qrcode::Color::Dark {
455 if bottom == qrcode::Color::Dark {
456 '█'
457 } else {
458 '▀'
459 }
460 } else if bottom == qrcode::Color::Dark {
461 '▄'
462 } else {
463 ' '
464 });
465 }
466 lines.push(line);
467 }
468 Ok(lines)
469}
470
471fn save_to_git_config(
472 git_repo: &Option<&Repo>,
473 signer_info: &SignerInfo,
474 global: bool,
475) -> Result<()> {
476 if let Err(error) = silently_save_to_git_config(git_repo, signer_info, global).context(format!(
477 "failed to save login details to {} git config",
478 if global { "global" } else { "local" }
479 )) {
480 eprintln!("Error: {:?}", error);
481 match signer_info {
482 SignerInfo::Nsec {
483 nsec,
484 password: _,
485 npub: _,
486 } => {
487 if nsec.contains("ncryptsec") {
488 eprintln!("consider manually setting git config nostr.nsec to: {nsec}");
489 } else {
490 eprintln!("consider manually setting git config nostr.nsec");
491 }
492 }
493 SignerInfo::Bunker {
494 bunker_uri,
495 bunker_app_key,
496 npub: _,
497 } => {
498 eprintln!("consider manually setting git config as follows:");
499 eprintln!("nostr.bunker-uri: {bunker_uri}");
500 eprintln!("nostr.bunker-app-key: {bunker_app_key}");
501 }
502 }
503 if global {
504 save_to_git_config(git_repo, signer_info, false)?
505 }
506 Err(error)
507 } else {
508 eprintln!(
509 "{}",
510 if global {
511 "saved login details to global git config"
512 } else {
513 "saved login details to local git config. you are only logged in to this local repository."
514 }
515 );
516 Ok(())
517 }
518}
519
520fn silently_save_to_git_config(
521 git_repo: &Option<&Repo>,
522 signer_info: &SignerInfo,
523 global: bool,
524) -> Result<()> {
525 if global {
526 // remove local login otherwise it will override global next time ngit is called
527 if let Some(git_repo) = git_repo {
528 git_repo.remove_git_config_item("nostr.npub", false)?;
529 git_repo.remove_git_config_item("nostr.nsec", false)?;
530 git_repo.remove_git_config_item("nostr.bunker-uri", false)?;
531 git_repo.remove_git_config_item("nostr.bunker-app-key", false)?;
532 }
533 }
534
535 let git_repo = if global {
536 &None
537 } else if git_repo.is_none() {
538 bail!("cannot update local git config wihout git_repo object")
539 } else {
540 git_repo
541 };
542
543 let npub_to_save;
544 match signer_info {
545 SignerInfo::Nsec {
546 nsec,
547 password: _,
548 npub,
549 } => {
550 npub_to_save = npub;
551 save_git_config_item(git_repo, "nostr.nsec", nsec)?;
552 remove_git_config_item(git_repo, "nostr.bunker-uri")?;
553 remove_git_config_item(git_repo, "nostr.bunker-app-key")?;
554 }
555 SignerInfo::Bunker {
556 bunker_uri,
557 bunker_app_key,
558 npub,
559 } => {
560 npub_to_save = npub;
561 remove_git_config_item(git_repo, "nostr.nsec")?;
562 save_git_config_item(git_repo, "nostr.bunker-uri", bunker_uri)?;
563 save_git_config_item(git_repo, "nostr.bunker-app-key", bunker_app_key)?;
564 }
565 }
566 if let Some(npub) = npub_to_save {
567 save_git_config_item(git_repo, "nostr.npub", npub)?;
568 } else {
569 remove_git_config_item(git_repo, "nostr.npub")?;
570 }
571 Ok(())
572}
573
574fn display_login_help_content() {
575 let mut printer = Printer::default();
576 let title_style = Style::new().bold().fg(console::Color::Yellow);
577 printer.println("|==============================|".to_owned());
578 // printer.println("| |".to_owned());
579 printer.println_with_custom_formatting(
580 format!(
581 "| {} |",
582 title_style.apply_to("nostr login / sign up help")
583 ),
584 "| nostr login / sign up help |".to_string(),
585 );
586 // printer.println("| |".to_owned());
587 printer.println("|==============================|".to_owned());
588 printer.printlns(vec![
589 "".to_string(),
590 "login / sign up help content should go here...".to_string(),
591 "press any key to see the login / signup menu again...".to_string(),
592 ]);
593 let _ = Term::stdout().read_char();
594 printer.clear_all();
595}