diff --git a/crates/hash/Cargo.toml b/crates/hash/Cargo.toml new file mode 100644 index 0000000..55b6ef1 --- /dev/null +++ b/crates/hash/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "hash" +version = "0.0.0" +edition = "2021" + +[dependencies] +argon2 = { version = "0.4.1" } +rand_core = { version = "0.6.4", features = ["std"] } +thiserror = { workspace = true } diff --git a/crates/hash/src/error.rs b/crates/hash/src/error.rs new file mode 100644 index 0000000..9345594 --- /dev/null +++ b/crates/hash/src/error.rs @@ -0,0 +1,11 @@ +// error +#[derive(thiserror::Error)] +// the rest +#[derive(Debug)] +pub enum Error { + #[error("Failed to hash value")] + Hash, + + #[error("Failed to parse hashed value")] + Parse, +} diff --git a/crates/hash/src/hash.rs b/crates/hash/src/hash.rs new file mode 100644 index 0000000..bdd6cf4 --- /dev/null +++ b/crates/hash/src/hash.rs @@ -0,0 +1,82 @@ +use crate::error::Error; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +pub(crate) fn hash(value: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + // Argon2 with default params (Argon2id v19) + let argon2 = Argon2::default(); + + // Hash value to PHC string ($argon2id$v=19$...) + argon2 + .hash_password(value.as_bytes(), &salt) + .map(|hashed| hashed.to_string()) + .map_err(|_| Error::Hash) +} + +fn verify(value: &str, hashed_value: &str) -> Result { + // Verify value against PHC string. + // + // NOTE: hash params from `parsed_hash` are used instead of what is configured in the + // `Argon2` instance. + let parsed_hash = PasswordHash::new(hashed_value).map_err(|_| Error::Parse)?; + + let hashes_match = Argon2::default() + .verify_password(value.as_bytes(), &parsed_hash) + .is_ok(); + + Ok(hashes_match) +} + +#[derive(Debug)] +pub struct Hash { + plain: Option, + hash: String, +} + +impl Hash { + pub fn from_plain(plain: impl Into) -> Result { + let plain = plain.into(); + let hash = hash(&plain)?; + Ok(Self { + plain: Some(plain), + hash, + }) + } + pub fn from_hash(hash: impl Into) -> Self { + Self { + plain: None, + hash: hash.into(), + } + } + pub fn plain(&self) -> Option<&str> { + self.plain.as_deref() + } + pub fn hash(&self) -> &str { + self.hash.as_ref() + } + pub fn compare(&self, plain: &str) -> Result { + verify(plain, self.hash()) + } +} + +#[cfg(test)] +mod tests { + use crate::hash::Hash; + + #[test] + fn can_be_compared_with_hashed() { + let value = Hash::from_plain("Testing the hashing").unwrap(); + + // Create from hashed value + let value_from_hash = Hash::from_hash(value.hash()); + + // Make sure when hashing the plain text version, it can match the hashed one + let does_value_match = value_from_hash.compare(value.plain().unwrap()).unwrap(); + + assert!(does_value_match); + } +} diff --git a/crates/hash/src/lib.rs b/crates/hash/src/lib.rs new file mode 100644 index 0000000..769d4c5 --- /dev/null +++ b/crates/hash/src/lib.rs @@ -0,0 +1,6 @@ +mod error; +mod hash; +mod password; + +pub use error::Error; +pub use password::Password; diff --git a/crates/hash/src/password.rs b/crates/hash/src/password.rs new file mode 100644 index 0000000..398272f --- /dev/null +++ b/crates/hash/src/password.rs @@ -0,0 +1,20 @@ +use crate::error::Error; +use crate::hash::{hash, Hash}; + +#[derive(Debug)] +pub struct Password(Hash); + +impl Password { + pub fn new(plain: &str) -> Result { + Ok(Self(Hash::from_hash(hash(plain)?))) + } + pub fn from_hash(hash: impl Into) -> Self { + Self(Hash::from_hash(hash)) + } + pub fn hash(&self) -> &str { + self.0.hash() + } + pub fn compare(&self, plain: &str) -> Result { + self.0.compare(plain).map_err(Error::from) + } +}