upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cli_interactor.rs31
-rw-r--r--src/config.rs111
-rw-r--r--src/key_handling/encryption.rs247
-rw-r--r--src/key_handling/mod.rs1
-rw-r--r--src/key_handling/users.rs262
-rw-r--r--src/login.rs83
-rw-r--r--src/main.rs5
-rw-r--r--src/sub_commands/login.rs3
8 files changed, 659 insertions, 84 deletions
diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs
index 2f28aee..d7de087 100644
--- a/src/cli_interactor.rs
+++ b/src/cli_interactor.rs
@@ -1,5 +1,5 @@
1use anyhow::{bail, Result}; 1use anyhow::Result;
2use dialoguer::{theme::ColorfulTheme, Input}; 2use dialoguer::{theme::ColorfulTheme, Input, Password};
3#[cfg(test)] 3#[cfg(test)]
4use mockall::*; 4use mockall::*;
5 5
@@ -11,6 +11,7 @@ pub struct Interactor {
11#[cfg_attr(test, automock)] 11#[cfg_attr(test, automock)]
12pub trait InteractorPrompt { 12pub trait InteractorPrompt {
13 fn input(&self, parms: PromptInputParms) -> Result<String>; 13 fn input(&self, parms: PromptInputParms) -> Result<String>;
14 fn password(&self, parms: PromptPasswordParms) -> Result<String>;
14} 15}
15impl InteractorPrompt for Interactor { 16impl InteractorPrompt for Interactor {
16 fn input(&self, parms: PromptInputParms) -> Result<String> { 17 fn input(&self, parms: PromptInputParms) -> Result<String> {
@@ -19,6 +20,15 @@ impl InteractorPrompt for Interactor {
19 .interact_text()?; 20 .interact_text()?;
20 Ok(input) 21 Ok(input)
21 } 22 }
23 fn password(&self, parms: PromptPasswordParms) -> Result<String> {
24 let mut p = Password::with_theme(&self.theme);
25 p.with_prompt(parms.prompt);
26 if parms.confirm {
27 p.with_confirmation("confirm password", "passwords didnt match...");
28 }
29 let pass: String = p.interact()?;
30 Ok(pass)
31 }
22} 32}
23 33
24#[derive(Default)] 34#[derive(Default)]
@@ -32,3 +42,20 @@ impl PromptInputParms {
32 self 42 self
33 } 43 }
34} 44}
45
46#[derive(Default)]
47pub struct PromptPasswordParms {
48 pub prompt: String,
49 pub confirm: bool,
50}
51
52impl PromptPasswordParms {
53 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
54 self.prompt = prompt.into();
55 self
56 }
57 pub const fn with_confirm(mut self) -> Self {
58 self.confirm = true;
59 self
60 }
61}
diff --git a/src/config.rs b/src/config.rs
index b26dea0..f410934 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -4,6 +4,7 @@ use anyhow::{anyhow, Context, Result};
4use directories::ProjectDirs; 4use directories::ProjectDirs;
5#[cfg(test)] 5#[cfg(test)]
6use mockall::*; 6use mockall::*;
7use nostr::secp256k1::XOnlyPublicKey;
7use serde::{self, Deserialize, Serialize}; 8use serde::{self, Deserialize, Serialize};
8 9
9#[derive(Default)] 10#[derive(Default)]
@@ -59,7 +60,7 @@ impl ConfigManagement for ConfigManager {
59 } 60 }
60} 61}
61 62
62#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq)] 63#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
63#[allow(clippy::module_name_repetitions)] 64#[allow(clippy::module_name_repetitions)]
64pub struct MyConfig { 65pub struct MyConfig {
65 pub version: u8, 66 pub version: u8,
@@ -68,44 +69,64 @@ pub struct MyConfig {
68 69
69#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 70#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
70pub struct UserRef { 71pub struct UserRef {
71 pub nsec: String, 72 pub public_key: XOnlyPublicKey,
73 pub encrypted_key: String,
72} 74}
73 75
74#[cfg(test)] 76#[cfg(test)]
75mod tests { 77mod tests {
76 use anyhow::Result; 78 use anyhow::Result;
77 use serial_test::serial; 79 use serial_test::serial;
78 use test_utils::*;
79 80
80 use super::*; 81 use super::*;
81 82
83 fn backup_existing_config() -> Result<()> {
84 let config_path = get_dirs()?.config_dir().join("config.json");
85 let backup_config_path = get_dirs()?.config_dir().join("config-backup.json");
86 if config_path.exists() {
87 std::fs::rename(config_path, backup_config_path)?;
88 }
89 Ok(())
90 }
91
92 fn restore_config_backup() -> Result<()> {
93 let config_path = get_dirs()?.config_dir().join("config.json");
94 let backup_config_path = get_dirs()?.config_dir().join("config-backup.json");
95 if config_path.exists() {
96 std::fs::remove_file(&config_path)?;
97 }
98 if backup_config_path.exists() {
99 std::fs::rename(backup_config_path, config_path)?;
100 }
101 Ok(())
102 }
103
82 mod load { 104 mod load {
83 use super::*; 105 use super::*;
84 106
85 #[test] 107 #[test]
86 #[serial] 108 #[serial]
87 fn when_config_file_doesnt_exist_defaults_are_returned() -> Result<()> { 109 fn when_config_file_doesnt_exist_defaults_are_returned() -> Result<()> {
88 with_fresh_config(|| { 110 backup_existing_config()?;
89 assert_eq!(ConfigManager.load()?, MyConfig::default()); 111 let c = ConfigManager;
90 112 assert_eq!(c.load()?, MyConfig::default());
91 Ok(()) 113 restore_config_backup()?;
92 }) 114 Ok(())
93 } 115 }
94 116
95 #[test] 117 #[test]
96 #[serial] 118 #[serial]
97 fn when_config_file_exists_it_is_returned() -> Result<()> { 119 fn when_config_file_exists_it_is_returned() -> Result<()> {
98 with_fresh_config(|| { 120 backup_existing_config()?;
99 let c = ConfigManager; 121 let c = ConfigManager;
100 let new_config = MyConfig { 122 let new_config = MyConfig {
101 version: 255, 123 version: 255,
102 ..MyConfig::default() 124 ..MyConfig::default()
103 }; 125 };
104 c.save(&new_config)?; 126 c.save(&new_config)?;
105 assert_eq!(c.load()?, new_config); 127 assert_eq!(c.load()?, new_config);
106 128 restore_config_backup()?;
107 Ok(()) 129 Ok(())
108 })
109 } 130 }
110 } 131 }
111 132
@@ -115,38 +136,36 @@ mod tests {
115 #[test] 136 #[test]
116 #[serial] 137 #[serial]
117 fn when_config_file_doesnt_config_is_saved() -> Result<()> { 138 fn when_config_file_doesnt_config_is_saved() -> Result<()> {
118 with_fresh_config(|| { 139 backup_existing_config()?;
119 let c = ConfigManager; 140 let c = ConfigManager;
120 let new_config = MyConfig { 141 let new_config = MyConfig {
121 version: 255, 142 version: 255,
122 ..MyConfig::default() 143 ..MyConfig::default()
123 }; 144 };
124 c.save(&new_config)?; 145 c.save(&new_config)?;
125 assert_eq!(c.load()?, new_config); 146 assert_eq!(c.load().unwrap(), new_config);
126 147 restore_config_backup()?;
127 Ok(()) 148 Ok(())
128 })
129 } 149 }
130 150
131 #[test] 151 #[test]
132 #[serial] 152 #[serial]
133 fn when_config_file_exists_new_config_is_saved() -> Result<()> { 153 fn when_config_file_exists_new_config_is_saved() -> Result<()> {
134 with_fresh_config(|| { 154 backup_existing_config()?;
135 let c = ConfigManager; 155 let c = ConfigManager;
136 let config = MyConfig { 156 let config = MyConfig {
137 version: 255, 157 version: 255,
138 ..MyConfig::default() 158 ..MyConfig::default()
139 }; 159 };
140 c.save(&config)?; 160 c.save(&config)?;
141 let new_config = MyConfig { 161 let new_config = MyConfig {
142 version: 254, 162 version: 254,
143 ..MyConfig::default() 163 ..MyConfig::default()
144 }; 164 };
145 c.save(&new_config)?; 165 c.save(&new_config)?;
146 assert_eq!(c.load()?, new_config); 166 assert_eq!(c.load().unwrap(), new_config);
147 167 restore_config_backup()?;
148 Ok(()) 168 Ok(())
149 })
150 } 169 }
151 } 170 }
152} 171}
diff --git a/src/key_handling/encryption.rs b/src/key_handling/encryption.rs
new file mode 100644
index 0000000..0ef7f69
--- /dev/null
+++ b/src/key_handling/encryption.rs
@@ -0,0 +1,247 @@
1use anyhow::{anyhow, bail, ensure, Context, Result};
2use chacha20poly1305::{
3 aead::{rand_core::RngCore, Aead, AeadCore, KeyInit, OsRng, Payload},
4 XChaCha20Poly1305,
5};
6#[cfg(test)]
7use mockall::*;
8use nostr::{prelude::*, Keys};
9use rand::{distributions::Alphanumeric, thread_rng, Rng};
10use zeroize::Zeroize;
11
12#[derive(Default)]
13pub struct Encryptor;
14
15#[cfg_attr(test, automock)]
16pub trait EncryptDecrypt {
17 /// requires less CPU time if the password is long
18 fn encrypt_key(&self, keys: &Keys, password: &str) -> Result<String>;
19 fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result<Keys>;
20 /// generates a long random string
21 fn random_token(&self) -> String;
22}
23
24/// approach and code adapted from nostr gossip client
25impl EncryptDecrypt for Encryptor {
26 fn encrypt_key(&self, keys: &Keys, password: &str) -> Result<String> {
27 // Generate a random 16-byte salt
28 let salt = {
29 let mut salt: [u8; 16] = [0; 16];
30 OsRng.fill_bytes(&mut salt);
31 salt
32 };
33
34 let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
35
36 let log2_rounds: u8 = if password.len() > 20 {
37 // we have enough of entropy - no need to spend CPU time adding much more
38 1
39 } else {
40 // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait
41 15
42 };
43
44 let associated_data: Vec<u8> = vec![1];
45
46 let ciphertext = {
47 let cipher = {
48 let symmetric_key = password_to_key(password, &salt, log2_rounds)
49 .context("failed create encryption key from password")?;
50 XChaCha20Poly1305::new((&symmetric_key).into())
51 };
52 cipher
53 .encrypt(
54 &nonce,
55 Payload {
56 msg: keys
57 .secret_key()
58 .context(
59 "supplied key should reveal secret key. Is this a public key only?",
60 )?
61 .display_secret()
62 .to_string()
63 .as_bytes(),
64 aad: &associated_data,
65 },
66 )
67 .map_err(|_| anyhow!("ChaChaPoly1305 failed to encrypt nsec with password"))?
68 };
69 // Combine salt, IV and ciphertext
70 let mut concatenation: Vec<u8> = Vec::new();
71 concatenation.push(0x1); // 1 byte version number
72 concatenation.push(log2_rounds); // 1 byte for scrypt N (rounds)
73 concatenation.extend(salt); // 16 bytes of salt
74 concatenation.extend(nonce); // 24 bytes of nonce
75 concatenation.extend(associated_data); // 1 byte of key security
76 concatenation.extend(ciphertext); // 48 bytes of ciphertext expected
77 // Total length is 91 = 1 + 1 + 16 + 24 + 1 + 48
78
79 bech32::encode(
80 "ncryptsec",
81 concatenation.to_base32(),
82 bech32::Variant::Bech32,
83 )
84 .context("encrypted nsec failed to encode")
85 }
86
87 fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result<nostr::Keys> {
88 let data =
89 bech32::decode(encrypted_key).context("failed to decode encrypted key as bech32")?;
90 if data.0 != "ncryptsec" {
91 bail!("encrypted key is in the wrong format - it doesnt start with ncryptsec");
92 }
93 let concatenation = Vec::<u8>::from_base32(&data.1)
94 .context("failed to convert bech32::decode output to Vec<u8>")?;
95
96 // Break into parts
97 let version: u8 = concatenation[0];
98 ensure!(version == 0x1, "encryption version is incorrect");
99 let log2_rounds: u8 = concatenation[1];
100 let salt: [u8; 16] = concatenation[2..2 + 16].try_into()?;
101 let nonce = &concatenation[2 + 16..2 + 16 + 24];
102 let associated_data = &concatenation[(2 + 16 + 24)..=(2 + 16 + 24)];
103 let ciphertext = &concatenation[2 + 16 + 24 + 1..];
104
105 let cipher = {
106 let symmetric_key = password_to_key(password, &salt, log2_rounds)?;
107 XChaCha20Poly1305::new((&symmetric_key).into())
108 };
109
110 let payload = Payload {
111 msg: ciphertext,
112 aad: associated_data,
113 };
114
115 let mut inner_secret = cipher
116 .decrypt(nonce.into(), payload)
117 .map_err(|_| anyhow!("failed to decrypt"))?;
118
119 if associated_data.is_empty() {
120 bail!("invalid encrypted key");
121 }
122
123 let key = Keys::from_sk_str(
124 std::str::from_utf8(&inner_secret).context("inner secret is not [u8]")?,
125 )
126 .context("incorrect password. Key decrypted with password did not produce a valid nsec.")?;
127
128 inner_secret.zeroize();
129
130 Ok(key)
131 }
132
133 fn random_token(&self) -> String {
134 thread_rng()
135 .sample_iter(&Alphanumeric)
136 .take(32)
137 .map(char::from)
138 .collect()
139 }
140}
141
142/// uses scrypt to stretch password into key
143fn password_to_key(password: &str, salt: &[u8; 16], log_n: u8) -> Result<[u8; 32]> {
144 let params = scrypt::Params::new(log_n, 8, 1, 32)
145 .context("scrypt failed to generate params to stretch password")?;
146 let mut key: [u8; 32] = [0; 32];
147 if log_n > 14 {
148 println!("this may take a few seconds...");
149 }
150
151 scrypt::scrypt(password.as_bytes(), salt, &params, &mut key)
152 .context("scrypt failed to stretch password")?;
153 Ok(key)
154}
155
156#[cfg(test)]
157mod tests {
158 use test_utils::*;
159
160 use super::*;
161
162 #[test]
163 fn encrypt_key_produces_string_prefixed_with() -> Result<()> {
164 let s = Encryptor.encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?;
165 assert!(s.starts_with("ncryptsec"));
166 Ok(())
167 }
168
169 #[test]
170 // ensures password encryption hasn't changed
171 fn decrypts_with_strong_password_from_reference_string() -> Result<()> {
172 let encryptor = Encryptor;
173 let decrypted_key = encryptor.decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?;
174
175 assert_eq!(
176 format!(
177 "{}",
178 TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap()
179 ),
180 format!(
181 "{}",
182 decrypted_key.secret_key().unwrap().to_bech32().unwrap()
183 ),
184 );
185 Ok(())
186 }
187
188 #[test]
189 // ensures password encryption hasn't changed
190 fn decrypts_with_weak_password_from_reference_string() -> Result<()> {
191 let encryptor = Encryptor;
192 let decrypted_key = encryptor.decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?;
193
194 assert_eq!(
195 format!(
196 "{}",
197 TEST_KEY_1_KEYS.secret_key().unwrap().to_bech32().unwrap()
198 ),
199 format!(
200 "{}",
201 decrypted_key.secret_key().unwrap().to_bech32().unwrap()
202 ),
203 );
204 Ok(())
205 }
206
207 #[test]
208 fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> {
209 let encryptor = Encryptor;
210 let key = nostr::Keys::generate();
211 let s = encryptor.encrypt_key(&key, TEST_PASSWORD)?;
212 let newkey = encryptor.decrypt_key(s.as_str(), TEST_PASSWORD)?;
213
214 assert_eq!(
215 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()),
216 format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()),
217 );
218 Ok(())
219 }
220
221 #[test]
222 fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> {
223 let encryptor = Encryptor;
224 let key = nostr::Keys::generate();
225 let s = encryptor.encrypt_key(&key, TEST_PASSWORD)?;
226 let newkey = encryptor.decrypt_key(s.as_str(), TEST_PASSWORD)?;
227
228 assert_eq!(
229 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()),
230 format!("{}", newkey.secret_key().unwrap().to_bech32().unwrap()),
231 );
232 Ok(())
233 }
234
235 #[test]
236 fn password_to_key_returns_ok_with_standard_password() {
237 let salt = {
238 let mut salt: [u8; 16] = [0; 16];
239 OsRng.fill_bytes(&mut salt);
240 salt
241 };
242
243 let log2_rounds: u8 = 1;
244
245 assert!(password_to_key(TEST_PASSWORD, &salt, log2_rounds).is_ok());
246 }
247}
diff --git a/src/key_handling/mod.rs b/src/key_handling/mod.rs
index 913bd46..bcb10df 100644
--- a/src/key_handling/mod.rs
+++ b/src/key_handling/mod.rs
@@ -1 +1,2 @@
1pub mod encryption;
1pub mod users; 2pub mod users;
diff --git a/src/key_handling/users.rs b/src/key_handling/users.rs
index bd1748a..1d2cc34 100644
--- a/src/key_handling/users.rs
+++ b/src/key_handling/users.rs
@@ -1,47 +1,90 @@
1use anyhow::{Context, Result}; 1use anyhow::{Context, Result};
2use nostr::prelude::*;
3use zeroize::Zeroize;
2 4
5use super::encryption::{EncryptDecrypt, Encryptor};
3use crate::{ 6use crate::{
4 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, 7 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms, PromptPasswordParms},
5 config::{ConfigManagement, ConfigManager, MyConfig, UserRef}, 8 config::{self, ConfigManagement, ConfigManager},
6}; 9};
7 10
8#[derive(Default)] 11#[derive(Default)]
9pub struct UserManager { 12pub struct UserManager {
10 config_manager: ConfigManager, 13 config_manager: ConfigManager,
11 interactor: Interactor, 14 interactor: Interactor,
15 encryptor: Encryptor,
12} 16}
13 17
14pub trait UserManagement { 18pub trait UserManagement {
15 fn add(&self, nsec: &Option<String>) -> Result<()>; 19 fn add(&self, nsec: &Option<String>, password: &Option<String>) -> Result<nostr::Keys>;
16} 20}
17 21
18#[cfg(test)] 22#[cfg(test)]
19use duplicate::duplicate_item; 23use duplicate::duplicate_item;
20#[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))] 24#[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))]
21impl UserManagement for UserManager { 25impl UserManagement for UserManager {
22 fn add(&self, nsec: &Option<String>) -> Result<()> { 26 fn add(&self, nsec: &Option<String>, password: &Option<String>) -> Result<nostr::Keys> {
23 let nsec = match nsec.clone() { 27 let mut prompt = "login with nsec (or hex private key)";
24 Some(nsec) => nsec, 28 let keys = loop {
29 let pk = match nsec.clone() {
30 Some(nsec) => nsec,
31 None => self
32 .interactor
33 .input(PromptInputParms::default().with_prompt(prompt))
34 .context("failed to get nsec input from interactor")?,
35 };
36 match Keys::from_sk_str(&pk) {
37 Ok(key) => {
38 break key;
39 }
40 Err(e) => {
41 if nsec.is_some() {
42 return Err(e).context(
43 "invalid nsec - supplied parameter could not be converted into a nostr private key",
44 );
45 }
46 prompt = "invalid nsec. try again with nsec (or hex private key)";
47 }
48 }
49 };
50
51 let mut pass = match password.clone() {
52 Some(pass) => pass,
25 None => self 53 None => self
26 .interactor 54 .interactor
27 .input( 55 .password(
28 PromptInputParms::default().with_prompt("login with nsec (or hex private key)"), 56 PromptPasswordParms::default()
57 .with_prompt("encrypt with password")
58 .with_confirm(),
29 ) 59 )
30 .context("failed to get nsec input from interactor.input")?, 60 .context("failed to get password input from interactor.password")?,
31 }; 61 };
32 62
63 let encrypted_secret_key = self
64 .encryptor
65 .encrypt_key(&keys, &pass)
66 .context("failed to encrypt nsec with password.")?;
67 pass.zeroize();
68
69 let user_ref = config::UserRef {
70 public_key: keys.public_key(),
71 encrypted_key: encrypted_secret_key,
72 };
73
74 // remove any duplicate entries for key before adding it to config
75 let mut cfg = self.config_manager.load().context("failed to load application config to find and remove any old versions of the user's encrypted key")?;
76 cfg.users = cfg
77 .users
78 .clone()
79 .into_iter()
80 .filter(|r| !r.public_key.eq(&keys.public_key()))
81 .collect();
82 cfg.users.push(user_ref);
33 self.config_manager 83 self.config_manager
34 .save(&MyConfig { 84 .save(&cfg)
35 users: vec![UserRef {
36 nsec: nsec.to_string(),
37 }],
38 ..MyConfig::default()
39 })
40 .context("failed to save application configuration with new user details in")?; 85 .context("failed to save application configuration with new user details in")?;
41 86
42 println!("logged in as {nsec}"); 87 Ok(keys)
43
44 Ok(())
45 } 88 }
46} 89}
47 90
@@ -50,12 +93,17 @@ mod tests {
50 use test_utils::*; 93 use test_utils::*;
51 94
52 use super::*; 95 use super::*;
53 use crate::{cli_interactor::MockInteractorPrompt, config::MockConfigManagement}; 96 use crate::{
97 cli_interactor::MockInteractorPrompt,
98 config::{MockConfigManagement, MyConfig, UserRef},
99 key_handling::encryption::MockEncryptDecrypt,
100 };
54 101
55 #[derive(Default)] 102 #[derive(Default)]
56 pub struct MockUserManager { 103 pub struct MockUserManager {
57 pub config_manager: MockConfigManagement, 104 pub config_manager: MockConfigManagement,
58 pub interactor: MockInteractorPrompt, 105 pub interactor: MockInteractorPrompt,
106 pub encryptor: MockEncryptDecrypt,
59 } 107 }
60 108
61 mod add { 109 mod add {
@@ -70,28 +118,88 @@ mod tests {
70 self.interactor 118 self.interactor
71 .expect_input() 119 .expect_input()
72 .returning(|_| Ok(TEST_KEY_1_NSEC.into())); 120 .returning(|_| Ok(TEST_KEY_1_NSEC.into()));
121 self.interactor
122 .expect_password()
123 .returning(|_| Ok(TEST_PASSWORD.into()));
124 self.encryptor
125 .expect_encrypt_key()
126 .returning(|_, _| Ok(TEST_KEY_1_ENCRYPTED.into()));
73 self 127 self
74 } 128 }
75 } 129 }
76 130
77 mod when_nsec_is_passed { 131 fn reuable_user_isnt_prompted(nsec: &str) {
132 let mut m = MockUserManager::default().add_return_expected_responses();
133 m.interactor = MockInteractorPrompt::default();
134 m.interactor.expect_input().never();
135 m.interactor.expect_password().never();
136 let _ = m.add(&Some(nsec.into()), &Some(TEST_PASSWORD.to_string()));
137 }
138
139 fn reuable_config_isnt_modified(nsec: &str) {
140 let mut m = MockUserManager::default();
141 m.config_manager.expect_save().never();
142 let _ = m.add(&Some(nsec.into()), &Some(TEST_PASSWORD.to_string()));
143 }
144
145 mod when_valid_nsec_and_password_is_passed {
78 use super::*; 146 use super::*;
79 147
80 #[test] 148 #[test]
81 fn user_isnt_prompted() { 149 fn user_isnt_prompted() {
150 reuable_user_isnt_prompted(TEST_KEY_1_NSEC);
151 }
152
153 #[test]
154 fn results_in_correct_keys() {
82 let mut m = MockUserManager::default().add_return_expected_responses(); 155 let mut m = MockUserManager::default().add_return_expected_responses();
83 m.interactor = MockInteractorPrompt::default(); 156 m.interactor = MockInteractorPrompt::default();
84 m.interactor.expect_input().never(); 157 m.interactor.expect_input().never();
85 158 m.interactor.expect_password().never();
86 let _ = m.add(&Some(TEST_KEY_1_NSEC.into())); 159 let r = m.add(
160 &Some(TEST_KEY_1_NSEC.into()),
161 &Some(TEST_PASSWORD.to_string()),
162 );
163 assert!(r.is_ok(), "should result in keys");
164 assert!(
165 r.is_ok_and(|k| k
166 .secret_key()
167 .is_ok_and(|k| k.display_secret().to_string().eq(TEST_KEY_1_SK_HEX))),
168 "keys should reflect nsec"
169 );
87 } 170 }
88 } 171 }
172 mod when_invalid_nsec_is_passed_with_password {
173 use super::*;
89 174
175 #[test]
176 fn user_isnt_prompted() {
177 reuable_user_isnt_prompted(TEST_INVALID_NSEC);
178 }
179
180 #[test]
181 fn config_isnt_modified() {
182 reuable_config_isnt_modified(TEST_INVALID_NSEC);
183 }
184
185 #[test]
186 fn results_in_an_error() {
187 let m = MockUserManager::default();
188 assert!(
189 m.add(
190 &Some(TEST_INVALID_NSEC.into()),
191 &Some(TEST_PASSWORD.to_string())
192 )
193 .is_err(),
194 "should result in an error"
195 );
196 }
197 }
90 mod when_no_nsec_is_passed { 198 mod when_no_nsec_is_passed {
91 use super::*; 199 use super::*;
92 200
93 #[test] 201 #[test]
94 fn prompt_for_nsec() { 202 fn prompt_for_nsec_and_password() {
95 let mut m = MockUserManager::default().add_return_expected_responses(); 203 let mut m = MockUserManager::default().add_return_expected_responses();
96 204
97 m.interactor = MockInteractorPrompt::new(); 205 m.interactor = MockInteractorPrompt::new();
@@ -100,12 +208,31 @@ mod tests {
100 .once() 208 .once()
101 .withf(|p| p.prompt.eq("login with nsec (or hex private key)")) 209 .withf(|p| p.prompt.eq("login with nsec (or hex private key)"))
102 .returning(|_| Ok(TEST_KEY_1_NSEC.into())); 210 .returning(|_| Ok(TEST_KEY_1_NSEC.into()));
211 m.interactor
212 .expect_password()
213 .once()
214 .withf(|p| p.prompt.eq("encrypt with password"))
215 .returning(|_| Ok(TEST_KEY_1_NSEC.into()));
103 216
104 let _ = m.add(&None); 217 let _ = m.add(&None, &None);
105 } 218 }
106 219
107 #[test] 220 #[test]
108 fn stored_in_config() { 221 fn results_in_correct_keys() {
222 let m = MockUserManager::default().add_return_expected_responses();
223
224 let r = m.add(&None, &None);
225 assert!(r.is_ok(), "should result in keys");
226 assert!(
227 r.is_ok_and(|k| k
228 .secret_key()
229 .is_ok_and(|k| k.display_secret().to_string().eq(TEST_KEY_1_SK_HEX))),
230 "keys should reflect nsec"
231 );
232 }
233
234 #[test]
235 fn stores_encrypted_key_in_config() {
109 let mut m = MockUserManager::default().add_return_expected_responses(); 236 let mut m = MockUserManager::default().add_return_expected_responses();
110 237
111 m.config_manager = MockConfigManagement::new(); 238 m.config_manager = MockConfigManagement::new();
@@ -114,10 +241,91 @@ mod tests {
114 .returning(|| Ok(MyConfig::default())); 241 .returning(|| Ok(MyConfig::default()));
115 m.config_manager 242 m.config_manager
116 .expect_save() 243 .expect_save()
117 .withf(|cfg| cfg.users.len().eq(&1) && cfg.users[0].nsec.eq(TEST_KEY_1_NSEC)) 244 .withf(|cfg| {
245 cfg.users.len().eq(&1)
246 && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED)
247 })
118 .returning(|_| Ok(())); 248 .returning(|_| Ok(()));
119 249
120 let _ = m.add(&None); 250 let _ = m.add(&None, &None);
251 }
252
253 #[test]
254 fn stored_key_encrypted_with_password() {
255 let mut m = MockUserManager::default().add_return_expected_responses();
256
257 m.encryptor = MockEncryptDecrypt::new();
258 m.encryptor
259 .expect_encrypt_key()
260 .once()
261 .withf(|k, p| {
262 k.eq(&Keys::from_sk_str(TEST_KEY_1_NSEC).unwrap()) && p.eq(TEST_PASSWORD)
263 })
264 .returning(|_, _| Ok(TEST_KEY_1_ENCRYPTED.into()));
265
266 let _ = m.add(&None, &None);
267 }
268
269 mod when_user_key_already_stored {
270 use super::*;
271 use crate::config::UserRef;
272
273 /// key overwritten as password may have changed
274 #[test]
275 fn key_not_saved_as_duplicate_but_encrypted_key_overwritten() {
276 let mut m = MockUserManager::default().add_return_expected_responses();
277
278 m.config_manager = MockConfigManagement::default();
279 m.config_manager.expect_load().returning(|| {
280 Ok(MyConfig {
281 users: vec![UserRef {
282 public_key: TEST_KEY_1_KEYS.public_key(),
283 // different key to TEST_KEY_1_ENCYPTED
284 encrypted_key: TEST_KEY_2_ENCRYPTED.into(),
285 }],
286 ..MyConfig::default()
287 })
288 });
289 m.config_manager
290 .expect_save()
291 .withf(|cfg| {
292 cfg.users.len() == 1
293 && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED)
294 })
295 .returning(|_| Ok(()));
296
297 let _ = m.add(&None, &None);
298 }
299 }
300
301 mod when_multiple_users_added {
302 use super::*;
303
304 #[test]
305 fn both_user_keys_are_stored() {
306 let mut m = MockUserManager::default().add_return_expected_responses();
307
308 m.config_manager = MockConfigManagement::default();
309 m.config_manager.expect_load().returning(|| {
310 Ok(MyConfig {
311 users: vec![UserRef {
312 public_key: TEST_KEY_2_KEYS.public_key(),
313 encrypted_key: TEST_KEY_2_ENCRYPTED.into(),
314 }],
315 ..MyConfig::default()
316 })
317 });
318 m.config_manager
319 .expect_save()
320 .withf(|cfg| {
321 cfg.users.len() == 2
322 // latest user stored at end of array
323 && cfg.users[1].encrypted_key.eq(TEST_KEY_1_ENCRYPTED)
324 })
325 .returning(|_| Ok(()));
326
327 let _ = m.add(&None, &None);
328 }
121 } 329 }
122 } 330 }
123 } 331 }
diff --git a/src/login.rs b/src/login.rs
index da19a75..a6ce76d 100644
--- a/src/login.rs
+++ b/src/login.rs
@@ -1,16 +1,85 @@
1use anyhow::{Context, Result}; 1use anyhow::{bail, Context, Result};
2use nostr::prelude::{FromSkStr, ToBech32};
3use zeroize::Zeroize;
2 4
3use crate::{ 5use crate::{
6 cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms},
4 config::{ConfigManagement, ConfigManager}, 7 config::{ConfigManagement, ConfigManager},
5 key_handling::users::{UserManagement, UserManager}, 8 key_handling::{
9 encryption::{EncryptDecrypt, Encryptor},
10 users::{UserManagement, UserManager},
11 },
6}; 12};
7 13
8pub fn launch(nsec: &Option<String>) -> Result<()> { 14/// handles the encrpytion and storage of key material
15pub fn launch(nsec: &Option<String>, password: &Option<String>) -> Result<nostr::Keys> {
16 // if nsec parameter
17 if let Some(nsec_unwrapped) = nsec {
18 // get key or fail without prompts
19 let key = nostr::Keys::from_sk_str(nsec_unwrapped).context("invalid nsec parameter")?;
20 println!(
21 "logged in as {}",
22 &key.public_key()
23 .to_bech32()
24 .context("public key should always produce bech32")?
25 );
26
27 // if password, add user to enable password login in future
28 if password.is_some() {
29 UserManager::default()
30 .add(nsec, password)
31 .context("could not store identity")?;
32 }
33 return Ok(key);
34 }
35
36 // if encrypted nsec stored, attempt password
9 let cfg = ConfigManager 37 let cfg = ConfigManager
10 .load() 38 .load()
11 .context("failed to load application config")?; 39 .context("failed to load application config")?;
12 if !cfg.users.is_empty() { 40 let key = if let Some(user) = cfg.users.last() {
13 println!("logged in as {}", cfg.users[0].nsec); 41 let mut pass = if let Some(p) = password.clone() {
14 } 42 p
15 UserManager::default().add(nsec) 43 } else {
44 println!(
45 "login as {}",
46 &user
47 .public_key
48 .to_bech32()
49 .context("public key should always produce bech32")?
50 );
51 Interactor::default()
52 .password(PromptPasswordParms::default().with_prompt("password"))
53 .context("failed to get password input from interactor.password")?
54 };
55
56 let key_result = Encryptor
57 .decrypt_key(&user.encrypted_key, pass.as_str())
58 .context("failed to decrypt key with provided password");
59 pass.zeroize();
60
61 key_result.context(format!(
62 "failed to log in as {}",
63 &user
64 .public_key
65 .to_bech32()
66 .context("public key should always produce bech32")?
67 ))?
68 } else {
69 // no nsec but password supplied
70 if password.is_some() {
71 bail!("no nsec available to decrypt with specified password");
72 }
73 // otherwise add new user with nsec and password prompts
74 UserManager::default()
75 .add(nsec, password)
76 .context("failed to add user")?
77 };
78 println!(
79 "logged in as {}",
80 &key.public_key()
81 .to_bech32()
82 .context("public key should always produce bech32")?
83 );
84 Ok(key)
16} 85}
diff --git a/src/main.rs b/src/main.rs
index d16f1a3..e6eac32 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,8 +17,11 @@ pub struct Cli {
17 #[command(subcommand)] 17 #[command(subcommand)]
18 command: Commands, 18 command: Commands,
19 /// nsec or hex private key 19 /// nsec or hex private key
20 #[arg(short, long)] 20 #[arg(short, long, global = true)]
21 nsec: Option<String>, 21 nsec: Option<String>,
22 /// password to decrypt nsec
23 #[arg(short, long, global = true)]
24 password: Option<String>,
22} 25}
23 26
24#[derive(Subcommand)] 27#[derive(Subcommand)]
diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs
index d61f578..5391024 100644
--- a/src/sub_commands/login.rs
+++ b/src/sub_commands/login.rs
@@ -7,5 +7,6 @@ use crate::{login, Cli};
7pub struct SubCommandArgs; 7pub struct SubCommandArgs;
8 8
9pub fn launch(args: &Cli, _command_args: &SubCommandArgs) -> Result<()> { 9pub fn launch(args: &Cli, _command_args: &SubCommandArgs) -> Result<()> {
10 login::launch(&args.nsec) 10 let _ = login::launch(&args.nsec, &args.password)?;
11 Ok(())
11} 12}