From c8f316e9d1ec4b33ab575424d003bd0bc628840d Mon Sep 17 00:00:00 2001 From: Johannes Date: Wed, 11 Nov 2020 10:53:55 +0100 Subject: [PATCH] Switch to checkpwn-lib (#35) --- Cargo.toml | 8 +- src/api/mod.rs | 333 ---------------------------------------- src/config.rs | 8 +- src/{api => }/errors.rs | 9 -- src/main.rs | 181 +++++++++++++--------- 5 files changed, 115 insertions(+), 424 deletions(-) delete mode 100644 src/api/mod.rs rename src/{api => }/errors.rs (78%) diff --git a/Cargo.toml b/Cargo.toml index 90a6e86..a35223f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,24 @@ [package] name = "checkpwn" -version = "0.4.1" +version = "0.5.0" authors = ["brycx "] description = "Check Have I Been Pwned and see if it's time for you to change passwords." keywords = [ "cli", "password", "security", "HIBP" ] categories = [ "command-line-utilities" ] readme = "README.md" +edition = "2018" repository = "https://github.com/brycx/checkpwn" license = "MIT" [dependencies] colored = "2.0" -sha-1 = { version = "0.9.1", default-features = false } -hex = "0.4.2" -ureq = { version = "1.4.1", default-features = false, features = ["tls"] } rpassword = "5.0.0" zeroize = "1.1.0" dirs-next = "2.0.0" serde = { version = "1.0.106", features = ["derive"] } serde_yaml = "0.8.11" +checkpwn_lib = "0.1.0" +anyhow = "1.0.33" [dev-dependencies] assert_cmd = "1.0.1" diff --git a/src/api/mod.rs b/src/api/mod.rs deleted file mode 100644 index e6fd715..0000000 --- a/src/api/mod.rs +++ /dev/null @@ -1,333 +0,0 @@ -// MIT License - -// Copyright (c) 2018-2020 brycx - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -extern crate colored; -extern crate hex; -extern crate sha1; -extern crate ureq; -extern crate zeroize; -#[macro_use] -pub mod errors; - -use self::colored::Colorize; -use self::sha1::{Digest, Sha1}; -use zeroize::Zeroize; - -use std::fs::File; -use std::io::{BufReader, Error}; -use std::panic; - -pub const CHECKPWN_USER_AGENT: &str = "checkpwn - cargo utility tool for hibp"; - -pub enum CheckableChoices { - ACC, - PASS, - PASTE, -} - -impl CheckableChoices { - fn get_api_route(&self, search_term: &str) -> String { - match self { - CheckableChoices::ACC => format!( - "https://haveibeenpwned.com/api/v3/breachedaccount/{}", - search_term - ), - CheckableChoices::PASS => { - format!("https://api.pwnedpasswords.com/range/{}", search_term) - } - CheckableChoices::PASTE => format!( - "https://haveibeenpwned.com/api/v3/pasteaccount/{}", - search_term - ), - } - } -} - -pub struct PassArg { - pub password: String, -} - -impl Drop for PassArg { - fn drop(&mut self) { - self.password.zeroize() - } -} - -/// Take the user-supplied command-line arguments and make a URL for the HIBP API. -/// If the `pass` argument has been selected, `input_data` needs to be the hashed password. -pub fn arg_to_api_route(arg: &CheckableChoices, input_data: &str) -> String { - match arg { - CheckableChoices::PASS => arg.get_api_route( - // Only send the first 5 chars to the password range API - &input_data[..5], - ), - _ => arg.get_api_route(input_data), - } -} - -/// Find matching key in received set of keys. -pub fn search_in_range(password_range_response: &str, hashed_key: &str) -> bool { - for line in password_range_response.lines() { - let pair: Vec<_> = line.split(':').collect(); - // Padded entries always have an occurrence of 0 and should be - // discarded. - if *pair.get(1).unwrap() == "0" { - continue; - } - - // Each response is truncated to only be the hash, no whitespace, etc. - // All hashes here have a length of 35, so the useless gets dropped by - // slicing. Don't include first five characters of own password, as - // this also is how the HIBP API returns passwords. - if *pair.get(0).unwrap() == &hashed_key[5..] { - return true; - } - } - - false -} - -/// Make a breach report based on a u16 status code and print result to terminal. -pub fn breach_report(status_code: u16, searchterm: &str, is_password: bool) -> ((), bool) { - // Do not display password in terminal - let request_key = if is_password { "********" } else { searchterm }; - - match status_code { - 404 => ( - println!( - "Breach status for {}: {}", - request_key.cyan(), - "NO BREACH FOUND".green() - ), - false, - ), - 200 => ( - println!( - "Breach status for {}: {}", - request_key.cyan(), - "BREACH FOUND".red() - ), - true, - ), - _ => { - set_checkpwn_panic!(errors::STATUSCODE_ERROR); - panic!(); - } - } -} - -/// Return a breach report based on two StatusCodes, both need to be false to be a non-breach. -fn evaluate_acc_breach(acc_stat: u16, paste_stat: u16, search_key: &str) -> ((), bool) { - match (acc_stat, paste_stat) { - (401, 401) => { - set_checkpwn_panic!(errors::INVALID_API_KEY); - panic!(); - } - (404, 404) => breach_report(404, &search_key, false), - // BadRequest allowed here because the account API lets you search for usernames - // and the paste API will return BadRequest on those - (404, 400) => breach_report(404, &search_key, false), - (400, 400) => { - set_checkpwn_panic!(errors::BAD_RESPONSE_ERROR); - panic!(); - } - // Since the account API both takes username and emails and situation where BadRequest - // and NotFound are returned should never occur. - (400, 404) => { - set_checkpwn_panic!(errors::BAD_RESPONSE_ERROR); - panic!(); - } - (400, 200) => { - set_checkpwn_panic!(errors::BAD_RESPONSE_ERROR); - panic!(); - } - _ => breach_report(200, &search_key, false), - } -} - -/// HIBP breach request used for `acc` arguments. -pub fn acc_breach_request(searchterm: &str, api_key: &str) { - let acc_stat = ureq::get(&arg_to_api_route(&CheckableChoices::ACC, searchterm)) - .set("User-Agent", CHECKPWN_USER_AGENT) - .set("hibp-api-key", api_key) - .timeout_connect(10_000) - .call(); - - let paste_stat = ureq::get(&arg_to_api_route(&CheckableChoices::PASTE, searchterm)) - .set("User-Agent", CHECKPWN_USER_AGENT) - .set("hibp-api-key", api_key) - .timeout_connect(10_000) - .call(); - - evaluate_acc_breach(acc_stat.status(), paste_stat.status(), searchterm); -} - -/// Read file into buffer. -pub fn read_file(path: &str) -> Result, Error> { - set_checkpwn_panic!(errors::READ_FILE_ERROR); - let file_path = File::open(path).unwrap(); - - Ok(BufReader::new(file_path)) -} - -/// Return SHA1 digest of string. -pub fn hash_password(password: &str) -> String { - let mut sha_digest = Sha1::default(); - sha_digest.update(password.as_bytes()); - // Make uppercase for easier comparison with - // HIBP API response - hex::encode(sha_digest.finalize()).to_uppercase() -} - -/// Strip all whitespace and all newlines from a given string. -pub fn strip(string: &str) -> String { - string - .replace("\n", "") - .replace(" ", "") - .replace("\'", "'") - .replace("\t", "") -} - -#[test] -fn test_strip_white_new() { - let string_1 = String::from("fkljjsdjlksfdklj dfiwj wefwefwfe"); - let string_2 = String::from("derbrererer\n"); - let string_3 = String::from("dee\nwfweww rb tte rererer\n"); - - assert_eq!(&strip(&string_1), "fkljjsdjlksfdkljdfiwjwefwefwfe"); - assert_eq!(&strip(&string_2), "derbrererer"); - assert_eq!(&strip(&string_3), "deewfwewwrbtterererer"); -} - -#[test] -fn test_sha1() { - let hash = hash_password("qwerty"); - assert_eq!( - hash, - "b1b3773a05c0ed0176787a4f1574ff0075f7521e".to_uppercase() - ); -} - -#[test] -fn test_make_req_and_arg_to_route() { - // API paths taken from https://haveibeenpwned.com/API/v3 - let path = CheckableChoices::ACC.get_api_route("test@example.com"); - assert_eq!( - path, - "https://haveibeenpwned.com/api/v3/breachedaccount/test@example.com" - ); - assert_eq!( - "https://api.pwnedpasswords.com/range/B1B37", - arg_to_api_route(&CheckableChoices::PASS, &hash_password("qwerty")) - ); - assert_eq!( - "https://haveibeenpwned.com/api/v3/pasteaccount/test@example.com", - arg_to_api_route(&CheckableChoices::PASTE, "test@example.com") - ); -} - -#[test] -fn test_good_argument() { - let option_arg = CheckableChoices::ACC; - let data_search = String::from("test@example.com"); - - arg_to_api_route(&option_arg, &data_search); -} - -#[should_panic] -#[test] -fn test_breach_invalid_status() { - breach_report(403, "saome", true); -} - -#[test] -fn test_search_success_and_failure() { - // https://api.pwnedpasswords.com/range/B1B37 - - let contains_pass = String::from( - "73678F196DE938F721CD408ED190330F5DB:3 -7377BA15B8D5E12FCCBA32B074D45503D67:2 -7387376AFD1B3DAB553D439C8A7D7CDDED1:2 -73A05C0ED0176787A4F1574FF0075F7521E:3752262 -748186F058DA83745B80E70B66D36B216A4:4 -75FEC591927A596B6114ED5DAC4E4C22E04:10 -76004E5282C5384DE32AFC2148BAD032450:2 -769A96DED7A904FBE8F130508B2BFDDAEB1:3 -76B8A2A14A15A8C22A49EC451DE9778581A:2 -76C507D6248060841D4B4A4D444947E28A8:11 -782C978C9120CF75BE0D93BE1330C2705E5:2 -783F271CECC5F9BBC1E56B0585568C80248:5 -7855E6B64AF9544B2B915CB09ADF44B507E:1", - ); - - let no_pass = String::from( - "7EC6529B5FFD62972B78F961DA68CCC1B0E:1 -7ECD0E2C0152DB98585B54B0161E05D5823:2 -7ED83795FEA81B716B31648AE233AB392B6:1 -7F14F4258243863575CBF33215358357C61:4 -7FF32ECF384A7DBD7F1325F2AA9421747D8:6 -7FFDB37B4ACDBAD365DE51962CAFFEE7412:1 -801EEE3EB6CE29DB12AB39D4E4C1E579372:3 -80BADE9877A506510B46A393706CE0E554F:9 -818D08C77BAAD2270478CE11D97F2E64CEA:1", - ); - - let hashed_password = hash_password("qwerty"); - - assert_eq!(search_in_range(&contains_pass, &hashed_password), true); - assert_eq!(search_in_range(&no_pass, &hashed_password), false); -} - -#[test] -fn test_evaluate_breach_good() { - let (_, ok_ok) = evaluate_acc_breach(200, 200, "search_key"); - let (_, ok_notfound) = evaluate_acc_breach(200, 404, "search_key"); - let (_, notfound_ok) = evaluate_acc_breach(404, 200, "search_key"); - let (_, ok_badrequest) = evaluate_acc_breach(200, 400, "search_key"); - let (_, notfound_badrequest) = evaluate_acc_breach(404, 400, "search_key"); - let (_, notfound_notfound) = evaluate_acc_breach(404, 404, "search_key"); - - assert_eq!(ok_ok, true); - assert_eq!(ok_notfound, true); - assert_eq!(notfound_ok, true); - assert_eq!(ok_badrequest, true); - assert_eq!(notfound_badrequest, false); - assert_eq!(notfound_notfound, false); -} - -#[test] -#[should_panic] -fn test_evaluate_breach_panic() { - let _badrequest_badrequest = evaluate_acc_breach(400, 400, "search_key"); -} - -#[test] -#[should_panic] -fn test_evaluate_breach_panic_2() { - let _badrequest_notfound = evaluate_acc_breach(400, 404, "search_key"); -} - -#[test] -#[should_panic] -fn test_evaluate_breach_panic_3() { - let _badrequest_ok = evaluate_acc_breach(400, 200, "search_key"); -} diff --git a/src/config.rs b/src/config.rs index 6207e80..2c28c75 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,5 @@ -extern crate dirs_next; -extern crate serde; -extern crate serde_yaml; - -use self::dirs_next::config_dir; -use self::serde::{Deserialize, Serialize}; +use dirs_next::config_dir; +use serde::{Deserialize, Serialize}; use std::{fs, io::Write, path::PathBuf}; const CHECKPWN_CONFIG_FILE_NAME: &str = "checkpwn.yml"; diff --git a/src/api/errors.rs b/src/errors.rs similarity index 78% rename from src/api/errors.rs rename to src/errors.rs index f6491a8..dabd10d 100644 --- a/src/api/errors.rs +++ b/src/errors.rs @@ -24,18 +24,9 @@ /// Errors that are meant to be internal or or unreachable print this. pub const USAGE_ERROR: &str = "Usage: checkpwn { pass | acc ( | | .ls) | register }"; -pub const STATUSCODE_ERROR: &str = "Unrecognized status code received"; -pub const PASSWORD_ERROR: &str = "Error retrieving password from stdin"; pub const READ_FILE_ERROR: &str = "Error reading local file"; -pub const NETWORK_ERROR: &str = "Failed to send request to HIBP"; -pub const DECODING_ERROR: &str = "Failed to decode response from HIBP"; -pub const API_ARG_ERROR: &str = - "SHOULD_BE_UNREACHABLE: Invalid argument in API route construction detected"; -pub const BAD_RESPONSE_ERROR: &str = - "Received a bad response from HIBP - make sure the account is valid"; pub const BUFREADER_ERROR: &str = "Failed to read file in to BufReader"; pub const READLINE_ERROR: &str = "Failed to read line from file"; -pub const INVALID_API_KEY: &str = "HIBP deemed the current API key invalid"; 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"; /// Set panic hook, to have .unwrap(), etc, return the custom panic message. diff --git a/src/main.rs b/src/main.rs index b9cbbf0..604b7ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,80 +19,39 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -mod config; -#[cfg(test)] -extern crate assert_cmd; -extern crate rpassword; -extern crate serde; -extern crate ureq; -extern crate zeroize; +#![forbid(unsafe_code)] +#![deny(clippy::mem_forget)] +#![warn( + rust_2018_idioms, + trivial_casts, + unused_qualifications, + overflowing_literals +)] +mod config; #[macro_use] -pub mod api; +mod errors; + +use anyhow::Result; +use checkpwn_lib::Password; +use colored::Colorize; + +use std::fs::File; +use std::io::{BufReader, Error}; #[cfg(test)] use assert_cmd::prelude::*; +use std::env; use std::io::{stdin, BufRead}; use std::panic; #[cfg(test)] use std::process::Command; -use std::{env, thread, time}; use zeroize::Zeroize; -fn acc_check(data_search: &str) { - set_checkpwn_panic!(api::errors::MISSING_API_KEY); - let mut config = config::Config::new(); - config.load_config().unwrap(); - - // Check if user wants to check a local list - if data_search.ends_with(".ls") { - set_checkpwn_panic!(api::errors::BUFREADER_ERROR); - let file = api::read_file(data_search).unwrap(); - - for line_iter in file.lines() { - set_checkpwn_panic!(api::errors::READLINE_ERROR); - let line = api::strip(&line_iter.unwrap()); - if line.is_empty() { - continue; - } - api::acc_breach_request(&line, &config.api_key); - // HIBP limits requests to one per 1500 milliseconds. We're allowing for 1600 below as a buffer. - thread::sleep(time::Duration::from_millis(1600)); - } - } else { - api::acc_breach_request(data_search, &config.api_key); - } -} - -fn pass_check(data_search: &api::PassArg) { - let mut hashed_password = api::hash_password(&data_search.password); - let uri_acc = api::arg_to_api_route(&api::CheckableChoices::PASS, &hashed_password); - - set_checkpwn_panic!(api::errors::NETWORK_ERROR); - let pass_stat = ureq::get(&uri_acc) - .set("User-Agent", api::CHECKPWN_USER_AGENT) - .set("Add-Padding", "true") - .timeout_connect(10_000) - .call(); - - set_checkpwn_panic!(api::errors::DECODING_ERROR); - let request_status = pass_stat.status(); - let pass_body: String = pass_stat.into_string().unwrap(); - - if api::search_in_range(&pass_body, &hashed_password) { - api::breach_report(request_status, "", true); - } else { - api::breach_report(404, "", true); - } - - // Zero out as this contains a weakly hashed password - hashed_password.zeroize(); -} - -fn main() { +fn main() -> Result<()> { // Set custom usage panic message - set_checkpwn_panic!(api::errors::USAGE_ERROR); + set_checkpwn_panic!(errors::USAGE_ERROR); assert!(env::args().len() >= 2); assert!(env::args().len() < 4); @@ -101,15 +60,13 @@ fn main() { match argvs[1].to_lowercase().as_str() { "acc" => { assert!(argvs.len() == 3); - acc_check(&argvs[2]); + acc_check(&argvs[2])?; } "pass" => { assert!(argvs.len() == 2); - set_checkpwn_panic!(api::errors::PASSWORD_ERROR); - let password = api::PassArg { - password: rpassword::prompt_password_stdout("Password: ").unwrap(), - }; - pass_check(&password); + let hashed_password = Password::new(&rpassword::prompt_password_stdout("Password: ")?)?; + let is_breached = checkpwn_lib::check_password(&hashed_password)?; + breach_report(is_breached, "", true); } "register" => { assert!(argvs.len() == 3); @@ -129,7 +86,7 @@ fn main() { ); let mut overwrite_choice = String::new(); - stdin().read_line(&mut overwrite_choice).unwrap(); + stdin().read_line(&mut overwrite_choice)?; overwrite_choice.to_lowercase(); match overwrite_choice.trim() { @@ -145,11 +102,91 @@ fn main() { _ => panic!(), }; // Zero out the collected arguments, in case the user accidentally inputs sensitive info - for argument in argvs.iter_mut() { - argument.zeroize(); + argvs.iter_mut().zeroize(); + + Ok(()) +} + +/// Make a breach report based on a u16 status code and print result to terminal. +fn breach_report(breached: bool, searchterm: &str, is_password: bool) { + // Do not display password in terminal + let request_key = if is_password { "********" } else { searchterm }; + + if breached { + println!( + "Breach status for {}: {}", + request_key.cyan(), + "BREACH FOUND".red() + ); + } else { + println!( + "Breach status for {}: {}", + request_key.cyan(), + "NO BREACH FOUND".green() + ); } - // Only one request every 1600 milliseconds from any given IP - thread::sleep(time::Duration::from_millis(1600)); +} + +/// Read file into buffer. +fn read_file(path: &str) -> Result, Error> { + set_checkpwn_panic!(errors::READ_FILE_ERROR); + let file_path = File::open(path).unwrap(); + + Ok(BufReader::new(file_path)) +} + +/// Strip all whitespace and all newlines from a given string. +fn strip(string: &str) -> String { + string + .replace("\n", "") + .replace(" ", "") + .replace("\'", "'") + .replace("\t", "") +} + +/// HIBP breach request used for `acc` arguments. +fn acc_breach_request(searchterm: &str, api_key: &str) -> Result<(), checkpwn_lib::CheckpwnError> { + let is_breached = checkpwn_lib::check_account(searchterm, api_key)?; + breach_report(is_breached, searchterm, false); + + Ok(()) +} + +fn acc_check(data_search: &str) -> Result<(), checkpwn_lib::CheckpwnError> { + // NOTE: checkpwn_lib handles any sleeping so we don't exceed the rate limit. + set_checkpwn_panic!(errors::MISSING_API_KEY); + let mut config = config::Config::new(); + config.load_config().unwrap(); + + // Check if user wants to check a local list + if data_search.ends_with(".ls") { + set_checkpwn_panic!(errors::BUFREADER_ERROR); + let file = read_file(data_search).unwrap(); + + for line_iter in file.lines() { + set_checkpwn_panic!(errors::READLINE_ERROR); + let line = strip(&line_iter.unwrap()); + if line.is_empty() { + continue; + } + acc_breach_request(&line, &config.api_key)?; + } + } else { + acc_breach_request(data_search, &config.api_key)?; + } + + Ok(()) +} + +#[test] +fn test_strip_white_new() { + let string_1 = String::from("fkljjsdjlksfdklj dfiwj wefwefwfe"); + let string_2 = String::from("derbrererer\n"); + let string_3 = String::from("dee\nwfweww rb tte rererer\n"); + + assert_eq!(&strip(&string_1), "fkljjsdjlksfdkljdfiwjwefwefwfe"); + assert_eq!(&strip(&string_2), "derbrererer"); + assert_eq!(&strip(&string_3), "deewfwewwrbtterererer"); } #[test]