checkpwn_lib/
lib.rs

1// MIT License
2
3// Copyright (c) 2020-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//! # Usage:
24//! ```rust
25//! use checkpwn_lib::{Password, check_password, check_account, CheckpwnError};
26//!
27//! let password = Password::new("qwerty")?;
28//! check_password(&password);
29//!
30//!
31//! check_account("your_account", "your_api_key");
32//!
33//! # Ok::<(), CheckpwnError>(())
34//! ```
35#![forbid(unsafe_code)]
36#![deny(clippy::mem_forget)]
37#![warn(
38    missing_docs,
39    rust_2018_idioms,
40    trivial_casts,
41    unused_qualifications,
42    overflowing_literals
43)]
44#![doc(html_root_url = "https://docs.rs/checkpwn_lib/0.2.1")]
45
46mod api;
47mod errors;
48
49pub use errors::CheckpwnError;
50use std::{thread, time};
51
52/// The checkpwn UserAgent sent to HIBP.
53pub const CHECKPWN_USER_AGENT: &str = "checkpwn - cargo utility tool for hibp";
54
55/// Check account, on both account and paste databases, using a given API key.
56/// Before sending a request, the thread sleeps for 1600 millis. HIBP limits at 1500.
57/// Returns Ok(bool), `bool` indicating whether the account is breached or not.
58/// Err() is returned if an error occurred during the check.
59pub fn check_account(account: &str, api_key: &str) -> Result<bool, CheckpwnError> {
60    if account.is_empty() || api_key.is_empty() {
61        return Err(CheckpwnError::EmptyInput);
62    }
63
64    // HIBP limits requests to one per 1500 milliseconds. We're allowing for 1600 below as a buffer.
65    thread::sleep(time::Duration::from_millis(1600));
66
67    let acc_db_api_route = api::arg_to_api_route(&api::CheckableChoices::Acc, account);
68    let paste_db_api_route = api::arg_to_api_route(&api::CheckableChoices::Paste, account);
69
70    let agent: ureq::Agent = ureq::AgentBuilder::new()
71        .timeout_connect(time::Duration::from_secs(10))
72        .build();
73
74    let acc_stat = agent
75        .get(&acc_db_api_route)
76        .set("User-Agent", CHECKPWN_USER_AGENT)
77        .set("hibp-api-key", api_key)
78        .call();
79
80    let paste_stat = agent
81        .get(&paste_db_api_route)
82        .set("User-Agent", CHECKPWN_USER_AGENT)
83        .set("hibp-api-key", api_key)
84        .call();
85
86    api::evaluate_acc_breach_statuscodes(
87        api::response_to_status_codes(&acc_stat)?,
88        api::response_to_status_codes(&paste_stat)?,
89    )
90}
91
92/// `Password` is a wrapper type for a password that is checked at HIBP.
93/// It contains an opaque `Debug` impl, to avoid the SHA1 hash of the password to leak.
94pub struct Password {
95    hash: String,
96}
97
98impl Password {
99    /// Hash and make a new `Password`. Returns `Err` if `password` is empty.
100    pub fn new(password: &str) -> Result<Self, CheckpwnError> {
101        if password.is_empty() {
102            return Err(CheckpwnError::EmptyInput);
103        }
104
105        Ok(Self {
106            hash: api::hash_password(password),
107        })
108    }
109}
110
111impl std::fmt::Debug for Password {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "Password {{ hash: ***OMITTED*** }}")
114    }
115}
116
117impl Drop for Password {
118    fn drop(&mut self) {
119        use zeroize::Zeroize;
120        self.hash.zeroize()
121    }
122}
123
124/// Check password.
125/// Returns Ok(bool), `bool` indicating whether the password is breached or not.
126/// Err() is returned if an error occurred during the check.
127pub fn check_password(password: &Password) -> Result<bool, CheckpwnError> {
128    let pass_db_api_route = api::arg_to_api_route(&api::CheckableChoices::Pass, &password.hash);
129
130    let agent: ureq::Agent = ureq::AgentBuilder::new()
131        .timeout_connect(time::Duration::from_secs(10))
132        .build();
133
134    let pass_stat = agent
135        .get(&pass_db_api_route)
136        .set("User-Agent", CHECKPWN_USER_AGENT)
137        .set("Add-Padding", "true")
138        .call();
139
140    let request_status = api::response_to_status_codes(&pass_stat)?;
141    // An error here that would abort the check will be returned already from the above
142    // so unwrap() here should be fine
143    let pass_body: String = pass_stat.unwrap().into_string().unwrap();
144
145    if api::search_in_range(&pass_body, &password.hash) {
146        if request_status == 200 {
147            Ok(true)
148        } else if request_status == 404 {
149            Ok(false)
150        } else {
151            Err(CheckpwnError::StatusCode)
152        }
153    } else {
154        Ok(false)
155    }
156}
157#[test]
158fn test_empty_input_errors() {
159    assert!(check_account("", "Test").is_err());
160    assert!(check_account("Test", "").is_err());
161    assert!(Password::new("").is_err());
162}
163
164#[cfg(test)]
165#[cfg(feature = "ci_test")]
166fn get_env_api_key_from_ci() -> String {
167    // If in CI, the key is in env.
168    // TODO: Local tests are not handled and simply fail.
169    std::env::var("API_KEY").unwrap()
170}
171
172#[cfg(feature = "ci_test")]
173#[test]
174fn test_check_account() {
175    use rand::prelude::*;
176
177    let mut rng = thread_rng();
178    let mut email_user: [char; 8] = ['a'; 8];
179    let mut email_domain: [char; 8] = ['a'; 8];
180    rng.fill(&mut email_user);
181    rng.fill(&mut email_domain);
182
183    let rnd_email = format!(
184        "{:?}@{:?}.com",
185        email_user.iter().collect::<String>(),
186        email_domain.iter().collect::<String>()
187    );
188
189    let api_key = get_env_api_key_from_ci();
190
191    assert!(check_account("test@example.com", &api_key).unwrap());
192    assert!(!check_account(&rnd_email, &api_key).unwrap());
193}
194
195#[test]
196fn test_check_password() {
197    let breached_password = Password::new("qwerty").unwrap();
198    let non_breached_password = Password::new("dHRUKbDaKgIobOtX").unwrap();
199
200    assert!(check_password(&breached_password).unwrap());
201    assert!(!check_password(&non_breached_password).unwrap());
202}