upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/lib/login
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/login')
-rw-r--r--src/lib/login/existing.rs212
-rw-r--r--src/lib/login/fresh.rs595
-rw-r--r--src/lib/login/key_encryption.rs38
-rw-r--r--src/lib/login/mod.rs883
-rw-r--r--src/lib/login/user.rs155
5 files changed, 1036 insertions, 847 deletions
diff --git a/src/lib/login/existing.rs b/src/lib/login/existing.rs
new file mode 100644
index 0000000..e388a34
--- /dev/null
+++ b/src/lib/login/existing.rs
@@ -0,0 +1,212 @@
1use std::{str::FromStr, sync::Arc, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use nostr::nips::nip46::NostrConnectURI;
5use nostr_connect::client::NostrConnect;
6use nostr_sdk::{NostrSigner, PublicKey};
7
8use super::{
9 key_encryption::decrypt_key,
10 print_logged_in_as,
11 user::{get_user_details, UserRef},
12 SignerInfo, SignerInfoSource,
13};
14#[cfg(not(test))]
15use crate::client::Client;
16#[cfg(test)]
17use crate::client::MockConnect;
18use crate::{
19 cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms},
20 client::fetch_public_key,
21 git::{get_git_config_item, Repo, RepoActions},
22};
23
24/// load signer from git config and UserProfile from cache or relays
25///
26/// # Parameters
27/// - `client`: include client to fetch profiles from relays that are missing
28/// from cache
29/// - `silent`: do not print outcome in termianl
30pub async fn load_existing_login(
31 git_repo: &Option<&Repo>,
32 signer_info: &Option<SignerInfo>,
33 password: &Option<String>,
34 source: &Option<SignerInfoSource>,
35 #[cfg(test)] client: Option<&MockConnect>,
36 #[cfg(not(test))] client: Option<&Client>,
37 silent: bool,
38 prompt_for_password: bool,
39) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> {
40 let (signer_info, source) = get_signer_info(git_repo, signer_info, password, source)?;
41
42 let (signer, public_key) = get_signer(&signer_info, prompt_for_password).await?;
43
44 let user_ref = get_user_details(
45 &public_key,
46 client,
47 if let Some(git_repo) = git_repo {
48 Some(git_repo.get_path()?)
49 } else {
50 None
51 },
52 silent,
53 )
54 .await?;
55
56 if !silent {
57 print_logged_in_as(&user_ref, client.is_none(), &source)?;
58 }
59 Ok((signer, user_ref, source))
60}
61
62/// priority order: cli arguments, local git config, global git config
63fn get_signer_info(
64 git_repo: &Option<&Repo>,
65 signer_info: &Option<SignerInfo>,
66 password: &Option<String>,
67 source: &Option<SignerInfoSource>,
68) -> Result<(SignerInfo, SignerInfoSource)> {
69 Ok(match source {
70 None => {
71 let mut result = None;
72 for source in &[
73 SignerInfoSource::CommandLineArguments,
74 SignerInfoSource::GitLocal,
75 SignerInfoSource::GitGlobal,
76 ] {
77 if let Ok(res) =
78 get_signer_info(git_repo, signer_info, password, &Some(source.clone()))
79 {
80 result = Some(res);
81 break;
82 }
83 }
84 result.context("cannot get or find signer info in cli arguments, local git config or global git config")?
85 }
86 Some(SignerInfoSource::CommandLineArguments) => {
87 if let Some(signer_info) = signer_info {
88 (signer_info.clone(), SignerInfoSource::CommandLineArguments)
89 } else {
90 bail!("cannot get signer from cli signer arguments because none were specified")
91 }
92 }
93 Some(SignerInfoSource::GitLocal) => {
94 let git_repo =
95 git_repo.context("failed to get local git config as no git_repo supplied")?;
96 if let Ok(nsec) = get_git_config_item(&Some(git_repo), "nostr.nsec")
97 .context("failed get local git config")?
98 .context("git local config item nostr.nsec doesn't exist")
99 {
100 (
101 SignerInfo::Nsec {
102 nsec: nsec.to_string(),
103 password: password.clone(),
104 npub: get_git_config_item(&Some(git_repo), "nostr.npub")
105 .context("failed get local git config")?,
106 },
107 SignerInfoSource::GitLocal,
108 )
109 } else if let Ok(bunker_uri) = get_git_config_item(&Some(git_repo), "nostr.bunker-uri")
110 .context("failed get local git config")?
111 .context("git local config item nostr.bunker-uri doesn't exist")
112 {
113 (SignerInfo::Bunker {
114 bunker_uri, bunker_app_key: get_git_config_item(&Some(git_repo), "nostr.bunker-app-key")
115 .context("failed get local git config")?
116 .context("git local config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?,
117 npub: get_git_config_item(&Some(git_repo), "nostr.npub")
118 .context("failed get local git config")?,
119 }, SignerInfoSource::GitLocal)
120 } else {
121 bail!("no signer info in local git config")
122 }
123 }
124 Some(SignerInfoSource::GitGlobal) => {
125 if let Some(nsec) = get_git_config_item(&None, "nostr.nsec")
126 .context("failed to get global git config")?
127 {
128 (
129 SignerInfo::Nsec {
130 nsec: nsec.to_string(),
131 password: password.clone(),
132 npub: get_git_config_item(&None, "nostr.npub")
133 .context("failed to get global git config")?,
134 },
135 SignerInfoSource::GitGlobal,
136 )
137 } else if let Some(bunker_uri) = get_git_config_item(&None, "nostr.bunker-uri")
138 .context("failed to get global git config")?
139 {
140 (SignerInfo::Bunker {
141 bunker_uri, bunker_app_key: get_git_config_item(&None, "nostr.bunker-app-key")
142 .context("failed get local git config")?
143 .context("git global config item nostr.bunker-uri exists but nostr.bunker-app-key doesn't")?,
144 npub: get_git_config_item(&None, "nostr.npub")
145 .context("failed get global git config")?,
146 }, SignerInfoSource::GitGlobal)
147 } else {
148 bail!("no signer info in global git config")
149 }
150 }
151 })
152}
153
154async fn get_signer(
155 signer_info: &SignerInfo,
156 prompt_for_ncryptsec_password: bool,
157) -> Result<(Arc<dyn NostrSigner>, PublicKey)> {
158 match signer_info {
159 SignerInfo::Nsec {
160 nsec,
161 password,
162 npub: _,
163 } => {
164 let keys = if nsec.contains("ncryptsec") {
165 // TODO get user details from npub
166 // TODO add retry loop
167 // TODO in retry loop give option to login again
168 let password = if let Some(password) = password {
169 password.clone()
170 } else {
171 if !prompt_for_ncryptsec_password {
172 bail!("cannot login without prompts a nsec is encrypted with a password");
173 }
174 Interactor::default()
175 .password(PromptPasswordParms::default().with_prompt("password"))
176 .context("failed to get password input from interactor.password")?
177 };
178 decrypt_key(nsec, password.clone().as_str())
179 .context("failed to decrypt key with provided password")
180 .context("failed to decrypt ncryptsec supplied as nsec with password")?
181 } else {
182 nostr::Keys::from_str(nsec).context("invalid nsec parameter")?
183 };
184 let public_key = keys.public_key();
185 Ok((Arc::new(keys), public_key))
186 }
187 SignerInfo::Bunker {
188 bunker_uri,
189 bunker_app_key,
190 npub,
191 } => {
192 let term = console::Term::stderr();
193 term.write_line("connecting to remote signer...")?;
194 let uri = NostrConnectURI::parse(bunker_uri)?;
195 let signer: Arc<dyn NostrSigner> = Arc::new(NostrConnect::new(
196 uri,
197 nostr::Keys::from_str(bunker_app_key).context("invalid app key")?,
198 Duration::from_secs(10 * 60),
199 None,
200 )?);
201 term.clear_last_lines(1)?;
202 let public_key = if let Some(pubic_key) =
203 npub.clone().and_then(|npub| PublicKey::parse(npub).ok())
204 {
205 pubic_key
206 } else {
207 fetch_public_key(&signer).await?
208 };
209 Ok((signer, public_key))
210 }
211 }
212}
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}
diff --git a/src/lib/login/key_encryption.rs b/src/lib/login/key_encryption.rs
index b50b507..efb38d1 100644
--- a/src/lib/login/key_encryption.rs
+++ b/src/lib/login/key_encryption.rs
@@ -1,23 +1,5 @@
1use anyhow::Result; 1use anyhow::Result;
2use nostr::{prelude::*, Keys}; 2use nostr::prelude::*;
3
4pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> {
5 let log2_rounds: u8 = if password.len() > 20 {
6 // we have enough of entropy - no need to spend CPU time adding much more
7 1
8 } else {
9 println!("this may take a few seconds...");
10 // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait
11 15
12 };
13 Ok(nostr::nips::nip49::EncryptedSecretKey::new(
14 keys.secret_key(),
15 password,
16 log2_rounds,
17 KeySecurity::Medium,
18 )?
19 .to_bech32()?)
20}
21 3
22pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> { 4pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> {
23 let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?; 5 let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?;
@@ -34,6 +16,24 @@ mod tests {
34 16
35 use super::*; 17 use super::*;
36 18
19 pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> {
20 let log2_rounds: u8 = if password.len() > 20 {
21 // we have enough of entropy - no need to spend CPU time adding much more
22 1
23 } else {
24 println!("this may take a few seconds...");
25 // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait
26 15
27 };
28 Ok(nostr::nips::nip49::EncryptedSecretKey::new(
29 keys.secret_key(),
30 password,
31 log2_rounds,
32 KeySecurity::Medium,
33 )?
34 .to_bech32()?)
35 }
36
37 #[test] 37 #[test]
38 fn encrypt_key_produces_string_prefixed_with() -> Result<()> { 38 fn encrypt_key_produces_string_prefixed_with() -> Result<()> {
39 let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; 39 let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?;
diff --git a/src/lib/login/mod.rs b/src/lib/login/mod.rs
index 0e00170..b45bc1d 100644
--- a/src/lib/login/mod.rs
+++ b/src/lib/login/mod.rs
@@ -1,129 +1,65 @@
1use std::{collections::HashSet, path::Path, str::FromStr, sync::Arc, time::Duration}; 1use std::{path::Path, sync::Arc};
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::Result;
4use console::Style; 4use fresh::fresh_login_or_signup;
5use dialoguer::theme::{ColorfulTheme, Theme}; 5use nostr::PublicKey;
6use nostr::{ 6use nostr_sdk::{NostrSigner, Timestamp, ToBech32};
7 nips::{nip05, nip46::NostrConnectURI},
8 PublicKey,
9};
10use nostr_connect::client::NostrConnect;
11use nostr_sdk::{
12 Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrSigner, SingleLetterTag, Timestamp, ToBech32,
13 Url,
14};
15use qrcode::QrCode;
16use tokio::sync::{oneshot, Mutex};
17 7
18#[cfg(not(test))] 8#[cfg(not(test))]
19use crate::client::Client; 9use crate::client::Client;
20#[cfg(test)] 10#[cfg(test)]
21use crate::client::MockConnect; 11use crate::client::MockConnect;
22use crate::{ 12use crate::git::{Repo, RepoActions};
23 cli_interactor::{
24 Interactor, InteractorPrompt, Printer, PromptConfirmParms, PromptInputParms,
25 PromptPasswordParms,
26 },
27 client::{fetch_public_key, get_event_from_global_cache, Connect},
28 git::{Repo, RepoActions},
29};
30 13
14pub mod existing;
31mod key_encryption; 15mod key_encryption;
32use key_encryption::{decrypt_key, encrypt_key}; 16use existing::load_existing_login;
33mod user; 17pub mod user;
34use user::{UserMetadata, UserRef, UserRelayRef, UserRelays}; 18use user::UserRef;
35 19pub mod fresh;
36/// handles the encrpytion and storage of key material 20
37#[allow(clippy::too_many_arguments)] 21pub async fn login_or_signup(
38pub async fn launch( 22 git_repo: &Option<&Repo>,
39 git_repo: &Repo, 23 signer_info: &Option<SignerInfo>,
40 bunker_uri: &Option<String>,
41 bunker_app_key: &Option<String>,
42 nsec: &Option<String>,
43 password: &Option<String>, 24 password: &Option<String>,
44 #[cfg(test)] client: Option<&MockConnect>, 25 #[cfg(test)] client: Option<&MockConnect>,
45 #[cfg(not(test))] client: Option<&Client>, 26 #[cfg(not(test))] client: Option<&Client>,
46 change_user: bool, 27) -> Result<(Arc<dyn NostrSigner>, UserRef, SignerInfoSource)> {
47 silent: bool, 28 let res =
48) -> Result<(Arc<dyn NostrSigner>, UserRef)> { 29 load_existing_login(git_repo, signer_info, password, &None, client, false, true).await;
49 if let Ok((signer, public_key)) = match get_signer_without_prompts( 30 if res.is_ok() {
50 git_repo, 31 res
51 bunker_uri,
52 bunker_app_key,
53 nsec,
54 password,
55 change_user,
56 )
57 .await
58 {
59 Ok((signer, public_key)) => Ok((signer, public_key)),
60 Err(error) => {
61 if error
62 .to_string()
63 .eq("git config item nostr.nsec is an ncryptsec")
64 {
65 eprintln!(
66 "login as {}",
67 if let Ok(public_key) = PublicKey::from_bech32(
68 get_config_item(git_repo, "nostr.npub").unwrap_or("unknown".to_string()),
69 ) {
70 if let Ok(user_ref) =
71 get_user_details(&public_key, client, git_repo.get_path()?, silent)
72 .await
73 {
74 user_ref.metadata.name
75 } else {
76 "unknown ncryptsec".to_string()
77 }
78 } else {
79 "unknown ncryptsec".to_string()
80 }
81 );
82 loop {
83 // prompt for password
84 let password = Interactor::default()
85 .password(PromptPasswordParms::default().with_prompt("password"))
86 .context("failed to get password input from interactor.password")?;
87 if let Ok(keys) = get_keys_with_password(git_repo, &password) {
88 break Ok((Arc::new(keys) as Arc<dyn NostrSigner>, None));
89 }
90 eprintln!("incorrect password");
91 }
92 } else {
93 if nsec.is_some() {
94 bail!(error);
95 }
96 Err(error)
97 }
98 }
99 } {
100 let user_ref = get_user_details(
101 // Note: if rust-nostr NostrConnect::new() were updated to accept user public key as
102 // requested then the added complexity added in this commit can be undone
103 &(if let Some(public_key) = public_key {
104 public_key
105 } else {
106 signer
107 .get_public_key()
108 .await
109 .context("cannot get public key from signer")?
110 }),
111 client,
112 git_repo.get_path()?,
113 silent,
114 )
115 .await?;
116
117 if !silent {
118 print_logged_in_as(&user_ref, client.is_none())?;
119 }
120 Ok((signer, user_ref))
121 } else { 32 } else {
122 fresh_login(git_repo, client, change_user).await 33 fresh_login_or_signup(git_repo, client, false).await
123 } 34 }
124} 35}
125 36
126fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { 37#[derive(Clone)]
38pub enum SignerInfo {
39 Nsec {
40 nsec: String,
41 password: Option<String>,
42 npub: Option<String>,
43 },
44 Bunker {
45 bunker_uri: String,
46 bunker_app_key: String,
47 npub: Option<String>,
48 },
49}
50
51#[derive(PartialEq, Clone)]
52pub enum SignerInfoSource {
53 GitLocal,
54 GitGlobal,
55 CommandLineArguments,
56}
57
58fn print_logged_in_as(
59 user_ref: &UserRef,
60 offline_mode: bool,
61 source: &SignerInfoSource,
62) -> Result<()> {
127 if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) { 63 if !offline_mode && user_ref.metadata.created_at.eq(&Timestamp::from(0)) {
128 eprintln!("cannot find profile..."); 64 eprintln!("cannot find profile...");
129 } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { 65 } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) {
@@ -133,703 +69,21 @@ fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> {
133 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." 69 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
134 ); 70 );
135 } 71 }
136 eprintln!("logged in as {}", user_ref.metadata.name); 72 eprintln!(
137 Ok(()) 73 "logged in as {}{}",
138} 74 user_ref.metadata.name,
139 75 match source {
140async fn get_signer_without_prompts( 76 SignerInfoSource::CommandLineArguments => " via cli arguments",
141 git_repo: &Repo, 77 SignerInfoSource::GitLocal => " just to local repository",
142 bunker_uri: &Option<String>, 78 SignerInfoSource::GitGlobal => "",
143 bunker_app_key: &Option<String>,
144 nsec: &Option<String>,
145 password: &Option<String>,
146 save_local: bool,
147) -> Result<(Arc<dyn NostrSigner>, Option<PublicKey>)> {
148 if let Some(nsec) = nsec {
149 Ok((
150 Arc::new(get_keys_from_nsec(git_repo, nsec, password, save_local)?),
151 None,
152 ))
153 } else if let Some(password) = password {
154 Ok((Arc::new(get_keys_with_password(git_repo, password)?), None))
155 } else if let Some(bunker_uri) = bunker_uri {
156 if let Some(bunker_app_key) = bunker_app_key {
157 let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key)
158 .await
159 .context("failed to connect with remote signer")?;
160 if save_local {
161 save_to_git_config(
162 git_repo,
163 &signer.get_public_key().await?.to_bech32()?,
164 &None,
165 &Some((bunker_uri.to_string(),bunker_app_key.to_string())),
166 false,
167 )
168 .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?;
169 }
170 Ok((signer, None))
171 } else {
172 bail!(
173 "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively."
174 )
175 }
176 } else if !save_local {
177 get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await
178 } else {
179 bail!("user wants prompts to specify new keys")
180 }
181}
182
183fn get_keys_from_nsec(
184 git_repo: &Repo,
185 nsec: &String,
186 password: &Option<String>,
187 save_local: bool,
188) -> Result<nostr::Keys> {
189 #[allow(unused_assignments)]
190 let mut s = String::new();
191 let keys = if nsec.contains("ncryptsec") {
192 s = nsec.to_string();
193 decrypt_key(
194 nsec,
195 password
196 .clone()
197 .context("password must be supplied when using ncryptsec as nsec parameter")?
198 .as_str(),
199 )
200 .context("failed to decrypt key with provided password")
201 .context("failed to decrypt ncryptsec supplied as nsec with password")?
202 } else {
203 s = nsec.to_string();
204 nostr::Keys::from_str(nsec).context("invalid nsec parameter")?
205 };
206 if save_local {
207 if let Some(password) = password {
208 s = encrypt_key(&keys, password)?;
209 }
210 save_to_git_config(
211 git_repo,
212 &keys.public_key().to_bech32()?,
213 &Some(s),
214 &None,
215 false,
216 )
217 .context("failed to save encrypted nsec in local git config nostr.nsec")?;
218 }
219 Ok(keys)
220}
221
222fn save_to_git_config(
223 git_repo: &Repo,
224 npub: &str,
225 nsec: &Option<String>,
226 bunker: &Option<(String, String)>,
227 global: bool,
228) -> Result<()> {
229 if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) {
230 eprintln!(
231 "failed to save login details to {} git config",
232 if global { "global" } else { "local" }
233 );
234 if let Some(nsec) = nsec {
235 if nsec.contains("ncryptsec") {
236 eprintln!("manually set git config nostr.nsec to: {nsec}");
237 } else {
238 eprintln!("manually set git config nostr.nsec");
239 }
240 }
241 if let Some(bunker) = bunker {
242 eprintln!("manually set git config as follows:");
243 eprintln!("nostr.bunker-uri: {}", bunker.0);
244 eprintln!("nostr.bunker-app-key: {}", bunker.1);
245 }
246 Err(error)
247 } else {
248 eprintln!(
249 "saved login details to {} git config",
250 if global { "global" } else { "local" }
251 );
252 Ok(())
253 }
254}
255fn silently_save_to_git_config(
256 git_repo: &Repo,
257 npub: &str,
258 nsec: &Option<String>,
259 bunker: &Option<(String, String)>,
260 global: bool,
261) -> Result<()> {
262 // must do this first otherwise it might remove the global items just added
263 if global {
264 git_repo.remove_git_config_item("nostr.npub", false)?;
265 git_repo.remove_git_config_item("nostr.nsec", false)?;
266 git_repo.remove_git_config_item("nostr.bunker-uri", false)?;
267 git_repo.remove_git_config_item("nostr.bunker-app-key", false)?;
268 }
269 if let Some(bunker) = bunker {
270 git_repo.remove_git_config_item("nostr.nsec", global)?;
271 git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?;
272 git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?;
273 }
274 if let Some(nsec) = nsec {
275 git_repo.save_git_config_item("nostr.nsec", nsec, global)?;
276 git_repo.remove_git_config_item("nostr.bunker-uri", global)?;
277 git_repo.remove_git_config_item("nostr.bunker-app-key", global)?;
278 }
279 git_repo.save_git_config_item("nostr.npub", npub, global)
280}
281
282fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> {
283 decrypt_key(
284 &git_repo
285 .get_git_config_item("nostr.nsec", None)
286 .context("failed get git config")?
287 .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?,
288 password,
289 )
290 .context("failed to decrypt stored nsec key with provided password")
291}
292
293async fn get_nip46_signer_from_uri_and_key(
294 uri: &str,
295 app_key: &str,
296) -> Result<Arc<dyn NostrSigner>> {
297 let term = console::Term::stderr();
298 term.write_line("connecting to remote signer...")?;
299 let uri = NostrConnectURI::parse(uri)?;
300 let signer = Arc::new(NostrConnect::new(
301 uri,
302 nostr::Keys::from_str(app_key).context("invalid app key")?,
303 Duration::from_secs(10 * 60),
304 None,
305 )?);
306 term.clear_last_lines(1)?;
307 Ok(signer)
308}
309
310async fn get_signer_with_git_config_nsec_or_bunker_without_prompts(
311 git_repo: &Repo,
312) -> Result<(Arc<dyn NostrSigner>, Option<PublicKey>)> {
313 if let Ok(local_nsec) = &git_repo
314 .get_git_config_item("nostr.nsec", Some(false))
315 .context("failed get local git config")?
316 .context("git local config item nostr.nsec doesn't exist")
317 {
318 if local_nsec.contains("ncryptsec") {
319 bail!("git global config item nostr.nsec is an ncryptsec")
320 }
321 Ok((
322 Arc::new(nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?),
323 None,
324 ))
325 } else if let Ok((uri, app_key, npub)) =
326 get_git_config_bunker_uri_and_app_key(git_repo, Some(false))
327 {
328 Ok((
329 get_nip46_signer_from_uri_and_key(&uri, &app_key).await?,
330 if let Ok(pubic_key) = PublicKey::parse(npub) {
331 Some(pubic_key)
332 } else {
333 None
334 },
335 ))
336 } else if let Ok(global_nsec) = &git_repo
337 .get_git_config_item("nostr.nsec", Some(true))
338 .context("failed get global git config")?
339 .context("git global config item nostr.nsec doesn't exist")
340 {
341 if global_nsec.contains("ncryptsec") {
342 bail!("git global config item nostr.nsec is an ncryptsec")
343 }
344 Ok((
345 Arc::new(nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?),
346 None,
347 ))
348 } else if let Ok((uri, app_key, npub)) =
349 get_git_config_bunker_uri_and_app_key(git_repo, Some(true))
350 {
351 Ok((
352 get_nip46_signer_from_uri_and_key(&uri, &app_key).await?,
353 if let Ok(pubic_key) = PublicKey::parse(npub) {
354 Some(pubic_key)
355 } else {
356 None
357 },
358 ))
359 } else {
360 bail!("cannot get nsec or bunker from git config")
361 }
362}
363
364fn get_git_config_bunker_uri_and_app_key(
365 git_repo: &Repo,
366 global: Option<bool>,
367) -> Result<(String, String, String)> {
368 Ok((
369 git_repo
370 .get_git_config_item("nostr.bunker-uri", global)
371 .context("failed get local git config")?
372 .context("git local config item nostr.bunker-uri doesn't exist")?
373 .to_string(),
374 git_repo
375 .get_git_config_item("nostr.bunker-app-key", global)
376 .context("failed get local git config")?
377 .context("git local config item nostr.bunker-app-key doesn't exist")?
378 .to_string(),
379 git_repo
380 .get_git_config_item("nostr.npub", global)
381 .context("failed get local git config")?
382 .context("git local config item nostr.npub doesn't exist")?
383 .to_string(),
384 ))
385}
386
387async fn fresh_login(
388 git_repo: &Repo,
389 #[cfg(test)] client: Option<&MockConnect>,
390 #[cfg(not(test))] client: Option<&Client>,
391 always_save: bool,
392) -> Result<(Arc<dyn NostrSigner>, UserRef)> {
393 let app_key = Keys::generate();
394 let app_key_secret = app_key.secret_key().to_secret_hex();
395 let relays = if let Some(client) = client {
396 client
397 .get_fallback_signer_relays()
398 .iter()
399 .flat_map(|s| Url::parse(s))
400 .collect::<Vec<Url>>()
401 } else {
402 vec![]
403 };
404 let offline = client.is_none();
405 let nostr_connect_url = NostrConnectURI::client(app_key.public_key(), relays.clone(), "ngit");
406 let qr = generate_qr(&nostr_connect_url.to_string())?;
407
408 let printer = Arc::new(Mutex::new(Printer::default()));
409 if !offline {
410 let printer_clone = Arc::clone(&printer);
411 let mut printer_locked = printer_clone.lock().await;
412 printer_locked.printlns(qr);
413 printer_locked.println(format!(
414 "scan QR or paste into remote signer: {nostr_connect_url}"
415 ));
416 printer_locked.println_with_custom_formatting(
417 {
418 let mut s = String::new();
419 let _ = ColorfulTheme::default().format_confirm_prompt(
420 &mut s,
421 "login with nsec / bunker url / nostr address instead",
422 Some(true),
423 );
424 s
425 },
426 "? login with nsec / bunker url / nostr address instead? (y/n) › yes".to_string(),
427 );
428 }
429
430 let (tx, rx) = oneshot::channel();
431 let printer_clone = Arc::clone(&printer);
432
433 let qr_listener = tokio::spawn(async move {
434 if offline {
435 return;
436 }
437 if let Ok(nostr_connect) = NostrConnect::new(
438 nostr_connect_url.clone(),
439 app_key.clone(),
440 Duration::from_secs(10 * 60),
441 None,
442 ) {
443 let signer: Arc<dyn NostrSigner> = Arc::new(nostr_connect);
444 if let Ok(pub_key) = fetch_public_key(&signer).await {
445 let mut printer_locked = printer_clone.lock().await;
446 printer_locked.clear_all();
447
448 printer_locked.println_with_custom_formatting(
449 format!(
450 "{}",
451 Style::new().bold().apply_to("connected to remote signer"),
452 ),
453 "connected to remote signer".to_string(),
454 );
455 printer_locked.println("press any key to continue...".to_string());
456 let _ = tx.send(Some((signer, pub_key)));
457 }
458 }
459 });
460 if !offline {
461 let _ = console::Term::stderr().read_char();
462 }
463 qr_listener.abort();
464 let printer_clone = Arc::clone(&printer);
465 let mut printer = printer_clone.lock().await;
466 printer.clear_all();
467
468 let (signer, public_key) = {
469 if let Ok(Some((signer, public_key))) = rx.await {
470 let bunker_url = NostrConnectURI::Bunker {
471 remote_signer_public_key: public_key,
472 relays: relays.clone(),
473 secret: None,
474 };
475 if let Err(error) = save_bunker(
476 git_repo,
477 &public_key,
478 &bunker_url.to_string(),
479 &app_key_secret,
480 always_save,
481 ) {
482 eprintln!("{error}");
483 }
484 (signer, public_key)
485 } else {
486 let mut public_key: Option<PublicKey> = None;
487 // prompt for nsec
488 let mut prompt = "login with nsec / bunker url / nostr address";
489 let signer: Arc<dyn NostrSigner> = loop {
490 let input = Interactor::default()
491 .input(PromptInputParms::default().with_prompt(prompt))
492 .context("failed to get nsec input from interactor")?;
493 if let Ok(keys) = nostr::Keys::from_str(&input) {
494 if let Err(error) = save_keys(git_repo, &keys, always_save) {
495 eprintln!("{error}");
496 }
497 break Arc::new(keys);
498 }
499 let uri = if let Ok(uri) = NostrConnectURI::parse(&input) {
500 uri
501 } else if input.contains('@') {
502 if let Ok(uri) = fetch_nip46_uri_from_nip05(&input).await {
503 uri
504 } else {
505 prompt = "failed. try again with nostr address / bunker uri / nsec";
506 continue;
507 }
508 } else {
509 prompt = "invalid. try again with nostr address / bunker uri / nsec";
510 continue;
511 };
512 match get_nip46_signer_from_uri_and_key(&uri.to_string(), &app_key_secret).await {
513 Ok(signer) => {
514 let pub_key = fetch_public_key(&signer).await?;
515 if let Err(error) = save_bunker(
516 git_repo,
517 &pub_key,
518 &uri.to_string(),
519 &app_key_secret,
520 always_save,
521 ) {
522 eprintln!("{error}");
523 }
524 public_key = Some(pub_key);
525 break signer;
526 }
527 Err(_) => {
528 prompt = "failed. try again with nostr address / bunker uri / nsec";
529 }
530 }
531 };
532 let public_key = if let Some(public_key) = public_key {
533 public_key
534 } else {
535 signer.get_public_key().await?
536 };
537 (signer, public_key)
538 }
539 };
540 // lookup profile
541 let user_ref = get_user_details(&public_key, client, git_repo.get_path()?, false).await?;
542 print_logged_in_as(&user_ref, client.is_none())?;
543 Ok((signer, user_ref))
544}
545
546fn generate_qr(data: &str) -> Result<Vec<String>> {
547 let mut lines = vec![];
548 let qr =
549 QrCode::new(data.as_bytes()).context("failed to create QR of nostrconnect login url")?;
550 let colors = qr.to_colors();
551 let rows: Vec<&[qrcode::Color]> = colors.chunks(qr.width()).collect();
552 for (row, data) in rows.iter().enumerate() {
553 let odd = row % 2 != 0;
554 if odd {
555 continue;
556 }
557 let mut line = String::new();
558 for (col, color) in data.iter().enumerate() {
559 let top = color;
560 let mut bottom = qrcode::Color::Light;
561 if let Some(next_row_data) = rows.get(row + 1) {
562 if let Some(color) = next_row_data.get(col) {
563 bottom = *color;
564 }
565 }
566 line.push(if *top == qrcode::Color::Dark {
567 if bottom == qrcode::Color::Dark {
568 '█'
569 } else {
570 '▀'
571 }
572 } else if bottom == qrcode::Color::Dark {
573 '▄'
574 } else {
575 ' '
576 });
577 }
578 lines.push(line);
579 }
580 Ok(lines)
581}
582
583pub async fn fetch_nip46_uri_from_nip05(nip05: &str) -> Result<NostrConnectURI> {
584 let term = console::Term::stderr();
585 term.write_line("contacting login service provider...")?;
586 let res = nip05::profile(&nip05, None).await;
587 term.clear_last_lines(1)?;
588 match res {
589 Ok(profile) => {
590 if profile.nip46.is_empty() {
591 eprintln!("nip05 provider isn't configured for remote login");
592 bail!("nip05 provider isn't configured for remote login")
593 }
594 Ok(NostrConnectURI::Bunker {
595 remote_signer_public_key: profile.public_key,
596 relays: profile.nip46,
597 secret: None,
598 })
599 } 79 }
600 Err(error) => { 80 );
601 eprintln!("error contacting login service provider: {error}");
602 Err(error).context("error contacting login service provider")
603 }
604 }
605}
606
607fn save_bunker(
608 git_repo: &Repo,
609 public_key: &PublicKey,
610 uri: &str,
611 app_key: &str,
612 always_save: bool,
613) -> Result<()> {
614 if always_save
615 || Interactor::default()
616 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?
617 {
618 let global = !Interactor::default().confirm(
619 PromptConfirmParms::default()
620 .with_prompt("save login just for this repository?")
621 .with_default(false),
622 )?;
623 let npub = public_key.to_bech32()?;
624 if let Err(error) = save_to_git_config(
625 git_repo,
626 &npub,
627 &None,
628 &Some((uri.to_string(), app_key.to_string())),
629 global,
630 ) {
631 if global {
632 if Interactor::default().confirm(
633 PromptConfirmParms::default()
634 .with_prompt("save in repository git config?")
635 .with_default(true),
636 )? {
637 save_to_git_config(
638 git_repo,
639 &npub,
640 &None,
641 &Some((uri.to_string(), app_key.to_string())),
642 false,
643 )?;
644 }
645 } else {
646 Err(error)?;
647 }
648 };
649 }
650 Ok(())
651}
652
653fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> {
654 if always_save
655 || Interactor::default()
656 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?
657 {
658 let global = !Interactor::default().confirm(
659 PromptConfirmParms::default()
660 .with_prompt("just for this repository?")
661 .with_default(false),
662 )?;
663
664 let encrypt = Interactor::default().confirm(
665 PromptConfirmParms::default()
666 .with_prompt("require password?")
667 .with_default(false),
668 )?;
669
670 let npub = keys.public_key().to_bech32()?;
671 let nsec_string = if encrypt {
672 let password = Interactor::default()
673 .password(
674 PromptPasswordParms::default()
675 .with_prompt("encrypt with password")
676 .with_confirm(),
677 )
678 .context("failed to get password input from interactor.password")?;
679 encrypt_key(keys, &password)?
680 } else {
681 keys.secret_key().to_bech32()?
682 };
683
684 if let Err(error) =
685 save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global)
686 {
687 if global {
688 if Interactor::default().confirm(
689 PromptConfirmParms::default()
690 .with_prompt("save in repository git config?")
691 .with_default(true),
692 )? {
693 save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?;
694 }
695 } else {
696 eprintln!("{error}");
697 Err(error)?;
698 }
699 };
700 };
701 Ok(()) 81 Ok(())
702} 82}
703 83
704fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> {
705 git_repo
706 .get_git_config_item(name, None)
707 .context("failed get git config")?
708 .context(format!("git config item {name} doesn't exist"))
709}
710
711fn extract_user_metadata(
712 public_key: &nostr::PublicKey,
713 events: &[nostr::Event],
714) -> Result<UserMetadata> {
715 let event = events
716 .iter()
717 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
718 .max_by_key(|e| e.created_at);
719
720 let metadata: Option<nostr::Metadata> = if let Some(event) = event {
721 Some(
722 nostr::Metadata::from_json(event.content.clone())
723 .context("metadata cannot be found in kind 0 event content")?,
724 )
725 } else {
726 None
727 };
728
729 Ok(UserMetadata {
730 name: if let Some(metadata) = metadata {
731 if let Some(n) = metadata.name {
732 n
733 } else if let Some(n) = metadata.custom.get("displayName") {
734 // strip quote marks that custom.get() adds
735 let binding = n.to_string();
736 let mut chars = binding.chars();
737 chars.next();
738 chars.next_back();
739 chars.as_str().to_string()
740 } else if let Some(n) = metadata.display_name {
741 n
742 } else {
743 public_key.to_bech32()?
744 }
745 } else {
746 public_key.to_bech32()?
747 },
748 created_at: if let Some(event) = event {
749 event.created_at
750 } else {
751 Timestamp::from(0)
752 },
753 })
754}
755
756fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays {
757 let event = events
758 .iter()
759 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
760 .max_by_key(|e| e.created_at);
761
762 UserRelays {
763 relays: if let Some(event) = event {
764 event
765 .tags
766 .iter()
767 .filter(|t| {
768 t.kind()
769 .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(
770 Alphabet::R,
771 )))
772 })
773 .map(|t| UserRelayRef {
774 url: t.as_slice()[1].clone(),
775 read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"),
776 write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"),
777 })
778 .collect()
779 } else {
780 vec![]
781 },
782 created_at: if let Some(event) = event {
783 event.created_at
784 } else {
785 Timestamp::from(0)
786 },
787 }
788}
789
790async fn get_user_details(
791 public_key: &PublicKey,
792 #[cfg(test)] client: Option<&crate::client::MockConnect>,
793 #[cfg(not(test))] client: Option<&Client>,
794 git_repo_path: &Path,
795 cache_only: bool,
796) -> Result<UserRef> {
797 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
798 Ok(user_ref)
799 } else {
800 let empty = UserRef {
801 public_key: public_key.to_owned(),
802 metadata: extract_user_metadata(public_key, &[])?,
803 relays: extract_user_relays(public_key, &[]),
804 };
805 if cache_only {
806 Ok(empty)
807 } else if let Some(client) = client {
808 let term = console::Term::stderr();
809 term.write_line("searching for profile...")?;
810 let (_, progress_reporter) = client
811 .fetch_all(
812 git_repo_path,
813 &HashSet::new(),
814 &HashSet::from_iter(vec![*public_key]),
815 )
816 .await?;
817 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
818 progress_reporter.clear()?;
819 // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;}
820 Ok(user_ref)
821 } else {
822 Ok(empty)
823 }
824 } else {
825 Ok(empty)
826 }
827 }
828}
829
830// None: in the edge case where the user is logged in via cli arguments rather 84// None: in the edge case where the user is logged in via cli arguments rather
831// than from git config this may be wrong. TODO: fix this 85// than from git config this may be wrong. TODO: fix this
832pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> { 86pub async fn get_likely_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey>> {
833 let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?; 87 let git_repo = Repo::from_path(&git_repo_path.to_path_buf())?;
834 Ok( 88 Ok(
835 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { 89 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
@@ -844,31 +98,6 @@ pub async fn get_logged_in_user(git_repo_path: &Path) -> Result<Option<PublicKey
844 ) 98 )
845} 99}
846 100
847pub async fn get_user_ref_from_cache(
848 git_repo_path: &Path,
849 public_key: &PublicKey,
850) -> Result<UserRef> {
851 let filters = vec![
852 nostr::Filter::default()
853 .author(*public_key)
854 .kind(Kind::Metadata),
855 nostr::Filter::default()
856 .author(*public_key)
857 .kind(Kind::RelayList),
858 ];
859
860 let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?;
861
862 if events.is_empty() {
863 bail!("no metadata and profile list in cache for selected public key");
864 }
865 Ok(UserRef {
866 public_key: public_key.to_owned(),
867 metadata: extract_user_metadata(public_key, &events)?,
868 relays: extract_user_relays(public_key, &events),
869 })
870}
871
872pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> { 101pub fn get_curent_user(git_repo: &Repo) -> Result<Option<PublicKey>> {
873 Ok( 102 Ok(
874 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? { 103 if let Some(npub) = git_repo.get_git_config_item("nostr.npub", None)? {
diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs
index 46652db..4456308 100644
--- a/src/lib/login/user.rs
+++ b/src/lib/login/user.rs
@@ -1,7 +1,16 @@
1use std::{collections::HashSet, path::Path};
2
3use anyhow::{bail, Context, Result};
1use nostr::PublicKey; 4use nostr::PublicKey;
2use nostr_sdk::Timestamp; 5use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32};
3use serde::{self, Deserialize, Serialize}; 6use serde::{self, Deserialize, Serialize};
4 7
8#[cfg(not(test))]
9use crate::client::Client;
10#[cfg(test)]
11use crate::client::MockConnect;
12use crate::client::{get_event_from_global_cache, Connect};
13
5#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 14#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
6pub struct UserRef { 15pub struct UserRef {
7 pub public_key: PublicKey, 16 pub public_key: PublicKey,
@@ -37,3 +46,147 @@ pub struct UserRelayRef {
37 pub read: bool, 46 pub read: bool,
38 pub write: bool, 47 pub write: bool,
39} 48}
49
50pub async fn get_user_details(
51 public_key: &PublicKey,
52 #[cfg(test)] client: Option<&MockConnect>,
53 #[cfg(not(test))] client: Option<&Client>,
54 git_repo_path: Option<&Path>,
55 cache_only: bool,
56) -> Result<UserRef> {
57 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
58 Ok(user_ref)
59 } else {
60 let empty = UserRef {
61 public_key: public_key.to_owned(),
62 metadata: extract_user_metadata(public_key, &[])?,
63 relays: extract_user_relays(public_key, &[]),
64 };
65 if cache_only {
66 Ok(empty)
67 } else if let Some(client) = client {
68 let term = console::Term::stderr();
69 term.write_line("searching for profile...")?;
70 let (_, progress_reporter) = client
71 .fetch_all(
72 git_repo_path,
73 &HashSet::new(),
74 &HashSet::from_iter(vec![*public_key]),
75 )
76 .await?;
77 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
78 progress_reporter.clear()?;
79 // if std::env::var("NGITTEST").is_err() {term.clear_last_lines(1)?;}
80 Ok(user_ref)
81 } else {
82 Ok(empty)
83 }
84 } else {
85 Ok(empty)
86 }
87 }
88}
89
90pub async fn get_user_ref_from_cache(
91 git_repo_path: Option<&Path>,
92 public_key: &PublicKey,
93) -> Result<UserRef> {
94 let filters = vec![
95 nostr::Filter::default()
96 .author(*public_key)
97 .kind(Kind::Metadata),
98 nostr::Filter::default()
99 .author(*public_key)
100 .kind(Kind::RelayList),
101 ];
102
103 let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?;
104
105 if events.is_empty() {
106 bail!("no metadata and profile list in cache for selected public key");
107 }
108 Ok(UserRef {
109 public_key: public_key.to_owned(),
110 metadata: extract_user_metadata(public_key, &events)?,
111 relays: extract_user_relays(public_key, &events),
112 })
113}
114
115pub fn extract_user_metadata(
116 public_key: &nostr::PublicKey,
117 events: &[nostr::Event],
118) -> Result<UserMetadata> {
119 let event = events
120 .iter()
121 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
122 .max_by_key(|e| e.created_at);
123
124 let metadata: Option<nostr::Metadata> = if let Some(event) = event {
125 Some(
126 nostr::Metadata::from_json(event.content.clone())
127 .context("metadata cannot be found in kind 0 event content")?,
128 )
129 } else {
130 None
131 };
132
133 Ok(UserMetadata {
134 name: if let Some(metadata) = metadata {
135 if let Some(n) = metadata.name {
136 n
137 } else if let Some(n) = metadata.custom.get("displayName") {
138 // strip quote marks that custom.get() adds
139 let binding = n.to_string();
140 let mut chars = binding.chars();
141 chars.next();
142 chars.next_back();
143 chars.as_str().to_string()
144 } else if let Some(n) = metadata.display_name {
145 n
146 } else {
147 public_key.to_bech32()?
148 }
149 } else {
150 public_key.to_bech32()?
151 },
152 created_at: if let Some(event) = event {
153 event.created_at
154 } else {
155 Timestamp::from(0)
156 },
157 })
158}
159
160pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays {
161 let event = events
162 .iter()
163 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
164 .max_by_key(|e| e.created_at);
165
166 UserRelays {
167 relays: if let Some(event) = event {
168 event
169 .tags
170 .iter()
171 .filter(|t| {
172 t.kind()
173 .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(
174 Alphabet::R,
175 )))
176 })
177 .map(|t| UserRelayRef {
178 url: t.as_slice()[1].clone(),
179 read: t.as_slice().len() == 2 || t.as_slice()[2].eq("read"),
180 write: t.as_slice().len() == 2 || t.as_slice()[2].eq("write"),
181 })
182 .collect()
183 } else {
184 vec![]
185 },
186 created_at: if let Some(event) = event {
187 event.created_at
188 } else {
189 Timestamp::from(0)
190 },
191 }
192}