upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/login.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-06-24 09:39:18 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-06-24 09:39:18 +0100
commit173ab188b326fbe78cfba4ab455a74619f4556bb (patch)
tree743a2413c241f7babd4efb336718c510eb743847 /src/login.rs
parent681fdd7683363c62251ecd8dabcc1931a18f4840 (diff)
feat(login): store in git config and use cache
replace ngit yaml file config with: * nsec / ncryptsec / npub in git config in nostr.* namespace * sql database cache for metadata and relay events allow different logins to be used for different git repositories by storing login in local git config
Diffstat (limited to 'src/login.rs')
-rw-r--r--src/login.rs461
1 files changed, 369 insertions, 92 deletions
diff --git a/src/login.rs b/src/login.rs
index 4cdf3c1..58d1b87 100644
--- a/src/login.rs
+++ b/src/login.rs
@@ -2,130 +2,407 @@ use std::str::FromStr;
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::{bail, Context, Result};
4use nostr::PublicKey; 4use nostr::PublicKey;
5use zeroize::Zeroize; 5use nostr_database::Order;
6use nostr_sdk::{Alphabet, FromBech32, JsonUtil, Kind, NostrDatabase, SingleLetterTag, ToBech32};
7use nostr_sqlite::SQLiteDatabase;
6 8
7#[cfg(not(test))] 9#[cfg(not(test))]
8use crate::client::Client; 10use crate::client::Client;
9#[cfg(test)] 11#[cfg(test)]
10use crate::client::MockConnect; 12use crate::client::MockConnect;
11use crate::{ 13use crate::{
12 cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, 14 cli_interactor::{
13 config::{ConfigManagement, ConfigManager, UserRef}, 15 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms,
14 key_handling::{
15 encryption::{EncryptDecrypt, Encryptor},
16 users::{UserManagement, UserManager},
17 }, 16 },
17 client::Connect,
18 config::{get_dirs, UserMetadata, UserRef, UserRelayRef, UserRelays},
19 git::{Repo, RepoActions},
20 key_handling::encryption::{decrypt_key, encrypt_key},
18}; 21};
19 22
20/// handles the encrpytion and storage of key material 23/// handles the encrpytion and storage of key material
21pub async fn launch( 24pub async fn launch(
25 git_repo: &Repo,
22 nsec: &Option<String>, 26 nsec: &Option<String>,
23 password: &Option<String>, 27 password: &Option<String>,
24 #[cfg(test)] client: Option<&MockConnect>, 28 #[cfg(test)] client: Option<&MockConnect>,
25 #[cfg(not(test))] client: Option<&Client>, 29 #[cfg(not(test))] client: Option<&Client>,
30 change_user: bool,
26) -> Result<(nostr::Keys, UserRef)> { 31) -> Result<(nostr::Keys, UserRef)> {
27 // if nsec parameter 32 if let Ok(keys) = match get_keys_without_prompts(git_repo, nsec, password, change_user) {
28 let key = if let Some(nsec_unwrapped) = nsec { 33 Ok(keys) => Ok(keys),
29 // get key or fail without prompts 34 Err(error) => {
30 let key = nostr::Keys::from_str(nsec_unwrapped).context("invalid nsec parameter")?; 35 if error
31 36 .to_string()
32 // if password, add user to enable password login in future 37 .eq("git config item nostr.nsec is an ncryptsec")
33 if password.is_some() { 38 {
34 UserManager::default() 39 println!(
35 .add(nsec, password) 40 "login as {}",
36 .context("could not store identity")?; 41 if let Ok(public_key) = PublicKey::from_bech32(
37 } else { 42 get_config_item(git_repo, "nostr.npub")
38 UserManager::default().add_user_to_config(key.public_key(), None, false)?; 43 .unwrap_or("unknown ncryptsec".to_string()),
44 ) {
45 if let Ok(user_ref) = get_user_details(&public_key, client).await {
46 user_ref.metadata.name
47 } else {
48 "unknown ncryptsec".to_string()
49 }
50 } else {
51 "unknown ncryptsec".to_string()
52 }
53 );
54 loop {
55 // prompt for password
56 let password = Interactor::default()
57 .password(PromptPasswordParms::default().with_prompt("password"))
58 .context("failed to get password input from interactor.password")?;
59 if let Ok(keys) = get_keys_with_password(git_repo, &password) {
60 break Ok(keys);
61 }
62 println!("incorrect password");
63 }
64 } else {
65 if nsec.is_some() {
66 bail!(error);
67 }
68 Err(error)
69 }
39 } 70 }
40 key 71 } {
72 // get user ref
73 let user_ref = get_user_details(&keys.public_key(), client).await?;
74 print_logged_in_as(&user_ref, client.is_none())?;
75 Ok((keys, user_ref))
41 } else { 76 } else {
42 let cfg = ConfigManager 77 fresh_login(git_repo, client, change_user).await
43 .load() 78 }
44 .context("failed to load application config")?; 79}
45 // if encrypted nsec present 80
46 if cfg.users.last().is_some() && !cfg.users.last().unwrap().encrypted_key.is_empty() { 81fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> {
47 // unfortunately this line is unstable in rust: 82 if !offline_mode && user_ref.metadata.created_at.eq(&0) {
48 // if let Some(user) = cfg.users.last() && !user.encrypted_key.is_empty() { 83 println!("cannot find your account metadata (name, etc) on relays");
49 let user = cfg.users.last().unwrap(); 84 } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) {
50 let mut pass = if let Some(p) = password.clone() { 85 println!("cannot extract account name from account metadata...");
51 p 86 } else if !offline_mode && user_ref.relays.created_at.eq(&0) {
52 } else { 87 println!(
53 println!("login as {}", &user.metadata.name); 88 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
54 Interactor::default() 89 );
55 .password(PromptPasswordParms::default().with_prompt("password")) 90 }
56 .context("failed to get password input from interactor.password")? 91 println!("logged in as {}", user_ref.metadata.name);
57 }; 92 Ok(())
58 93}
59 let key_result = Encryptor 94
60 .decrypt_key(&user.encrypted_key, pass.as_str()) 95fn get_keys_without_prompts(
61 .context("failed to decrypt key with provided password"); 96 git_repo: &Repo,
62 pass.zeroize(); 97 nsec: &Option<String>,
63 98 password: &Option<String>,
64 key_result.context(format!("failed to log in as {}", &user.metadata.name))? 99 save_local: bool,
100) -> Result<nostr::Keys> {
101 if let Some(nsec) = nsec {
102 get_keys_from_nsec(git_repo, nsec, password, save_local)
103 } else if let Some(password) = password {
104 get_keys_with_password(git_repo, password)
105 } else if !save_local {
106 get_keys_with_git_config_nsec_without_prompts(git_repo)
107 } else {
108 bail!("user wants prompts to specify new keys")
109 }
110}
111
112fn get_keys_from_nsec(
113 git_repo: &Repo,
114 nsec: &String,
115 password: &Option<String>,
116 save_local: bool,
117) -> Result<nostr::Keys> {
118 #[allow(unused_assignments)]
119 let mut s = String::new();
120 let keys = if nsec.contains("ncryptsec") {
121 s = nsec.to_string();
122 decrypt_key(
123 nsec,
124 password
125 .clone()
126 .context("password must be supplied when using ncryptsec as nsec parameter")?
127 .as_str(),
128 )
129 .context("failed to decrypt key with provided password")
130 .context("failed to decrypt ncryptsec supplied as nsec with password")?
131 } else {
132 s = nsec.to_string();
133 nostr::Keys::from_str(nsec).context("invalid nsec parameter")?
134 };
135 if save_local {
136 if let Some(password) = password {
137 s = encrypt_key(&keys, password)?;
65 } 138 }
66 // no encrypted nsec present 139 git_repo
67 else { 140 .save_git_config_item("nostr.nsec", &s, false)
68 // no nsec but password supplied 141 .context("failed to save encrypted nsec in local git config nostr.nsec")?;
69 if password.is_some() { 142 git_repo.save_git_config_item("nostr.npub", &keys.public_key().to_bech32()?, false)?;
70 bail!("no nsec available to decrypt with specified password"); 143 }
144 Ok(keys)
145}
146
147fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> {
148 decrypt_key(
149 &git_repo
150 .get_git_config_item("nostr.nsec", false)
151 .context("failed get git config")?
152 .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?,
153 password,
154 )
155 .context("failed to decrypt stored nsec key with provided password")
156}
157
158fn get_keys_with_git_config_nsec_without_prompts(git_repo: &Repo) -> Result<nostr::Keys> {
159 let nsec = &git_repo
160 .get_git_config_item("nostr.nsec", false)
161 .context("failed get git config")?
162 .context("git config item nostr.nsec doesn't exist")?;
163 if nsec.contains("ncryptsec") {
164 bail!("git config item nostr.nsec is an ncryptsec")
165 }
166 nostr::Keys::from_str(nsec).context("invalid nsec parameter")
167}
168
169async fn fresh_login(
170 git_repo: &Repo,
171 #[cfg(test)] client: Option<&MockConnect>,
172 #[cfg(not(test))] client: Option<&Client>,
173 always_save: bool,
174) -> Result<(nostr::Keys, UserRef)> {
175 // prompt for nsec
176 let mut prompt = "login with nsec";
177 let keys = loop {
178 match nostr::Keys::from_str(
179 &Interactor::default()
180 .input(PromptInputParms::default().with_prompt(prompt))
181 .context("failed to get nsec input from interactor")?,
182 ) {
183 Ok(key) => {
184 break key;
185 }
186 Err(_) => {
187 prompt = "invalid nsec. try again with nsec (or hex private key)";
71 } 188 }
72 // otherwise add new user with nsec and password prompts
73 UserManager::default()
74 .add(nsec, password)
75 .context("failed to add user")?
76 } 189 }
77 }; 190 };
191 // lookup profile
192 // save keys
193 if let Err(error) = save_keys(git_repo, &keys, always_save) {
194 println!("{error}");
195 }
196 let user_ref = get_user_details(&keys.public_key(), client).await?;
197 print_logged_in_as(&user_ref, client.is_none())?;
198 Ok((keys, user_ref))
199}
200
201fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> {
202 let store = always_save
203 || Interactor::default()
204 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?;
205
206 let global = !Interactor::default().confirm(
207 PromptConfirmParms::default()
208 .with_prompt("just for this repository?")
209 .with_default(false),
210 )?;
211
212 let encrypt = Interactor::default().confirm(
213 PromptConfirmParms::default()
214 .with_prompt("require password?")
215 .with_default(false),
216 )?;
78 217
79 // get user details 218 if store {
80 let user_ref = if let Some(client) = client { 219 let npub = keys.public_key().to_bech32()?;
81 get_user_details(&key.public_key(), client).await? 220 let nsec_string = if encrypt {
221 let password = Interactor::default()
222 .password(
223 PromptPasswordParms::default()
224 .with_prompt("encrypt with password")
225 .with_confirm(),
226 )
227 .context("failed to get password input from interactor.password")?;
228 encrypt_key(keys, &password)?
229 } else {
230 keys.secret_key()?.to_bech32()?
231 };
232
233 if let Err(error) = git_repo.save_git_config_item("nostr.nsec", &nsec_string, global) {
234 if global {
235 println!("failed to edit global git config instead");
236 if Interactor::default().confirm(
237 PromptConfirmParms::default()
238 .with_prompt("save in repository git config?")
239 .with_default(true),
240 )? {
241 git_repo.save_git_config_item("nostr.nsec", &nsec_string, false)?;
242 git_repo.save_git_config_item("nostr.npub", &npub, false)?;
243 }
244 } else {
245 bail!(error)
246 }
247 } else {
248 git_repo.save_git_config_item("nostr.npub", &npub, global)?;
249 };
250 };
251 Ok(())
252}
253
254fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> {
255 git_repo
256 .get_git_config_item(name, false)
257 .context("failed get git config")?
258 .context(format!("git config item {name} doesn't exist"))
259}
260
261fn extract_user_metadata(
262 public_key: &nostr::PublicKey,
263 events: &[nostr::Event],
264) -> Result<UserMetadata> {
265 let event = events
266 .iter()
267 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
268 .max_by_key(|e| e.created_at);
269
270 let metadata: Option<nostr::Metadata> = if let Some(event) = event {
271 Some(
272 nostr::Metadata::from_json(event.content.clone())
273 .context("metadata cannot be found in kind 0 event content")?,
274 )
82 } else { 275 } else {
83 // this will get user details with name as npub 276 None
84 UserManager::default()
85 .get_user_from_cache(&key.public_key())?
86 .clone()
87 }; 277 };
88 278
89 // print logged in 279 Ok(UserMetadata {
90 println!("logged in as {}", user_ref.metadata.name); 280 name: if let Some(metadata) = metadata {
281 if let Some(n) = metadata.name {
282 n
283 } else if let Some(n) = metadata.custom.get("displayName") {
284 // strip quote marks that custom.get() adds
285 let binding = n.to_string();
286 let mut chars = binding.chars();
287 chars.next();
288 chars.next_back();
289 chars.as_str().to_string()
290 } else if let Some(n) = metadata.display_name {
291 n
292 } else {
293 public_key.to_bech32()?
294 }
295 } else {
296 public_key.to_bech32()?
297 },
298 created_at: if let Some(event) = event {
299 event.created_at.as_u64()
300 } else {
301 0
302 },
303 })
304}
91 305
92 Ok((key, user_ref.clone())) 306fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays {
307 let event = events
308 .iter()
309 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
310 .max_by_key(|e| e.created_at);
311
312 UserRelays {
313 relays: if let Some(event) = event {
314 event
315 .tags
316 .iter()
317 .filter(|t| {
318 t.kind()
319 .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(
320 Alphabet::R,
321 )))
322 })
323 .map(|t| UserRelayRef {
324 url: t.as_vec()[1].clone(),
325 read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"),
326 write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"),
327 })
328 .collect()
329 } else {
330 vec![]
331 },
332 created_at: if let Some(event) = event {
333 event.created_at.as_u64()
334 } else {
335 0
336 },
337 }
93} 338}
94 339
95async fn get_user_details( 340async fn get_user_details(
96 public_key: &PublicKey, 341 public_key: &PublicKey,
97 #[cfg(test)] client: &crate::client::MockConnect, 342 #[cfg(test)] client: Option<&crate::client::MockConnect>,
98 #[cfg(not(test))] client: &Client, 343 #[cfg(not(test))] client: Option<&Client>,
99) -> Result<UserRef> { 344) -> Result<UserRef> {
100 let term = console::Term::stdout(); 345 if client.is_some() {
101 term.write_line("searching for profile and relay updates...")?; 346 println!("searching for profile and relay updates...");
102 let user_manager = UserManager::default();
103 let user_ref = user_manager
104 .get_user(
105 client,
106 public_key,
107 // use cache for 3 minutes
108 3 * 60,
109 )
110 .await?;
111 term.clear_last_lines(1)?;
112 if user_ref.metadata.created_at.eq(&0) {
113 println!("cannot find your account metadata (name, etc) on relays",);
114 // TODO use secondary fallback list of relays.
115 // TODO better reporting of what relays were checked and what the user
116 // here is a starter:
117 // cannot find account details on relays:
118 // - purplepages.xyz
119 // - fallbackrelay1
120 // - ...
121 // would you like to:
122 // [-] proceed anyway
123 // - add custom fallback relays
124 } else if user_ref.relays.created_at.eq(&0) {
125 println!(
126 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
127 );
128 // TODO better guidance on how to do this
129 } 347 }
348 let database = SQLiteDatabase::open(get_dirs()?.config_dir().join("cache.sqlite")).await?;
349 let mut events: Vec<nostr::Event> = vec![];
350 let filters = vec![
351 nostr::Filter::default()
352 .author(*public_key)
353 .kind(Kind::Metadata),
354 nostr::Filter::default()
355 .author(*public_key)
356 .kind(Kind::RelayList),
357 ];
358 if let Ok(cached_events) = database.query(filters.clone(), Order::Asc).await {
359 for event in cached_events {
360 events.push(event);
361 }
362 }
363 let mut relays_to_search = if let Some(client) = client {
364 client.get_fallback_relays().clone()
365 } else {
366 vec![]
367 };
368 let mut relays_searched = vec![];
369 let user_ref = loop {
370 if let Some(client) = client {
371 for event in client
372 .get_events(relays_to_search.clone(), filters.clone())
373 .await
374 .unwrap_or(vec![])
375 {
376 let _ = database.save_event(&event).await;
377 events.push(event);
378 }
379 }
380
381 #[allow(clippy::clone_on_copy)]
382 let user_ref = UserRef {
383 public_key: public_key.clone(),
384 metadata: extract_user_metadata(public_key, &events)?,
385 relays: extract_user_relays(public_key, &events),
386 };
387
388 if client.is_none() {
389 break user_ref;
390 }
391 for r in &relays_to_search {
392 relays_searched.push(r.clone());
393 }
394
395 relays_to_search = user_ref
396 .relays
397 .write()
398 .iter()
399 .filter(|r| !relays_searched.iter().any(|or| r.eq(&or)))
400 .map(std::clone::Clone::clone)
401 .collect();
402 if !relays_to_search.is_empty() {
403 continue;
404 }
405 break user_ref;
406 };
130 Ok(user_ref) 407 Ok(user_ref)
131} 408}