rs_password_utils/pwned/
mod.rs

1// Copyright 2020 astonbitecode
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14use std::str::{from_utf8, FromStr};
15
16use hyper::{body, Client};
17use hyper_tls::HttpsConnector;
18use sha1::{Digest, Sha1};
19
20use crate::errors::{self, PasswordUtilsError};
21
22pub mod blocking;
23
24/// Returns Ok(true) if the password is found in the [pwned passwords list](https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/).
25/// The call leverages the [k-anonimity API](https://blog.cloudflare.com/validating-leaked-passwords-with-k-anonymity/) and therefore, the password is not used in the API call in any form (not even hashed).
26pub async fn is_pwned(pass: &str) -> errors::Result<bool> {
27    check_pwned(pass).await
28        .map(|api_resp| api_resp != PwnedResponse::Ok)
29}
30
31/// Returns a Result<PwnedResponse> as a result for whether the password is found in the [pwned passwords list](https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/).
32/// The call leverages the [k-anonimity API](https://blog.cloudflare.com/validating-leaked-passwords-with-k-anonymity/) and therefore, the password is not used in the API call in any form (not even hashed).
33pub async fn check_pwned(pass: &str) -> errors::Result<PwnedResponse> {
34    let (hash_head, hash_tail) = calc_sha1_hash(pass);
35    let pwned_passwords_string = get_pwned_password_response_string(&hash_head).await?;
36    let pwned_resp = parse_pwned_password_api_response(&pwned_passwords_string)
37        .into_iter()
38        .find(|response_elem| response_elem.hash_tail == hash_tail)
39        .map(|found| PwnedResponse::Pwned(found.occurrences));
40    Ok(pwned_resp.unwrap_or_else(|| PwnedResponse::Ok))
41}
42
43async fn get_pwned_password_response_string(hash_head: &str) -> errors::Result<String> {
44    let https = HttpsConnector::new();
45    let client = Client::builder().build::<_, hyper::Body>(https);
46    let uri_string = format!("https://api.pwnedpasswords.com/range/{}", hash_head);
47    let uri = uri_string.parse()?;
48    let resp = client.get(uri).await?;
49    if resp.status().is_success() {
50        let body_bytes = body::to_bytes(resp.into_body()).await?;
51        Ok(from_utf8(body_bytes.as_ref())?.to_string())
52    } else {
53        Err(PasswordUtilsError::CommunicationWithThirdPartyApiError(format!("Error while invoking pwnedpasswords api: {}", resp.status())))
54    }
55}
56
57fn parse_pwned_password_api_response(resp_str: &str) -> Vec<PwnedPasswordsApiResponse> {
58    resp_str.lines()
59        .map(|line| {
60            let splitted: Vec<&str> = line.trim().split(':').collect();
61            if splitted.len() == 2 {
62                let hash_tail = splitted.first().unwrap().trim().to_lowercase();
63
64                let occurrences_str = *splitted.last().unwrap();
65                let occurrences = FromStr::from_str(occurrences_str.trim())?;
66                Ok((hash_tail, occurrences))
67            } else {
68                Err(PasswordUtilsError::ParseError(format!("Error while parsing pwnedpasswords api response line: {}", line)))
69            }
70        })
71        .map(|res: errors::Result<(String, isize)>| {
72            let (hash_tail, occurrences) = res.unwrap();
73            PwnedPasswordsApiResponse::new(hash_tail, occurrences)
74        })
75        .collect()
76}
77
78fn calc_sha1_hash(pass: &str) -> (String, String) {
79    let mut hasher = Sha1::new();
80    hasher.update(pass.as_bytes());
81    let hash_string = hex::encode(hasher.finalize());
82
83    let (hash_head, hash_tail) = hash_string.split_at(5);
84    (hash_head.to_string().to_lowercase(), hash_tail.to_string().to_lowercase())
85}
86
87#[derive(Debug, PartialEq, Eq, Clone)]
88pub enum PwnedResponse {
89    Pwned(isize),
90    Ok,
91}
92
93#[derive(Debug, PartialEq, Eq, Clone)]
94struct PwnedPasswordsApiResponse {
95    hash_tail: String,
96    occurrences: isize,
97}
98
99impl PwnedPasswordsApiResponse {
100    fn new(hash_tail: String, occurrences: isize) -> PwnedPasswordsApiResponse {
101        PwnedPasswordsApiResponse {
102            hash_tail,
103            occurrences,
104        }
105    }
106}
107
108#[cfg(test)]
109mod pwned_unit_tests {
110    use tokio::runtime::Runtime;
111
112    use super::*;
113
114    #[test]
115    #[ignore]
116    fn test_dummy() {
117        let f = check_pwned("test");
118        let res = Runtime::new().unwrap().block_on(f).unwrap();
119        assert!(res != PwnedResponse::Ok);
120    }
121    
122    #[test]
123    fn test_calc_sha1_hash() {
124        let (hash_head, hash_tail) = calc_sha1_hash("test");
125        assert!(hash_head == "a94a8");
126        assert!(hash_tail == "fe5ccb19ba61c4c0873d391e987982fbbd3");
127    }
128
129    #[test]
130    fn test_parse_pwned_api_response() {
131        let resp_str = "00264A0EA456B57A3FC7258B13F3D29B3C0:6
132                                00294015E5A8513C73396D18309F3FFF34A:6
133                                005656C989B06C7846338A1473281F2A791:4
134                                007279035BE63272C81B84BD8B07D25D7E5:2
135                                00791B26EB0E2F2C108CC538F771A640A6F:2
136                                FE5CCB19BA61C4C0873D391E987982FBBD3:76479
137                                010B55A0CE243B3AA85FC808ACBEB97FFA3:1
138                                011F0995FD72D213077D18CDCD4D08E00EA:2
139                                02A4E38B06CD1DA522048DD15257A584578:2
140                                03ECD7302EDC571D9F2D43848F045743D9E:5
141                                04D07D84D6474B686D5DD5F5C72A729C43C:2
142                                054555A079E6C52256D15651C2A6663DDB9:1
143                                05A7177A60AB6D2D0889FD08B6DFA6029FC:1
144                                05ED8B82BC639347C1509E9FAC64AA2D4FD:2
145                                06A1A3683C2CF4C9E91415A1272857D216D:1
146                                083536B05F8D77476B109A31B4FF50FC5E5:2
147                                08597FCF86893DE61DFD7CA71D1F14D2391:6
148                                08F8BCF21B908CBCF69053F5BF0A9B031AC:4
149                                090840B696670B1EA84DFF706905FBDE59E:3
150                                09F379E2E256538E9C98A20B6FDB020AEAE:3
151                                0A6D8A3C5076E5286F0BCD5113E9AADFFE2:3
152                                0AB7D91C1985FC5B703C1CD03FF63DEC533:2
153                                0B6C4C0F0C06DB5C3BFDC2492020A0ABC59:1
154                                0B918D4FD7045B4704DF52F9915C6B8F8D0:2
155                                0C9EF2CD9CF0B7F5D5F9944439214C1D917:2
156                                0FDC95106A7317D7050498F80F3AB967899:1
157                                10651664D4736F4B78BFC747F25D62A4BC5:2
158                                10B1F31D9E0F0248404EA1988CDC2CDF1D3:2
159                                10D5F6C93918F47C196629F9FFF0304F4E5:1
160                                115F3ECCB8BED60A41FF462427E40519777:1
161                                11A2AC0DB7DF425B380482C68A507BEC0EC:1
162                                11E22CC801506FC5E7F86E0924947C78935:9
163                                11F97FB9ACCBB56C8DE320C7945054044CC:2";
164        let parsed = parse_pwned_password_api_response(resp_str);
165        assert!(parsed.len() == 33);
166        assert!(parsed.iter().find(|resp| resp.hash_tail == "fe5ccb19ba61c4c0873d391e987982fbbd3").is_some());
167    }
168}