├── .github ├── dependabot.yml └── workflows │ ├── security_audit.yml │ └── tests.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src ├── config.rs ├── errors.rs └── main.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "cargo" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/security_audit.yml: -------------------------------------------------------------------------------- 1 | name: Security Audit 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' # Midnight of each day 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | security_audit: 11 | name: Security Audit 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout sources 15 | uses: actions/checkout@v4 16 | with: 17 | ref: master 18 | persist-credentials: false 19 | 20 | - name: Install stable toolchain 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: stable 24 | override: true 25 | 26 | - name: Install cargo audit 27 | run: cargo install cargo-audit 28 | 29 | - name: Run cargo audit 30 | uses: actions-rs/cargo@v1 31 | with: 32 | command: audit 33 | args: --ignore RUSTSEC-2020-0095 --deny warnings -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | on: 2 | # Test on PRs for any branch 3 | pull_request: 4 | branches: 5 | - '*' 6 | # Test only pushes to master branch 7 | push: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: '0 0 * * 0' # Midnight of each sunday 12 | 13 | name: Tests 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | tests: 19 | name: Test Suite 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | rust: 24 | - stable 25 | - nightly 26 | 27 | steps: 28 | - name: Checkout sources 29 | uses: actions/checkout@v4 30 | with: 31 | persist-credentials: false 32 | 33 | - name: Install toolchain 34 | uses: actions-rs/toolchain@v1 35 | with: 36 | profile: minimal 37 | toolchain: ${{ matrix.rust }} 38 | override: true 39 | - name: Run cargo test 40 | env: 41 | API_KEY: ${{secrets.HIBP_TEST_KEY}} 42 | uses: actions-rs/cargo@v1 43 | with: 44 | command: test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | list.ls 13 | 14 | # Good ol' Mac. 15 | .DS_Store 16 | 17 | .idea -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "checkpwn" 3 | version = "0.5.6" 4 | authors = ["brycx "] 5 | description = "Check Have I Been Pwned and see if it's time for you to change passwords." 6 | keywords = [ "cli", "password", "security", "HIBP" ] 7 | categories = [ "command-line-utilities" ] 8 | readme = "README.md" 9 | edition = "2018" 10 | repository = "https://github.com/brycx/checkpwn" 11 | license = "MIT" 12 | 13 | [dependencies] 14 | colored = "2.0" 15 | rpassword = "7.0.0" 16 | zeroize = "1.3.0" 17 | dirs-next = "2.0.0" 18 | serde = { version = "1.0.106", features = ["derive"] } 19 | serde_yaml = "0.9.13" 20 | checkpwn_lib = "0.2.0" 21 | anyhow = "1.0.33" 22 | 23 | [dev-dependencies] 24 | assert_cmd = "2.0.0" 25 | rand = "0.8.4" 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2022 brycx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## checkpwn ![Tests](https://github.com/brycx/checkpwn/workflows/Tests/badge.svg) ![Security Audit](https://github.com/brycx/checkpwn/workflows/Security%20Audit/badge.svg) 2 | Check [Have I Been Pwned](https://haveibeenpwned.com/) and see if it's time for you to change passwords. 3 | 4 | 5 | ### Getting started 6 | 7 | #### Install: 8 | ``` 9 | cargo install checkpwn 10 | ``` 11 | 12 | #### Update: 13 | ``` 14 | cargo install --force checkpwn 15 | ``` 16 | 17 | #### Register & update API key: 18 | ``` 19 | checkpwn register 123456789 20 | ``` 21 | 22 | This command creates a `checkpwn.yml` configuration file in the users configuration directory, 23 | which saves the API key. This is needed for all calls to the account API (`checkpwn acc`). 24 | 25 | #### Check an account, or list of accounts, for breaches: 26 | ``` 27 | checkpwn acc test@example.com 28 | ``` 29 | 30 | ``` 31 | checkpwn acc daily_breach_check.ls 32 | ``` 33 | 34 | _NOTE: List files must have the .ls file extension._ 35 | 36 | When checking accounts, they will be run against both the HIBP "paste" and "account" database. 37 | 38 | #### Check a password: 39 | ``` 40 | checkpwn pass 41 | ``` 42 | 43 | ### Changelog 44 | 45 | See [here](https://github.com/brycx/checkpwn/releases). 46 | 47 | ### License 48 | checkpwn is licensed under the MIT license. See the `LICENSE` file for more information. 49 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use dirs_next::config_dir; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{fs, io::Write, path::PathBuf}; 4 | 5 | const CHECKPWN_CONFIG_FILE_NAME: &str = "checkpwn.yml"; 6 | const CHECKPWN_CONFIG_DIR: &str = "checkpwn"; 7 | 8 | #[derive(Serialize, Deserialize, Debug)] 9 | pub struct Config { 10 | pub api_key: String, 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct ConfigPaths { 15 | pub config_file_path: PathBuf, 16 | } 17 | 18 | impl Config { 19 | pub fn new() -> Config { 20 | Config { 21 | api_key: "".to_string(), 22 | } 23 | } 24 | 25 | pub fn get_config_path(&self) -> Option { 26 | match config_dir() { 27 | Some(mut dir) => { 28 | dir.push(CHECKPWN_CONFIG_DIR); 29 | dir.push(CHECKPWN_CONFIG_FILE_NAME); 30 | Some(ConfigPaths { 31 | config_file_path: dir, 32 | }) 33 | } 34 | None => None, 35 | } 36 | } 37 | 38 | fn build_path(&self) -> Result<(), Box> { 39 | let mut path = self 40 | .get_config_path() 41 | .expect("Failed to determine configuration file path."); 42 | path.config_file_path.pop(); //remove the filename so we don't accidentally create it as a directory 43 | fs::create_dir_all(&path.config_file_path)?; 44 | Ok(()) 45 | } 46 | 47 | #[cfg(debug_assertions)] 48 | pub fn load_config(&mut self) -> Result<(), Box> { 49 | // If in CI, the key is in env. Local tests use the config file. 50 | match std::env::var("API_KEY") { 51 | Ok(api_key) => { 52 | self.api_key = api_key; 53 | Ok(()) 54 | } 55 | Err(std::env::VarError::NotPresent) => { 56 | let path = self 57 | .get_config_path() 58 | .expect("Failed to determine configuration file path."); 59 | let config_string = fs::read_to_string(&path.config_file_path)?; 60 | let config_yml: Config = serde_yaml::from_str(&config_string)?; 61 | 62 | self.api_key = config_yml.api_key; 63 | Ok(()) 64 | } 65 | _ => panic!("CI API KEY WAS NOT UTF8"), 66 | } 67 | } 68 | 69 | #[cfg(not(debug_assertions))] 70 | pub fn load_config(&mut self) -> Result<(), Box> { 71 | let path = self 72 | .get_config_path() 73 | .expect("Failed to determine configuration file path."); 74 | let config_string = fs::read_to_string(&path.config_file_path)?; 75 | let config_yml: Config = serde_yaml::from_str(&config_string)?; 76 | 77 | self.api_key = config_yml.api_key; 78 | 79 | Ok(()) 80 | } 81 | 82 | pub fn save_config(&self, api_key: &str) -> Result<(), Box> { 83 | let path: ConfigPaths = self 84 | .get_config_path() 85 | .expect("Failed to determine configuration file path."); 86 | 87 | self.build_path()?; 88 | let new_config = Config { 89 | api_key: api_key.to_string(), 90 | }; 91 | 92 | let config_to_write = serde_yaml::to_string(&new_config)?; 93 | let mut config_file = fs::File::create(&path.config_file_path)?; 94 | config_file.write_all(config_to_write.as_bytes())?; 95 | 96 | Ok(()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | // MIT License 2 | 3 | // Copyright (c) 2018-2022 brycx 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | /// All the different errors for checkpwn; 24 | /// Errors that are meant to be internal or or unreachable print this. 25 | pub const USAGE_ERROR: &str = 26 | "Usage: checkpwn { pass | acc ( | | .ls) | register }"; 27 | pub const READ_FILE_ERROR: &str = "Error reading local file"; 28 | pub const BUFREADER_ERROR: &str = "Failed to read file in to BufReader"; 29 | pub const READLINE_ERROR: &str = "Failed to read line from file"; 30 | pub const MISSING_API_KEY: &str = "Failed to read or parse the configuration file 'checkpwn.yml'. You need to register an API key to be able to check accounts"; 31 | 32 | /// Set panic hook, to have .unwrap(), etc, return the custom panic message. 33 | macro_rules! set_checkpwn_panic { 34 | ($x:expr) => { 35 | // Set new hook with custom message 36 | panic::set_hook(Box::new(|_| { 37 | println!( 38 | "\nThe following error was encountered: {:?}\n\ 39 | \nIf you think this is a bug, please report it in the project repository.", 40 | $x 41 | ); 42 | })); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // MIT License 2 | 3 | // Copyright (c) 2018-2022 brycx 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | #![forbid(unsafe_code)] 24 | #![deny(clippy::mem_forget)] 25 | #![warn( 26 | rust_2018_idioms, 27 | trivial_casts, 28 | unused_qualifications, 29 | overflowing_literals 30 | )] 31 | 32 | mod config; 33 | #[macro_use] 34 | mod errors; 35 | 36 | use anyhow::Result; 37 | use checkpwn_lib::Password; 38 | use colored::Colorize; 39 | 40 | use std::fs::File; 41 | use std::io::{BufReader, Error}; 42 | 43 | #[cfg(test)] 44 | use assert_cmd::prelude::*; 45 | use std::env; 46 | use std::io::{stdin, BufRead}; 47 | use std::panic; 48 | #[cfg(test)] 49 | use std::process::Command; 50 | use zeroize::Zeroize; 51 | 52 | fn main() -> Result<()> { 53 | // Set custom usage panic message 54 | set_checkpwn_panic!(errors::USAGE_ERROR); 55 | assert!(env::args().len() >= 2); 56 | assert!(env::args().len() < 4); 57 | 58 | let mut argvs: Vec = env::args().collect(); 59 | 60 | match argvs[1].to_lowercase().as_str() { 61 | "acc" => { 62 | assert_eq!(argvs.len(), 3); 63 | acc_check(&argvs[2])?; 64 | } 65 | "pass" => { 66 | assert_eq!(argvs.len(), 2); 67 | let hashed_password = Password::new(&rpassword::prompt_password("Password: ")?)?; 68 | let is_breached = checkpwn_lib::check_password(&hashed_password)?; 69 | breach_report(is_breached, "", true); 70 | } 71 | "register" => { 72 | assert_eq!(argvs.len(), 3); 73 | let configuration = config::Config::new(); 74 | let config_path = configuration 75 | .get_config_path() 76 | .expect("Failed to determine configuration file path."); 77 | 78 | if !config_path.config_file_path.exists() { 79 | match configuration.save_config(&argvs[2]) { 80 | Ok(()) => println!("Successfully saved client configuration."), 81 | Err(e) => panic!("Encountered error saving client configuration: {}", e), 82 | } 83 | } else { 84 | println!( 85 | "A configuration file already exists. Do you want to overwrite it? [y/n]: " 86 | ); 87 | let mut overwrite_choice = String::new(); 88 | 89 | stdin().read_line(&mut overwrite_choice)?; 90 | overwrite_choice = overwrite_choice.to_lowercase(); 91 | 92 | match overwrite_choice.trim() { 93 | "y" => match configuration.save_config(&argvs[2]) { 94 | Ok(()) => println!("Successfully saved new client configuration."), 95 | Err(e) => panic!("Encountered error saving client configuration: {}", e), 96 | }, 97 | "n" => println!("Configuration unchanged. Exiting client."), 98 | _ => panic!("Invalid choice. Please enter 'y' for 'yes' or 'n' for 'no'."), 99 | } 100 | } 101 | } 102 | _ => panic!(), 103 | }; 104 | // Zero out the collected arguments, in case the user accidentally inputs sensitive info 105 | argvs.iter_mut().zeroize(); 106 | 107 | Ok(()) 108 | } 109 | 110 | /// Make a breach report based on a u16 status code and print result to terminal. 111 | fn breach_report(breached: bool, searchterm: &str, is_password: bool) { 112 | // Do not display password in terminal 113 | let request_key = if is_password { "********" } else { searchterm }; 114 | 115 | if breached { 116 | println!( 117 | "Breach status for {}: {}", 118 | request_key.cyan(), 119 | "BREACH FOUND".red() 120 | ); 121 | } else { 122 | println!( 123 | "Breach status for {}: {}", 124 | request_key.cyan(), 125 | "NO BREACH FOUND".green() 126 | ); 127 | } 128 | } 129 | 130 | /// Read file into buffer. 131 | fn read_file(path: &str) -> Result, Error> { 132 | set_checkpwn_panic!(errors::READ_FILE_ERROR); 133 | let file_path = File::open(path).unwrap(); 134 | 135 | Ok(BufReader::new(file_path)) 136 | } 137 | 138 | /// Strip all whitespace and all newlines from a given string. 139 | fn strip(string: &str) -> String { 140 | string 141 | .replace('\n', "") 142 | .replace(' ', "") 143 | .replace('\'', "") 144 | .replace('\t', "") 145 | } 146 | 147 | /// HIBP breach request used for `acc` arguments. 148 | fn acc_breach_request(searchterm: &str, api_key: &str) -> Result<(), checkpwn_lib::CheckpwnError> { 149 | let is_breached = checkpwn_lib::check_account(searchterm, api_key)?; 150 | breach_report(is_breached, searchterm, false); 151 | 152 | Ok(()) 153 | } 154 | 155 | fn acc_check(data_search: &str) -> Result<(), checkpwn_lib::CheckpwnError> { 156 | // NOTE: checkpwn_lib handles any sleeping so we don't exceed the rate limit. 157 | set_checkpwn_panic!(errors::MISSING_API_KEY); 158 | let mut config = config::Config::new(); 159 | config.load_config().unwrap(); 160 | 161 | // Check if user wants to check a local list 162 | if data_search.ends_with(".ls") { 163 | set_checkpwn_panic!(errors::BUFREADER_ERROR); 164 | let file = read_file(data_search).unwrap(); 165 | 166 | for line_iter in file.lines() { 167 | set_checkpwn_panic!(errors::READLINE_ERROR); 168 | let line = strip(&line_iter.unwrap()); 169 | if line.is_empty() { 170 | continue; 171 | } 172 | acc_breach_request(&line, &config.api_key)?; 173 | } 174 | } else { 175 | acc_breach_request(data_search, &config.api_key)?; 176 | } 177 | 178 | Ok(()) 179 | } 180 | 181 | #[test] 182 | fn test_strip_white_new() { 183 | let string_1 = String::from("fkljjsdjlksfdklj dfiwj wefwefwfe"); 184 | let string_2 = String::from("derbrererer\n"); 185 | let string_3 = String::from("dee\nwfweww rb tte rererer\n"); 186 | 187 | assert_eq!(&strip(&string_1), "fkljjsdjlksfdkljdfiwjwefwefwfe"); 188 | assert_eq!(&strip(&string_2), "derbrererer"); 189 | assert_eq!(&strip(&string_3), "deewfwewwrbtterererer"); 190 | } 191 | 192 | #[test] 193 | fn test_cli_acc_breach() { 194 | let res = Command::new("cargo") 195 | .args(&["run", "acc", "test@example.com"]) 196 | .unwrap(); 197 | 198 | assert!(String::from_utf8_lossy(&res.stdout).contains("BREACH FOUND")); 199 | } 200 | 201 | #[test] 202 | fn test_cli_acc_no_breach() { 203 | use rand::prelude::*; 204 | 205 | let mut rng = thread_rng(); 206 | let mut email_user: [char; 8] = ['a'; 8]; 207 | let mut email_domain: [char; 8] = ['a'; 8]; 208 | rng.fill(&mut email_user); 209 | rng.fill(&mut email_domain); 210 | 211 | let rnd_email = format!( 212 | "{:?}@{:?}.com", 213 | email_user.iter().collect::(), 214 | email_domain.iter().collect::() 215 | ); 216 | 217 | let res = Command::new("cargo") 218 | .args(&["run", "acc", &rnd_email]) 219 | .unwrap(); 220 | 221 | assert!( 222 | String::from_utf8_lossy(&res.stdout).contains("NO BREACH FOUND"), 223 | "Found breach for {:?}", 224 | rnd_email 225 | ); 226 | } 227 | 228 | #[test] 229 | #[should_panic] 230 | fn test_cli_arg_fail() { 231 | Command::new("cargo") 232 | .args(&["run", "wrong", "test@example.com"]) 233 | .unwrap() 234 | .assert() 235 | .failure(); 236 | } 237 | 238 | #[test] 239 | #[should_panic] 240 | fn test_cli_arg_fail_2() { 241 | Command::new("cargo") 242 | .args(&["run"]) 243 | .unwrap() 244 | .assert() 245 | .failure(); 246 | } 247 | 248 | #[test] 249 | #[should_panic] 250 | fn test_cli_arg_fail_3() { 251 | Command::new("cargo") 252 | .args(&["run", "wrong", "test@example.com", "too much"]) 253 | .unwrap() 254 | .assert() 255 | .failure(); 256 | } 257 | 258 | #[test] 259 | fn test_cli_arg_ok() { 260 | Command::new("cargo") 261 | .args(&["run", "acc", "test@example.com"]) 262 | .unwrap() 263 | .assert() 264 | .success(); 265 | } 266 | --------------------------------------------------------------------------------