upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib/login/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/login/mod.rs')
-rw-r--r--src/lib/login/mod.rs695
1 files changed, 695 insertions, 0 deletions
diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs
new file mode 100644
index 0000000..19bb97c
--- /dev/null
+++ b/src/lib/login/mod.rs
@@ -0,0 +1,695 @@
1use std::{collections::HashSet, path::Path, str::FromStr, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use nostr::{
5 nips::{nip05, nip46::NostrConnectURI},
6 PublicKey,
7};
8use nostr_sdk::{
9 Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32,
10};
11use nostr_signer::Nip46Signer;
12
13#[cfg(not(test))]
14use crate::client::Client;
15#[cfg(test)]
16use crate::client::MockConnect;
17use crate::{
18 cli_interactor::{
19 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms,
20 },
21 client::{fetch_public_key, get_event_from_global_cache, Connect},
22 config::{UserMetadata, UserRef, UserRelayRef, UserRelays},
23 git::{Repo, RepoActions},
24 key_handling::encryption::{decrypt_key, encrypt_key},
25};
26
27/// handles the encrpytion and storage of key material
28#[allow(clippy::too_many_arguments)]
29pub async fn launch(
30 git_repo: &Repo,
31 bunker_uri: &Option<String>,
32 bunker_app_key: &Option<String>,
33 nsec: &Option<String>,
34 password: &Option<String>,
35 #[cfg(test)] client: Option<&MockConnect>,
36 #[cfg(not(test))] client: Option<&Client>,
37 change_user: bool,
38 silent: bool,
39) -> Result<(NostrSigner, UserRef)> {
40 if let Ok(signer) = match get_signer_without_prompts(
41 git_repo,
42 bunker_uri,
43 bunker_app_key,
44 nsec,
45 password,
46 change_user,
47 )
48 .await
49 {
50 Ok(signer) => Ok(signer),
51 Err(error) => {
52 if error
53 .to_string()
54 .eq("git config item nostr.nsec is an ncryptsec")
55 {
56 println!(
57 "login as {}",
58 if let Ok(public_key) = PublicKey::from_bech32(
59 get_config_item(git_repo, "nostr.npub")
60 .unwrap_or("unknown ncryptsec".to_string()),
61 ) {
62 if let Ok(user_ref) =
63 get_user_details(&public_key, client, git_repo.get_path()?, silent)
64 .await
65 {
66 user_ref.metadata.name
67 } else {
68 "unknown ncryptsec".to_string()
69 }
70 } else {
71 "unknown ncryptsec".to_string()
72 }
73 );
74 loop {
75 // prompt for password
76 let password = Interactor::default()
77 .password(PromptPasswordParms::default().with_prompt("password"))
78 .context("failed to get password input from interactor.password")?;
79 if let Ok(keys) = get_keys_with_password(git_repo, &password) {
80 break Ok(NostrSigner::Keys(keys));
81 }
82 println!("incorrect password");
83 }
84 } else {
85 if nsec.is_some() {
86 bail!(error);
87 }
88 Err(error)
89 }
90 }
91 } {
92 // get user ref
93 let user_ref = get_user_details(
94 &signer
95 .public_key()
96 .await
97 .context("cannot get public key from signer")?,
98 client,
99 git_repo.get_path()?,
100 silent,
101 )
102 .await?;
103 if !silent {
104 print_logged_in_as(&user_ref, client.is_none())?;
105 }
106 Ok((signer, user_ref))
107 } else if silent {
108 bail!("TODO: enable interactive login in nostr git remote helper");
109 } else {
110 fresh_login(git_repo, client, change_user).await
111 }
112}
113
114fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> {
115 if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) {
116 println!("cannot find profile...");
117 } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) {
118 println!("cannot extract account name from account metadata...");
119 } else if !offline_mode && user_ref.relays.created_at.eq(&Timestamp::from(0)) {
120 println!(
121 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
122 );
123 }
124 println!("logged in as {}", user_ref.metadata.name);
125 Ok(())
126}
127
128async fn get_signer_without_prompts(
129 git_repo: &Repo,
130 bunker_uri: &Option<String>,
131 bunker_app_key: &Option<String>,
132 nsec: &Option<String>,
133 password: &Option<String>,
134 save_local: bool,
135) -> Result<NostrSigner> {
136 if let Some(nsec) = nsec {
137 Ok(NostrSigner::Keys(get_keys_from_nsec(
138 git_repo, nsec, password, save_local,
139 )?))
140 } else if let Some(password) = password {
141 Ok(NostrSigner::Keys(get_keys_with_password(
142 git_repo, password,
143 )?))
144 } else if let Some(bunker_uri) = bunker_uri {
145 if let Some(bunker_app_key) = bunker_app_key {
146 let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key)
147 .await
148 .context("failed to connect with remote signer")?;
149 if save_local {
150 save_to_git_config(
151 git_repo,
152 &signer.public_key().await?.to_bech32()?,
153 &None,
154 &Some((bunker_uri.to_string(),bunker_app_key.to_string())),
155 false,
156 )
157 .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?;
158 }
159 Ok(signer)
160 } else {
161 bail!(
162 "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively."
163 )
164 }
165 } else if !save_local {
166 get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await
167 } else {
168 bail!("user wants prompts to specify new keys")
169 }
170}
171
172fn get_keys_from_nsec(
173 git_repo: &Repo,
174 nsec: &String,
175 password: &Option<String>,
176 save_local: bool,
177) -> Result<nostr::Keys> {
178 #[allow(unused_assignments)]
179 let mut s = String::new();
180 let keys = if nsec.contains("ncryptsec") {
181 s = nsec.to_string();
182 decrypt_key(
183 nsec,
184 password
185 .clone()
186 .context("password must be supplied when using ncryptsec as nsec parameter")?
187 .as_str(),
188 )
189 .context("failed to decrypt key with provided password")
190 .context("failed to decrypt ncryptsec supplied as nsec with password")?
191 } else {
192 s = nsec.to_string();
193 nostr::Keys::from_str(nsec).context("invalid nsec parameter")?
194 };
195 if save_local {
196 if let Some(password) = password {
197 s = encrypt_key(&keys, password)?;
198 }
199 save_to_git_config(
200 git_repo,
201 &keys.public_key().to_bech32()?,
202 &Some(s),
203 &None,
204 false,
205 )
206 .context("failed to save encrypted nsec in local git config nostr.nsec")?;
207 }
208 Ok(keys)
209}
210
211fn save_to_git_config(
212 git_repo: &Repo,
213 npub: &str,
214 nsec: &Option<String>,
215 bunker: &Option<(String, String)>,
216 global: bool,
217) -> Result<()> {
218 if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) {
219 println!(
220 "failed to save login details to {} git config",
221 if global { "global" } else { "local" }
222 );
223 if let Some(nsec) = nsec {
224 if nsec.contains("ncryptsec") {
225 println!("manually set git config nostr.nsec to: {nsec}");
226 } else {
227 println!("manually set git config nostr.nsec");
228 }
229 }
230 if let Some(bunker) = bunker {
231 println!("manually set git config as follows:");
232 println!("nostr.bunker-uri: {}", bunker.0);
233 println!("nostr.bunker-app-key: {}", bunker.1);
234 }
235 Err(error)
236 } else {
237 println!(
238 "saved login details to {} git config",
239 if global { "global" } else { "local" }
240 );
241 Ok(())
242 }
243}
244fn silently_save_to_git_config(
245 git_repo: &Repo,
246 npub: &str,
247 nsec: &Option<String>,
248 bunker: &Option<(String, String)>,
249 global: bool,
250) -> Result<()> {
251 // must do this first otherwise it might remove the global items just added
252 if global {
253 git_repo.remove_git_config_item("nostr.npub", false)?;
254 git_repo.remove_git_config_item("nostr.nsec", false)?;
255 git_repo.remove_git_config_item("nostr.bunker-uri", false)?;
256 git_repo.remove_git_config_item("nostr.bunker-app-key", false)?;
257 }
258 if let Some(bunker) = bunker {
259 git_repo.remove_git_config_item("nostr.nsec", global)?;
260 git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?;
261 git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?;
262 }
263 if let Some(nsec) = nsec {
264 git_repo.save_git_config_item("nostr.nsec", nsec, global)?;
265 git_repo.remove_git_config_item("nostr.bunker-uri", global)?;
266 git_repo.remove_git_config_item("nostr.bunker-app-key", global)?;
267 }
268 git_repo.save_git_config_item("nostr.npub", npub, global)
269}
270
271fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> {
272 decrypt_key(
273 &git_repo
274 .get_git_config_item("nostr.nsec", None)
275 .context("failed get git config")?
276 .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?,
277 password,
278 )
279 .context("failed to decrypt stored nsec key with provided password")
280}
281
282async fn get_nip46_signer_from_uri_and_key(uri: &str, app_key: &str) -> Result<NostrSigner> {
283 let term = console::Term::stderr();
284 term.write_line("connecting to remote signer...")?;
285 let uri = NostrConnectURI::parse(uri)?;
286 let signer = NostrSigner::nip46(
287 Nip46Signer::new(
288 uri,
289 nostr::Keys::from_str(app_key).context("invalid app key")?,
290 Duration::from_secs(30),
291 None,
292 )
293 .await?,
294 );
295 term.clear_last_lines(1)?;
296 Ok(signer)
297}
298
299async fn get_signer_with_git_config_nsec_or_bunker_without_prompts(
300 git_repo: &Repo,
301) -> Result<NostrSigner> {
302 if let Ok(local_nsec) = &git_repo
303 .get_git_config_item("nostr.nsec", Some(false))
304 .context("failed get local git config")?
305 .context("git local config item nostr.nsec doesn't exist")
306 {
307 if local_nsec.contains("ncryptsec") {
308 bail!("git global config item nostr.nsec is an ncryptsec")
309 }
310 Ok(NostrSigner::Keys(
311 nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?,
312 ))
313 } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(false))
314 {
315 get_nip46_signer_from_uri_and_key(&uri, &app_key).await
316 } else if let Ok(global_nsec) = &git_repo
317 .get_git_config_item("nostr.nsec", Some(true))
318 .context("failed get global git config")?
319 .context("git global config item nostr.nsec doesn't exist")
320 {
321 if global_nsec.contains("ncryptsec") {
322 bail!("git global config item nostr.nsec is an ncryptsec")
323 }
324 Ok(NostrSigner::Keys(
325 nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?,
326 ))
327 } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) {
328 get_nip46_signer_from_uri_and_key(&uri, &app_key).await
329 } else {
330 bail!("cannot get nsec or bunker from git config")
331 }
332}
333
334fn get_git_config_bunker_uri_and_app_key(
335 git_repo: &Repo,
336 global: Option<bool>,
337) -> Result<(String, String)> {
338 Ok((
339 git_repo
340 .get_git_config_item("nostr.bunker-uri", global)
341 .context("failed get local git config")?
342 .context("git local config item nostr.bunker-uri doesn't exist")?
343 .to_string(),
344 git_repo
345 .get_git_config_item("nostr.bunker-app-key", global)
346 .context("failed get local git config")?
347 .context("git local config item nostr.bunker-app-key doesn't exist")?
348 .to_string(),
349 ))
350}
351
352async fn fresh_login(
353 git_repo: &Repo,
354 #[cfg(test)] client: Option<&MockConnect>,
355 #[cfg(not(test))] client: Option<&Client>,
356 always_save: bool,
357) -> Result<(NostrSigner, UserRef)> {
358 let mut public_key: Option<PublicKey> = None;
359 // prompt for nsec
360 let mut prompt = "login with nostr address / nsec";
361 let signer = loop {
362 let input = Interactor::default()
363 .input(PromptInputParms::default().with_prompt(prompt))
364 .context("failed to get nsec input from interactor")?;
365 if let Ok(keys) = nostr::Keys::from_str(&input) {
366 if let Err(error) = save_keys(git_repo, &keys, always_save) {
367 println!("{error}");
368 }
369 break NostrSigner::Keys(keys);
370 }
371 let uri = if let Ok(uri) = NostrConnectURI::parse(&input) {
372 uri
373 } else if input.contains('@') {
374 if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await {
375 uri
376 } else {
377 prompt = "failed. try again with nostr address / bunker uri / nsec";
378 continue;
379 }
380 } else {
381 prompt = "invalid. try again with nostr address / bunker uri / nsec";
382 continue;
383 };
384 let app_key = Keys::generate().secret_key()?.to_secret_hex();
385 match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key).await {
386 Ok(signer) => {
387 let pub_key = fetch_public_key(&signer).await?;
388 if let Err(error) =
389 save_bunker(git_repo, &pub_key, &uri.to_string(), &app_key, always_save)
390 {
391 println!("{error}");
392 }
393 public_key = Some(pub_key);
394 break signer;
395 }
396 Err(_) => {
397 prompt = "failed. try again with nostr address / bunker uri / nsec";
398 }
399 }
400 };
401 let public_key = if let Some(public_key) = public_key {
402 public_key
403 } else {
404 signer.public_key().await?
405 };
406 // lookup profile
407 let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?;
408 print_logged_in_as(&user_ref, client.is_none())?;
409 Ok((signer, user_ref))
410}
411
412pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> {
413 let term = console::Term::stderr();
414 term.write_line("contacting login service provider...")?;
415 let res = nip05::profile(&nip05, None).await;
416 term.clear_last_lines(1)?;
417 match res {
418 Ok(profile) => {
419 if profile.nip46.is_empty() {
420 println!("nip05 provider isn't configured for remote login");
421 bail!("nip05 provider isn't configured for remote login")
422 }
423 Ok(NostrConnectURI::Bunker {
424 signer_public_key: profile.public_key,
425 relays: profile.nip46,
426 secret: None,
427 })
428 }
429 Err(error) => {
430 println!("error contacting login service provider: {error}");
431 Err(error).context("error contacting login service provider")
432 }
433 }
434}
435
436fn save_bunker(
437 git_repo: &Repo,
438 public_key: &PublicKey,
439 uri: &str,
440 app_key: &str,
441 always_save: bool,
442) -> Result<()> {
443 if always_save
444 || Interactor::default()
445 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?
446 {
447 let global = !Interactor::default().confirm(
448 PromptConfirmParms::default()
449 .with_prompt("just for this repository?")
450 .with_default(false),
451 )?;
452 let npub = public_key.to_bech32()?;
453 if let Err(error) = save_to_git_config(
454 git_repo,
455 &npub,
456 &None,
457 &Some((uri.to_string(), app_key.to_string())),
458 global,
459 ) {
460 if global {
461 if Interactor::default().confirm(
462 PromptConfirmParms::default()
463 .with_prompt("save in repository git config?")
464 .with_default(true),
465 )? {
466 save_to_git_config(
467 git_repo,
468 &npub,
469 &None,
470 &Some((uri.to_string(), app_key.to_string())),
471 false,
472 )?;
473 }
474 } else {
475 Err(error)?;
476 }
477 };
478 }
479 Ok(())
480}
481
482fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> {
483 if always_save
484 || Interactor::default()
485 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?
486 {
487 let global = !Interactor::default().confirm(
488 PromptConfirmParms::default()
489 .with_prompt("just for this repository?")
490 .with_default(false),
491 )?;
492
493 let encrypt = Interactor::default().confirm(
494 PromptConfirmParms::default()
495 .with_prompt("require password?")
496 .with_default(false),
497 )?;
498
499 let npub = keys.public_key().to_bech32()?;
500 let nsec_string = if encrypt {
501 let password = Interactor::default()
502 .password(
503 PromptPasswordParms::default()
504 .with_prompt("encrypt with password")
505 .with_confirm(),
506 )
507 .context("failed to get password input from interactor.password")?;
508 encrypt_key(keys, &password)?
509 } else {
510 keys.secret_key()?.to_bech32()?
511 };
512
513 if let Err(error) =
514 save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global)
515 {
516 if global {
517 if Interactor::default().confirm(
518 PromptConfirmParms::default()
519 .with_prompt("save in repository git config?")
520 .with_default(true),
521 )? {
522 save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?;
523 }
524 } else {
525 Err(error)?;
526 }
527 };
528 };
529 Ok(())
530}
531
532fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> {
533 git_repo
534 .get_git_config_item(name, None)
535 .context("failed get git config")?
536 .context(format!("git config item {name} doesn't exist"))
537}
538
539fn extract_user_metadata(
540 public_key: &nostr::PublicKey,
541 events: &[nostr::Event],
542) -> Result<UserMetadata> {
543 let event = events
544 .iter()
545 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
546 .max_by_key(|e| e.created_at);
547
548 let metadata: Option<nostr::Metadata> = if let Some(event) = event {
549 Some(
550 nostr::Metadata::from_json(event.content.clone())
551 .context("metadata cannot be found in kind 0 event content")?,
552 )
553 } else {
554 None
555 };
556
557 Ok(UserMetadata {
558 name: if let Some(metadata) = metadata {
559 if let Some(n) = metadata.name {
560 n
561 } else if let Some(n) = metadata.custom.get("displayName") {
562 // strip quote marks that custom.get() adds
563 let binding = n.to_string();
564 let mut chars = binding.chars();
565 chars.next();
566 chars.next_back();
567 chars.as_str().to_string()
568 } else if let Some(n) = metadata.display_name {
569 n
570 } else {
571 public_key.to_bech32()?
572 }
573 } else {
574 public_key.to_bech32()?
575 },
576 created_at: if let Some(event) = event {
577 event.created_at
578 } else {
579 Timestamp::from(0)
580 },
581 })
582}
583
584fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays {
585 let event = events
586 .iter()
587 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
588 .max_by_key(|e| e.created_at);
589
590 UserRelays {
591 relays: if let Some(event) = event {
592 event
593 .tags
594 .iter()
595 .filter(|t| {
596 t.kind()
597 .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(
598 Alphabet::R,
599 )))
600 })
601 .map(|t| UserRelayRef {
602 url: t.as_vec()[1].clone(),
603 read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"),
604 write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"),
605 })
606 .collect()
607 } else {
608 vec![]
609 },
610 created_at: if let Some(event) = event {
611 event.created_at
612 } else {
613 Timestamp::from(0)
614 },
615 }
616}
617
618async fn get_user_details(
619 public_key: &PublicKey,
620 #[cfg(test)] client: Option<&crate::client::MockConnect>,
621 #[cfg(not(test))] client: Option<&Client>,
622 git_repo_path: &Path,
623 cache_only: bool,
624) -> Result<UserRef> {
625 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
626 Ok(user_ref)
627 } else {
628 let empty = UserRef {
629 public_key: public_key.to_owned(),
630 metadata: extract_user_metadata(public_key, &[])?,
631 relays: extract_user_relays(public_key, &[]),
632 };
633 if cache_only {
634 Ok(empty)
635 } else if let Some(client) = client {
636 let term = console::Term::stderr();
637 term.write_line("searching for profile...")?;
638 let (_, progress_reporter) = client
639 .fetch_all(
640 git_repo_path,
641 &HashSet::new(),
642 &HashSet::from_iter(vec![*public_key]),
643 )
644 .await?;
645 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
646 progress_reporter.clear()?;
647 // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;}
648 Ok(user_ref)
649 } else {
650 Ok(empty)
651 }
652 } else {
653 Ok(empty)
654 }
655 }
656}
657pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> {
658 let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?;
659 Ok(
660 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
661 if let Ok(pubic_key) = PublicKey::parse(npub) {
662 Some(pubic_key)
663 } else {
664 None
665 }
666 } else {
667 None
668 },
669 )
670}
671
672pub async fn get_user_ref_from_cache(
673 git_repo_path: &Path,
674 public_key: &PublicKey,
675) -> Result<UserRef> {
676 let filters = vec![
677 nostr::Filter::default()
678 .author(*public_key)
679 .kind(Kind::Metadata),
680 nostr::Filter::default()
681 .author(*public_key)
682 .kind(Kind::RelayList),
683 ];
684
685 let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?;
686
687 if events.is_empty() {
688 bail!("no metadata and profile list in cache for selected public key");
689 }
690 Ok(UserRef {
691 public_key: public_key.to_owned(),
692 metadata: extract_user_metadata(public_key, &events)?,
693 relays: extract_user_relays(public_key, &events),
694 })
695}