rs-password-utils 0.2.1

Password utilities, writen in Rust
Documentation
// Copyright 2020 astonbitecode
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::str::{FromStr, from_utf8};

use hyper::{Client, body};
use hyper_tls::HttpsConnector;
use sha1::{Digest, Sha1};

use crate::errors::{self, PasswordUtilsError};

pub mod blocking;

/// Returns Ok(true) if the password is found in the [pwned passwords list](https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/).
/// 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).
pub async fn is_pwned(pass: &str) -> errors::Result<bool> {
    check_pwned(pass)
        .await
        .map(|api_resp| api_resp != PwnedResponse::Ok)
}

/// 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/).
/// 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).
pub async fn check_pwned(pass: &str) -> errors::Result<PwnedResponse> {
    let (hash_head, hash_tail) = calc_sha1_hash(pass);
    let pwned_passwords_string = get_pwned_password_response_string(&hash_head).await?;
    let pwned_resp = parse_pwned_password_api_response(&pwned_passwords_string)
        .into_iter()
        .find(|response_elem| response_elem.hash_tail == hash_tail)
        .map(|found| PwnedResponse::Pwned(found.occurrences));
    Ok(pwned_resp.unwrap_or_else(|| PwnedResponse::Ok))
}

async fn get_pwned_password_response_string(hash_head: &str) -> errors::Result<String> {
    let https = HttpsConnector::new();
    let client = Client::builder().build::<_, hyper::Body>(https);
    let uri_string = format!("https://api.pwnedpasswords.com/range/{}", hash_head);
    let uri = uri_string.parse()?;
    let resp = client.get(uri).await?;
    if resp.status().is_success() {
        let body_bytes = body::to_bytes(resp.into_body()).await?;
        Ok(from_utf8(body_bytes.as_ref())?.to_string())
    } else {
        Err(PasswordUtilsError::CommunicationWithThirdPartyApiError(
            format!("Error while invoking pwnedpasswords api: {}", resp.status()),
        ))
    }
}

fn parse_pwned_password_api_response(resp_str: &str) -> Vec<PwnedPasswordsApiResponse> {
    resp_str
        .lines()
        .map(|line| {
            let splitted: Vec<&str> = line.trim().split(':').collect();
            if splitted.len() == 2 {
                let hash_tail = splitted.first().unwrap().trim().to_lowercase();

                let occurrences_str = *splitted.last().unwrap();
                let occurrences = FromStr::from_str(occurrences_str.trim())?;
                Ok((hash_tail, occurrences))
            } else {
                Err(PasswordUtilsError::ParseError(format!(
                    "Error while parsing pwnedpasswords api response line: {}",
                    line
                )))
            }
        })
        .map(|res: errors::Result<(String, isize)>| {
            let (hash_tail, occurrences) = res.unwrap();
            PwnedPasswordsApiResponse::new(hash_tail, occurrences)
        })
        .collect()
}

fn calc_sha1_hash(pass: &str) -> (String, String) {
    let mut hasher = Sha1::new();
    hasher.update(pass.as_bytes());
    let hash_string = hex::encode(hasher.finalize());

    let (hash_head, hash_tail) = hash_string.split_at(5);
    (
        hash_head.to_string().to_lowercase(),
        hash_tail.to_string().to_lowercase(),
    )
}

#[derive(Debug, PartialEq, Eq, Clone)]
pub enum PwnedResponse {
    Pwned(isize),
    Ok,
}

#[derive(Debug, PartialEq, Eq, Clone)]
struct PwnedPasswordsApiResponse {
    hash_tail: String,
    occurrences: isize,
}

impl PwnedPasswordsApiResponse {
    fn new(hash_tail: String, occurrences: isize) -> PwnedPasswordsApiResponse {
        PwnedPasswordsApiResponse {
            hash_tail,
            occurrences,
        }
    }
}

#[cfg(test)]
mod pwned_unit_tests {
    use tokio::runtime::Runtime;

    use super::*;

    #[test]
    #[ignore]
    fn test_dummy() {
        let f = check_pwned("test");
        let res = Runtime::new().unwrap().block_on(f).unwrap();
        assert!(res != PwnedResponse::Ok);
    }

    #[test]
    fn test_calc_sha1_hash() {
        let (hash_head, hash_tail) = calc_sha1_hash("test");
        assert!(hash_head == "a94a8");
        assert!(hash_tail == "fe5ccb19ba61c4c0873d391e987982fbbd3");
    }

    #[test]
    fn test_parse_pwned_api_response() {
        let resp_str = "00264A0EA456B57A3FC7258B13F3D29B3C0:6
                                00294015E5A8513C73396D18309F3FFF34A:6
                                005656C989B06C7846338A1473281F2A791:4
                                007279035BE63272C81B84BD8B07D25D7E5:2
                                00791B26EB0E2F2C108CC538F771A640A6F:2
                                FE5CCB19BA61C4C0873D391E987982FBBD3:76479
                                010B55A0CE243B3AA85FC808ACBEB97FFA3:1
                                011F0995FD72D213077D18CDCD4D08E00EA:2
                                02A4E38B06CD1DA522048DD15257A584578:2
                                03ECD7302EDC571D9F2D43848F045743D9E:5
                                04D07D84D6474B686D5DD5F5C72A729C43C:2
                                054555A079E6C52256D15651C2A6663DDB9:1
                                05A7177A60AB6D2D0889FD08B6DFA6029FC:1
                                05ED8B82BC639347C1509E9FAC64AA2D4FD:2
                                06A1A3683C2CF4C9E91415A1272857D216D:1
                                083536B05F8D77476B109A31B4FF50FC5E5:2
                                08597FCF86893DE61DFD7CA71D1F14D2391:6
                                08F8BCF21B908CBCF69053F5BF0A9B031AC:4
                                090840B696670B1EA84DFF706905FBDE59E:3
                                09F379E2E256538E9C98A20B6FDB020AEAE:3
                                0A6D8A3C5076E5286F0BCD5113E9AADFFE2:3
                                0AB7D91C1985FC5B703C1CD03FF63DEC533:2
                                0B6C4C0F0C06DB5C3BFDC2492020A0ABC59:1
                                0B918D4FD7045B4704DF52F9915C6B8F8D0:2
                                0C9EF2CD9CF0B7F5D5F9944439214C1D917:2
                                0FDC95106A7317D7050498F80F3AB967899:1
                                10651664D4736F4B78BFC747F25D62A4BC5:2
                                10B1F31D9E0F0248404EA1988CDC2CDF1D3:2
                                10D5F6C93918F47C196629F9FFF0304F4E5:1
                                115F3ECCB8BED60A41FF462427E40519777:1
                                11A2AC0DB7DF425B380482C68A507BEC0EC:1
                                11E22CC801506FC5E7F86E0924947C78935:9
                                11F97FB9ACCBB56C8DE320C7945054044CC:2";
        let parsed = parse_pwned_password_api_response(resp_str);
        assert!(parsed.len() == 33);
        assert!(
            parsed
                .iter()
                .find(|resp| resp.hash_tail == "fe5ccb19ba61c4c0873d391e987982fbbd3")
                .is_some()
        );
    }
}