diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2023-09-01 00:00:00 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2023-09-01 00:00:00 +0000 |
| commit | 96660a90e4cd296a2922d7a547de4cd9d0b1928b (patch) | |
| tree | e5216e22ee1a3e1653d8d1ecd856f4f03615d6a1 /test_utils | |
| parent | 6423baebd92e45c9be85157c443dff42e65d8d14 (diff) | |
feat(login) password login using encrypted nsec
Enables the user to only handle the nsec upon first use of the tool
by encrypting it with a password and storing it on disk in an
application cache.
The approach to encryption draws heavily from that used by the gossip
nostr client.
- unencrypted nsec is zeroed from memory
- a salt is used to defend against rainbow tables
- computationally expensive key stretching defends against
brute-force attacks of passwords with low entropy.
There is UX trade-off between decryption speed and key-stretching
computation. This UX challenge is exacerbated in a cli tool as
decryption must take place more regularly. Thought was put into the
selected n_log and a heavily reduced value is provided for long
passwords where security benefits are smaller.
A more granular reducing in computation was also considered by
rejected to avoided to revealing just how weak a password is as most
weak passwords are reused.
Diffstat (limited to 'test_utils')
| -rw-r--r-- | test_utils/Cargo.toml | 2 | ||||
| -rw-r--r-- | test_utils/src/lib.rs | 116 |
2 files changed, 114 insertions, 4 deletions
diff --git a/test_utils/Cargo.toml b/test_utils/Cargo.toml index e1f6090..1a39957 100644 --- a/test_utils/Cargo.toml +++ b/test_utils/Cargo.toml | |||
| @@ -8,5 +8,7 @@ anyhow = "1.0.75" | |||
| 8 | assert_cmd = "2.0.12" | 8 | assert_cmd = "2.0.12" |
| 9 | dialoguer = "0.10.4" | 9 | dialoguer = "0.10.4" |
| 10 | directories = "5.0.1" | 10 | directories = "5.0.1" |
| 11 | nostr = "0.23.0" | ||
| 12 | once_cell = "1.18.0" | ||
| 11 | rexpect = { git = "https://github.com/phaer/rexpect.git", branch= "skip-ansi-escape-codes" } | 13 | rexpect = { git = "https://github.com/phaer/rexpect.git", branch= "skip-ansi-escape-codes" } |
| 12 | strip-ansi-escapes = "0.2.0" | 14 | strip-ansi-escapes = "0.2.0" |
diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index 495e8d2..1a4231a 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs | |||
| @@ -3,14 +3,38 @@ use std::ffi::OsStr; | |||
| 3 | use anyhow::{ensure, Context, Result}; | 3 | use anyhow::{ensure, Context, Result}; |
| 4 | use dialoguer::theme::{ColorfulTheme, Theme}; | 4 | use dialoguer::theme::{ColorfulTheme, Theme}; |
| 5 | use directories::ProjectDirs; | 5 | use directories::ProjectDirs; |
| 6 | use nostr::{self, prelude::FromSkStr}; | ||
| 7 | use once_cell::sync::Lazy; | ||
| 6 | use rexpect::session::{Options, PtySession}; | 8 | use rexpect::session::{Options, PtySession}; |
| 7 | use strip_ansi_escapes::strip_str; | 9 | use strip_ansi_escapes::strip_str; |
| 8 | 10 | ||
| 9 | pub static TEST_KEY_1_NSEC: &str = | 11 | pub static TEST_KEY_1_NSEC: &str = |
| 10 | "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; | 12 | "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; |
| 13 | pub static TEST_KEY_1_SK_HEX: &str = | ||
| 14 | "08608a436aee4c07ea5c36f85cb17c58f52b3ad7094f9318cc777771f0bf218b"; | ||
| 15 | pub static TEST_KEY_1_NPUB: &str = | ||
| 16 | "npub175lyhnt6nn00qjw0v3navw9pxgv43txnku0tpxprl4h6mvpr6a5qlphudg"; | ||
| 17 | pub static TEST_KEY_1_DISPLAY_NAME: &str = "bob"; | ||
| 18 | pub static TEST_KEY_1_ENCRYPTED: &str = "ncryptsec1qyq607h3cykxc3f2a44u89cdk336fptccn3fm5pf3nmf93d3c86qpunc7r6klwcn6lyszjy72wxwqq9aljg4pm6atvjrds9e248yhv76xfnt464265kgnjsvg8rlg06wg4sp9uljzfpu8zuaztcvfn2j8ggdrg8mldh850cy75efsyqqansert9wqmn4e6khpgvfz7h5le9"; | ||
| 19 | pub static TEST_KEY_1_ENCRYPTED_WEAK: &str = "ncryptsec1qy8ke0tjqnn8wt3w6lnc86c27ry3qrptxctjfcgruryxy0at238kwyjwsswd7z88thysruzw3awlrsxjvw5uptcd7vt70ft9rtkx00m8cgy3khm4hxa5d2gfnc6athnfruy2eyl6pkas8k34jg85z7xjqqadzfzh9rp0fzxqtw0tvxksac3n8yc98uksvuf93e0lcvqy8j6"; | ||
| 20 | pub static TEST_KEY_1_KEYS: Lazy<nostr::Keys> = | ||
| 21 | Lazy::new(|| nostr::Keys::from_sk_str(TEST_KEY_1_NSEC).unwrap()); | ||
| 11 | 22 | ||
| 12 | pub static TEST_KEY_2_NSEC: &str = | 23 | pub static TEST_KEY_2_NSEC: &str = |
| 13 | "nsec1ypglg6nj6ep0g2qmyfqcv2al502gje3jvpwye6mthmkvj93tqkesknv6qm"; | 24 | "nsec1ypglg6nj6ep0g2qmyfqcv2al502gje3jvpwye6mthmkvj93tqkesknv6qm"; |
| 25 | pub static TEST_KEY_2_NPUB: &str = | ||
| 26 | "npub1h2yz2eh0798nh25hvypenrz995nla9dktfuk565ljf3ghnkhdljsul834e"; | ||
| 27 | |||
| 28 | pub static TEST_KEY_2_DISPLAY_NAME: &str = "carole"; | ||
| 29 | pub static TEST_KEY_2_ENCRYPTED: &str = "...2"; | ||
| 30 | pub static TEST_KEY_2_KEYS: Lazy<nostr::Keys> = | ||
| 31 | Lazy::new(|| nostr::Keys::from_sk_str(TEST_KEY_2_NSEC).unwrap()); | ||
| 32 | |||
| 33 | pub static TEST_INVALID_NSEC: &str = "nsec1ppsg5sm2aex"; | ||
| 34 | pub static TEST_PASSWORD: &str = "769dfd£pwega8SHGv3!#Bsfd5t"; | ||
| 35 | pub static TEST_INVALID_PASSWORD: &str = "INVALID769dfd£pwega8SHGv3!"; | ||
| 36 | pub static TEST_WEAK_PASSWORD: &str = "fhaiuhfwe"; | ||
| 37 | pub static TEST_RANDOM_TOKEN: &str = "lkjh2398HLKJ43hrweiJ6FaPfdssgtrg"; | ||
| 14 | 38 | ||
| 15 | /// wrapper for a cli testing tool - currently wraps rexpect and dialoguer | 39 | /// wrapper for a cli testing tool - currently wraps rexpect and dialoguer |
| 16 | /// | 40 | /// |
| @@ -41,6 +65,16 @@ impl CliTester { | |||
| 41 | i.prompt(true).context("initial input prompt")?; | 65 | i.prompt(true).context("initial input prompt")?; |
| 42 | Ok(i) | 66 | Ok(i) |
| 43 | } | 67 | } |
| 68 | |||
| 69 | pub fn expect_password(&mut self, prompt: &str) -> Result<CliTesterPasswordPrompt> { | ||
| 70 | let mut i = CliTesterPasswordPrompt { | ||
| 71 | tester: self, | ||
| 72 | prompt: prompt.to_string(), | ||
| 73 | confirmation_prompt: "".to_string(), | ||
| 74 | }; | ||
| 75 | i.prompt().context("initial password prompt")?; | ||
| 76 | Ok(i) | ||
| 77 | } | ||
| 44 | } | 78 | } |
| 45 | 79 | ||
| 46 | pub struct CliTesterInputPrompt<'a> { | 80 | pub struct CliTesterInputPrompt<'a> { |
| @@ -101,6 +135,70 @@ impl CliTesterInputPrompt<'_> { | |||
| 101 | } | 135 | } |
| 102 | } | 136 | } |
| 103 | 137 | ||
| 138 | pub struct CliTesterPasswordPrompt<'a> { | ||
| 139 | tester: &'a mut CliTester, | ||
| 140 | prompt: String, | ||
| 141 | confirmation_prompt: String, | ||
| 142 | } | ||
| 143 | |||
| 144 | impl CliTesterPasswordPrompt<'_> { | ||
| 145 | fn prompt(&mut self) -> Result<&mut Self> { | ||
| 146 | let p = match self.confirmation_prompt.is_empty() { | ||
| 147 | true => self.prompt.as_str(), | ||
| 148 | false => self.confirmation_prompt.as_str(), | ||
| 149 | }; | ||
| 150 | |||
| 151 | let mut s = String::new(); | ||
| 152 | self.tester | ||
| 153 | .formatter | ||
| 154 | .format_password_prompt(&mut s, p) | ||
| 155 | .expect("diagluer theme formatter should succeed"); | ||
| 156 | |||
| 157 | ensure!(s.contains(p), "dialoguer must be broken"); | ||
| 158 | |||
| 159 | self.tester | ||
| 160 | .expect(format!("\r{}", sanatize(s)).as_str()) | ||
| 161 | .context("expect password input prompt")?; | ||
| 162 | Ok(self) | ||
| 163 | } | ||
| 164 | |||
| 165 | pub fn with_confirmation(&mut self, prompt: &str) -> Result<&mut Self> { | ||
| 166 | self.confirmation_prompt = prompt.to_string(); | ||
| 167 | Ok(self) | ||
| 168 | } | ||
| 169 | |||
| 170 | pub fn succeeds_with(&mut self, password: &str) -> Result<&mut Self> { | ||
| 171 | self.tester.send_line(password)?; | ||
| 172 | |||
| 173 | self.tester | ||
| 174 | .expect("\r\n") | ||
| 175 | .context("expect new lines after password input")?; | ||
| 176 | |||
| 177 | if !self.confirmation_prompt.is_empty() { | ||
| 178 | self.prompt() | ||
| 179 | .context("expect password confirmation prompt")?; | ||
| 180 | self.tester.send_line(password)?; | ||
| 181 | self.tester | ||
| 182 | .expect("\r\n\r") | ||
| 183 | .context("expect new lines after password confirmation input")?; | ||
| 184 | } | ||
| 185 | |||
| 186 | let mut s = String::new(); | ||
| 187 | self.tester | ||
| 188 | .formatter | ||
| 189 | .format_password_prompt_selection(&mut s, self.prompt.as_str()) | ||
| 190 | .expect("diagluer theme formatter should succeed"); | ||
| 191 | |||
| 192 | ensure!(s.contains(self.prompt.as_str()), "dialoguer must be broken"); | ||
| 193 | |||
| 194 | self.tester | ||
| 195 | .expect(format!("\r{}\r\n", sanatize(s)).as_str()) | ||
| 196 | .context("expect password prompt success")?; | ||
| 197 | |||
| 198 | Ok(self) | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 104 | impl CliTester { | 202 | impl CliTester { |
| 105 | pub fn new<I, S>(args: I) -> Self | 203 | pub fn new<I, S>(args: I) -> Self |
| 106 | where | 204 | where |
| @@ -108,7 +206,17 @@ impl CliTester { | |||
| 108 | S: AsRef<OsStr>, | 206 | S: AsRef<OsStr>, |
| 109 | { | 207 | { |
| 110 | Self { | 208 | Self { |
| 111 | rexpect_session: rexpect_with(args).expect("rexpect to spawn new process"), | 209 | rexpect_session: rexpect_with(args, 2000).expect("rexpect to spawn new process"), |
| 210 | formatter: ColorfulTheme::default(), | ||
| 211 | } | ||
| 212 | } | ||
| 213 | pub fn new_with_timeout<I, S>(timeout_ms: u64, args: I) -> Self | ||
| 214 | where | ||
| 215 | I: IntoIterator<Item = S>, | ||
| 216 | S: AsRef<OsStr>, | ||
| 217 | { | ||
| 218 | Self { | ||
| 219 | rexpect_session: rexpect_with(args, timeout_ms).expect("rexpect to spawn new process"), | ||
| 112 | formatter: ColorfulTheme::default(), | 220 | formatter: ColorfulTheme::default(), |
| 113 | } | 221 | } |
| 114 | } | 222 | } |
| @@ -122,7 +230,7 @@ impl CliTester { | |||
| 122 | .process | 230 | .process |
| 123 | .exit() | 231 | .exit() |
| 124 | .expect("process to exit"); | 232 | .expect("process to exit"); |
| 125 | self.rexpect_session = rexpect_with(args).expect("rexpect to spawn new process"); | 233 | self.rexpect_session = rexpect_with(args, 2000).expect("rexpect to spawn new process"); |
| 126 | self | 234 | self |
| 127 | } | 235 | } |
| 128 | 236 | ||
| @@ -213,7 +321,7 @@ fn sanatize(s: String) -> String { | |||
| 213 | .collect::<String>() | 321 | .collect::<String>() |
| 214 | } | 322 | } |
| 215 | 323 | ||
| 216 | pub fn rexpect_with<I, S>(args: I) -> Result<PtySession, rexpect::error::Error> | 324 | pub fn rexpect_with<I, S>(args: I, timeout_ms: u64) -> Result<PtySession, rexpect::error::Error> |
| 217 | where | 325 | where |
| 218 | I: IntoIterator<Item = S>, | 326 | I: IntoIterator<Item = S>, |
| 219 | S: AsRef<std::ffi::OsStr>, | 327 | S: AsRef<std::ffi::OsStr>, |
| @@ -224,7 +332,7 @@ where | |||
| 224 | rexpect::session::spawn_with_options( | 332 | rexpect::session::spawn_with_options( |
| 225 | cmd, | 333 | cmd, |
| 226 | Options { | 334 | Options { |
| 227 | timeout_ms: Some(2000), | 335 | timeout_ms: Some(timeout_ms), |
| 228 | strip_ansi_escape_codes: true, | 336 | strip_ansi_escape_codes: true, |
| 229 | }, | 337 | }, |
| 230 | ) | 338 | ) |