use std::iter;
use serde::Serialize;
use chrono::{DateTime, Utc};
use rand::seq::IndexedRandom;
use zeroize::Zeroizing;
use block_padding::{RawPadding, Iso7816};
use crypto_common::typenum::Unsigned;
use argon2::Argon2;
use chacha20poly1305::{XChaCha20Poly1305, KeyInit, aead::{Aead, Payload, KeySizeUser}};
use crate::Result;
pub use argon2::RECOMMENDED_SALT_LEN;
pub const NONCE_LEN: usize = 24;
pub const PADDING_BLOCK_SIZE: usize = 256;
pub const PASSWORD_CHARSET: &'static [u8] =
b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,;:!?-+*/%=_@#$^&~()[]{}";
pub const PASSWORD_LEN: usize = 40;
#[derive(Clone, Copy, Debug, Serialize)]
struct AdditionalData<'a> {
account: Option<&'a str>,
label: &'a str,
last_modified_at: DateTime<Utc>,
}
#[derive(Clone, Debug)]
pub struct EncryptionOutput {
pub encrypted_secret: Vec<u8>,
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
pub auth_nonce: [u8; NONCE_LEN],
}
#[derive(Clone, Copy, Debug)]
pub struct EncryptionInput<'a> {
pub plaintext_secret: &'a [u8],
pub label: &'a str,
pub account: Option<&'a str>,
pub last_modified_at: DateTime<Utc>,
}
impl EncryptionInput<'_> {
pub fn encrypt_and_authenticate(self, encryption_password: &[u8]) -> Result<EncryptionOutput> {
let unpadded_secret = self.plaintext_secret;
let total_len = (unpadded_secret.len() / PADDING_BLOCK_SIZE + 1) * PADDING_BLOCK_SIZE;
let mut padded_secret = Zeroizing::new(vec![0x00_u8; total_len]);
padded_secret[..unpadded_secret.len()].copy_from_slice(unpadded_secret);
Iso7816::raw_pad(padded_secret.as_mut_slice(), unpadded_secret.len());
let additional_data = AdditionalData {
account: self.account,
label: self.label,
last_modified_at: self.last_modified_at,
};
let additional_data_str = serde_json::to_string(&additional_data)?;
let kdf_salt: [u8; RECOMMENDED_SALT_LEN] = rand::random();
let auth_nonce: [u8; NONCE_LEN] = rand::random();
let hasher = Argon2::default();
let mut key = Zeroizing::new([0_u8; <XChaCha20Poly1305 as KeySizeUser>::KeySize::USIZE]);
hasher.hash_password_into(encryption_password, &kdf_salt, &mut *key)?;
let aead = XChaCha20Poly1305::new_from_slice(key.as_slice())?;
let payload = Payload {
msg: padded_secret.as_slice(),
aad: additional_data_str.as_bytes(),
};
let encrypted_secret = aead.encrypt(<_>::from(&auth_nonce), payload)?;
Ok(EncryptionOutput {
encrypted_secret,
kdf_salt,
auth_nonce,
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct DecryptionInput<'a> {
pub encrypted_secret: &'a [u8],
pub kdf_salt: [u8; RECOMMENDED_SALT_LEN],
pub auth_nonce: [u8; NONCE_LEN],
pub label: &'a str,
pub account: Option<&'a str>,
pub last_modified_at: DateTime<Utc>,
}
impl DecryptionInput<'_> {
pub fn decrypt_and_verify(self, decryption_password: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
let additional_data = AdditionalData {
account: self.account,
label: self.label,
last_modified_at: self.last_modified_at,
};
let additional_data_str = serde_json::to_string(&additional_data)?;
let hasher = Argon2::default();
let mut key = Zeroizing::new([0_u8; <XChaCha20Poly1305 as KeySizeUser>::KeySize::USIZE]);
hasher.hash_password_into(decryption_password, &self.kdf_salt, &mut *key)?;
let aead = XChaCha20Poly1305::new_from_slice(key.as_slice())?;
let payload = Payload {
msg: self.encrypted_secret,
aad: additional_data_str.as_bytes(),
};
let plaintext_secret = aead.decrypt(<_>::from(&self.auth_nonce), payload)?;
let mut plaintext_secret = Zeroizing::new(plaintext_secret);
let unpadded_len = Iso7816::raw_unpad(plaintext_secret.as_slice())?.len();
plaintext_secret.truncate(unpadded_len);
Ok(plaintext_secret)
}
}
pub fn generate_password() -> Zeroizing<String> {
let mut rng = rand::rng();
iter::from_fn(|| PASSWORD_CHARSET.choose(&mut rng))
.copied()
.map(char::from)
.take(PASSWORD_LEN)
.collect::<String>()
.into()
}
#[cfg(test)]
mod tests {
use chrono::{Utc, Days};
use rand::{Rng, RngCore, distributions::{Standard, DistString}};
use zxcvbn::{zxcvbn, Score};
use crate::error::{Error, Result};
use super::{EncryptionInput, DecryptionInput, PADDING_BLOCK_SIZE, PASSWORD_LEN};
#[test]
fn correct_encryption_and_decryption_succeeds() -> Result<()> {
let timestamp = Utc::now();
let mut rng = rand::thread_rng();
let p0 = vec![]; let mut p1 = vec![0_u8; PADDING_BLOCK_SIZE - 1];
let mut p2 = vec![0_u8; PADDING_BLOCK_SIZE];
let mut p3 = vec![0_u8; PADDING_BLOCK_SIZE + 1];
rng.fill_bytes(&mut p1);
rng.fill_bytes(&mut p2);
rng.fill_bytes(&mut p3);
for payload in [p0, p1, p2, p3] {
let password_len: usize = rng.gen_range(8..64);
let password = Standard.sample_string(&mut rng, password_len);
let encryption_input = EncryptionInput {
plaintext_secret: payload.as_slice(),
label: "the precise label does not matter",
account: Some("my uninteresting account name"),
last_modified_at: timestamp,
};
let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: encryption_input.account,
last_modified_at: timestamp,
};
let decrypted_secret = decryption_input.decrypt_and_verify(password.as_bytes())?;
assert_eq!(decrypted_secret.as_slice(), payload.as_slice());
}
Ok(())
}
#[test]
fn incorrect_password_fails_decryption() -> Result<()> {
let timestamp = Utc::now();
let mut rng = rand::thread_rng();
let p0 = vec![]; let mut p1 = vec![0_u8; PADDING_BLOCK_SIZE - 1];
let mut p2 = vec![0_u8; PADDING_BLOCK_SIZE];
let mut p3 = vec![0_u8; PADDING_BLOCK_SIZE + 1];
rng.fill_bytes(&mut p1);
rng.fill_bytes(&mut p2);
rng.fill_bytes(&mut p3);
for payload in [p0, p1, p2, p3] {
let password_len: usize = rng.gen_range(8..64);
let password = Standard.sample_string(&mut rng, password_len);
let encryption_input = EncryptionInput {
plaintext_secret: payload.as_slice(),
label: "the precise label does not matter",
account: Some("my uninteresting account name"),
last_modified_at: timestamp,
};
let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: encryption_input.account,
last_modified_at: timestamp,
};
let wrong_password = b"this is NOT the right password!";
let result = decryption_input.decrypt_and_verify(wrong_password);
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {:#?}",
result,
);
}
Ok(())
}
#[test]
fn altered_additional_data_fails_verification() -> Result<()> {
let timestamp = Utc::now();
let mut rng = rand::thread_rng();
let p0 = vec![]; let mut p1 = vec![0_u8; PADDING_BLOCK_SIZE - 1];
let mut p2 = vec![0_u8; PADDING_BLOCK_SIZE];
let mut p3 = vec![0_u8; PADDING_BLOCK_SIZE + 1];
rng.fill_bytes(&mut p1);
rng.fill_bytes(&mut p2);
rng.fill_bytes(&mut p3);
for payload in [p0, p1, p2, p3] {
let password_len: usize = rng.gen_range(8..64);
let password = Standard.sample_string(&mut rng, password_len);
let encryption_input = EncryptionInput {
plaintext_secret: payload.as_slice(),
label: "the precise label does not matter",
account: Some("my uninteresting account name"),
last_modified_at: timestamp,
};
let output = encryption_input.encrypt_and_authenticate(password.as_bytes())?;
{
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: None,
last_modified_at: timestamp,
};
let result = decryption_input.decrypt_and_verify(password.as_bytes());
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {:#?}",
result,
);
}
{
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: &encryption_input.label[1..],
account: encryption_input.account,
last_modified_at: timestamp,
};
let result = decryption_input.decrypt_and_verify(password.as_bytes());
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {:#?}",
result,
);
}
{
let decryption_input = DecryptionInput {
encrypted_secret: output.encrypted_secret.as_slice(),
kdf_salt: output.kdf_salt,
auth_nonce: output.auth_nonce,
label: encryption_input.label,
account: encryption_input.account,
last_modified_at: timestamp.checked_sub_days(Days::new(1)).unwrap(),
};
let result = decryption_input.decrypt_and_verify(password.as_bytes());
assert!(
matches!(
result,
Err(Error::XChaCha20Poly1305(chacha20poly1305::Error))
),
"unexpected result: {:#?}",
result,
);
}
}
Ok(())
}
#[test]
fn generated_password_is_strong() {
for _ in 0..1024 {
let password = super::generate_password();
assert_eq!(password.len(), PASSWORD_LEN);
let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit());
let has_punct = password.chars().any(|c| c.is_ascii_punctuation());
let char_class_count =
u32::from(has_lower) + u32::from(has_upper) + u32::from(has_digit) + u32::from(has_punct);
assert!(char_class_count >= 3);
assert!(
password.chars().all(|c| {
!c.is_ascii_control() && (
c.is_ascii_lowercase() ||
c.is_ascii_uppercase() ||
c.is_ascii_digit() ||
c.is_ascii_punctuation()
)
})
);
let entropy = zxcvbn(password.as_str(), &[]);
assert_eq!(entropy.score(), Score::Four);
assert!(entropy.feedback().is_none());
}
}
}