Made cryptography and entity modules

Coupling was rising so it just makes sense
This commit is contained in:
2024-05-05 18:38:21 +03:00
parent 9af37f78b2
commit 5871943c01
23 changed files with 151 additions and 175 deletions

163
src/cryptography/account.rs Normal file
View File

@@ -0,0 +1,163 @@
use crate::entity::account::Account;
use chacha20poly1305::{AeadCore, AeadInPlace, ChaCha20Poly1305, KeyInit};
use pbkdf2::pbkdf2_hmac_array;
use rand::{rngs::OsRng, RngCore};
use sha2::Sha256;
pub struct Cipher {
chacha: ChaCha20Poly1305,
}
impl Cipher {
/// Creates a new cipher from a master password and the salt
#[inline]
#[must_use]
pub fn new(password: &[u8], salt: &[u8]) -> Self {
let key = pbkdf2_hmac_array::<Sha256, 32>(password, salt, 480_000);
Self {
chacha: ChaCha20Poly1305::new(&key.into()),
}
}
/// Encrypts the value with the current cipher. The 12 byte nonce is appended to the result
#[inline]
#[allow(clippy::missing_panics_doc)]
pub fn encrypt(&self, value: &mut Vec<u8>) {
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
self.chacha.encrypt_in_place(&nonce, b"", value).unwrap();
value.extend_from_slice(&nonce);
}
/// Decrypts the value with the current cipher. The 12 byte nonce is expected to be at the end of the value
///
/// # Errors
///
/// Returns an error if the tag doesn't match the ciphertext
#[inline]
#[allow(clippy::missing_panics_doc)]
pub fn decrypt(&self, value: &mut Vec<u8>) -> super::Result<()> {
if value.len() <= 12 {
return Err(super::Error::InvalidInputLength);
}
let nonce: [u8; 12] = value[value.len() - 12..].try_into().unwrap();
value.truncate(value.len() - 12);
self.chacha
.decrypt_in_place(nonce.as_slice().into(), b"", value)
.map_err(Into::into)
}
}
#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct Decrypted {
pub name: String,
pub login: String,
pub password: String,
}
impl Decrypted {
/// Constructs `DecryptedAccount` by decrypting the provided account
///
/// # Errors
///
/// Returns an error if the tag doesn't match the ciphertext or if the decrypted data isn't valid UTF-8
#[inline]
pub fn from_account(mut account: Account, master_pass: &str) -> super::Result<Self> {
let cipher = Cipher::new(master_pass.as_bytes(), &account.salt);
cipher.decrypt(&mut account.enc_login)?;
cipher.decrypt(&mut account.enc_password)?;
Ok(Self {
name: account.name,
login: String::from_utf8(account.enc_login)?,
password: String::from_utf8(account.enc_password)?,
})
}
/// Constructs `ActiveModel` with eath field Set by encrypting `self`
#[inline]
#[must_use]
pub fn into_account(self, user_id: u64, master_pass: &str) -> Account {
let mut enc_login = self.login.into_bytes();
let mut enc_password = self.password.into_bytes();
let mut salt = vec![0; 64];
OsRng.fill_bytes(&mut salt);
let cipher = Cipher::new(master_pass.as_bytes(), &salt);
cipher.encrypt(&mut enc_login);
cipher.encrypt(&mut enc_password);
Account {
user_id,
name: self.name,
salt,
enc_login,
enc_password,
}
}
/// Returns true if the account's fields are valid
#[inline]
#[must_use]
pub fn validate(&self) -> bool {
[
self.name.as_str(),
self.login.as_str(),
self.password.as_str(),
]
.into_iter()
.all(super::validate_field)
}
}
#[cfg(test)]
mod tests {
use super::*;
use once_cell::sync::Lazy;
const TESTING_MASTER_PASSWORD: &str = "VeryStr^n#M@$terP@$$!word";
static CIPHER: Lazy<Cipher> = Lazy::new(|| {
let mut salt = [0; 64];
OsRng.fill_bytes(&mut salt);
Cipher::new(TESTING_MASTER_PASSWORD.as_bytes(), &salt)
});
#[test]
fn cipher_test() -> crate::cryptography::Result<()> {
const ORIGINAL: &[u8] = b"Data to protect";
let mut data = ORIGINAL.to_owned();
CIPHER.encrypt(&mut data);
CIPHER.decrypt(&mut data)?;
assert_eq!(ORIGINAL, data);
Ok(())
}
#[test]
fn account_encryption() -> crate::cryptography::Result<()> {
let original = Decrypted {
name: "Account Name".to_owned(),
login: "StrongLogin@mail.com".to_owned(),
password: "StrongP@$$word!".to_owned(),
};
let account = original.clone().into_account(1, TESTING_MASTER_PASSWORD);
let decrypted = Decrypted::from_account(account, TESTING_MASTER_PASSWORD)?;
assert_eq!(original, decrypted);
Ok(())
}
#[test]
fn decrypt_invalid_input_length() {
let mut bytes = vec![0];
assert!(matches!(
CIPHER.decrypt(&mut bytes),
Err(crate::cryptography::Error::InvalidInputLength)
));
}
}

View File

@@ -0,0 +1,93 @@
use crate::entity::master_pass::MasterPass;
use once_cell::sync::Lazy;
use rand::{rngs::OsRng, RngCore};
use scrypt::{scrypt, Params};
use subtle::ConstantTimeEq;
pub const HASH_LENGTH: usize = 64;
pub const SALT_LENGTH: usize = 64;
static PARAMS: Lazy<Params> = Lazy::new(|| Params::new(14, 8, 1, HASH_LENGTH).unwrap());
/// Hashes the bytes with Scrypt with the given salt
#[inline]
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn hash_scrypt(bytes: &[u8], salt: &[u8]) -> [u8; HASH_LENGTH] {
let mut hash = [0; HASH_LENGTH];
scrypt(bytes, salt, &PARAMS, &mut hash).unwrap();
hash
}
/// Verifieble scrypt hashed bytes
pub struct HashedBytes<T, U>
where
T: AsRef<[u8]>,
U: AsRef<[u8]>,
{
pub hash: T,
pub salt: U,
}
impl HashedBytes<[u8; HASH_LENGTH], [u8; SALT_LENGTH]> {
#[inline]
#[must_use]
pub fn new(bytes: &[u8]) -> Self {
let mut salt = [0; 64];
OsRng.fill_bytes(&mut salt);
Self {
hash: hash_scrypt(bytes, &salt),
salt,
}
}
}
impl<T, U> HashedBytes<T, U>
where
T: AsRef<[u8]>,
U: AsRef<[u8]>,
{
#[inline]
#[must_use]
pub fn verify(&self, bytes: &[u8]) -> bool {
let hash = hash_scrypt(bytes, self.salt.as_ref());
hash.ct_eq(self.hash.as_ref()).into()
}
}
impl<'a> From<&'a MasterPass> for HashedBytes<&'a [u8], &'a [u8]> {
#[inline]
fn from(value: &'a MasterPass) -> Self {
HashedBytes {
hash: &value.password_hash,
salt: &value.salt,
}
}
}
impl From<MasterPass> for HashedBytes<Vec<u8>, Vec<u8>> {
fn from(value: MasterPass) -> Self {
Self {
hash: value.password_hash,
salt: value.salt,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn params_valid() {
#[allow(clippy::no_effect_underscore_binding)]
let _params: &Params = &PARAMS; // Initializes the PARAMS, which might panic if the passed in values are invalid
}
#[test]
fn hashing_test() {
const ORIGINAL: &[u8] = b"Important data";
assert!(HashedBytes::new(ORIGINAL).verify(ORIGINAL));
}
}

31
src/cryptography/mod.rs Normal file
View File

@@ -0,0 +1,31 @@
//! Functions to encrypt the database models
pub mod account;
pub mod hashing;
pub mod passwords;
/// Returns true if the field is valid
#[inline]
#[must_use]
pub fn validate_field(field: &str) -> bool {
if field.len() > 255 {
return false;
}
field
.chars()
.all(|char| !['`', '\\', '\n', '\t'].contains(&char))
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Invalid input length")]
InvalidInputLength,
#[error(transparent)]
ChaCha(#[from] chacha20poly1305::Error),
#[error(transparent)]
InvalidUTF8(#[from] std::string::FromUtf8Error),
}
type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,112 @@
use arrayvec::ArrayString;
use rand::{seq::SliceRandom, thread_rng, CryptoRng, Rng};
use std::array;
const CHARS: &[u8] = br##"!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~"##;
bitflags::bitflags! {
struct PasswordFlags: u8 {
const LOWERCASE = 0b0001;
const UPPERCASE = 0b0010;
const NUMBER = 0b0100;
const SPECIAL_CHARACTER = 0b1000;
}
#[derive(PartialEq, Eq, Clone, Copy)]
pub struct PasswordValidity: u8 {
const NO_LOWERCASE = 0b00001;
const NO_UPPERCASE = 0b00010;
const NO_NUMBER = 0b00100;
const NO_SPECIAL_CHARACTER = 0b01000;
const TOO_SHORT = 0b10000;
}
}
/// Returns true if the generated master password is valid.
/// It checks that it has at least one lowercase, one uppercase, one number and one punctuation char
#[inline]
#[must_use]
fn check_generated_password<const LENGTH: usize>(password: &[u8; LENGTH]) -> bool {
let mut flags = PasswordFlags::empty();
for &byte in password {
match byte {
b'a'..=b'z' => flags |= PasswordFlags::LOWERCASE,
b'A'..=b'Z' => flags |= PasswordFlags::UPPERCASE,
b'0'..=b'9' => flags |= PasswordFlags::NUMBER,
b'!'..=b'/' | b':'..=b'@' | b'['..=b'`' | b'{'..=b'~' => {
flags |= PasswordFlags::SPECIAL_CHARACTER;
}
_ => (),
}
if flags.is_all() {
return true;
}
}
false
}
/// Continuously generates the password until it passes the checks
#[inline]
#[must_use]
fn generate_password<R, const LENGTH: usize>(rng: &mut R) -> ArrayString<LENGTH>
where
R: Rng + CryptoRng,
{
loop {
let password = array::from_fn(|_| *CHARS.choose(rng).unwrap());
if check_generated_password(&password) {
return ArrayString::from_byte_string(&password).unwrap();
}
}
}
#[inline]
#[must_use]
#[allow(clippy::module_name_repetitions)]
pub fn generate_passwords<const AMOUNT: usize, const LENGTH: usize>(
) -> [ArrayString<LENGTH>; AMOUNT] {
let mut rng = thread_rng();
array::from_fn(|_| generate_password(&mut rng))
}
#[inline]
#[must_use]
pub fn check_master_pass(password: &str) -> PasswordValidity {
let mut count = 0;
let mut chars = password.chars();
let mut flags = PasswordValidity::all();
for char in &mut chars {
count += 1;
if char.is_lowercase() {
flags.remove(PasswordValidity::NO_LOWERCASE);
} else if char.is_uppercase() {
flags.remove(PasswordValidity::NO_UPPERCASE);
} else if char.is_ascii_digit() {
flags.remove(PasswordValidity::NO_NUMBER);
} else if char.is_ascii_punctuation() {
flags.remove(PasswordValidity::NO_SPECIAL_CHARACTER);
}
if flags == PasswordValidity::TOO_SHORT {
count += chars.count();
break;
}
}
if count >= 8 {
flags.remove(PasswordValidity::TOO_SHORT);
}
flags
}
#[cfg(test)]
mod tests {
use super::CHARS;
#[test]
fn chars_must_be_ascii() {
assert!(CHARS.is_ascii());
}
}