From b30b5d0add87050f59dc1476c123485885453995 Mon Sep 17 00:00:00 2001 From: Sam Hadow Date: Thu, 1 Oct 2026 18:28:40 +0200 Subject: [PATCH] PBKDF: implement argon2id --- README.md | 2 +- src/file_crypt/file.rs | 21 ++- src/file_crypt/format.rs | 48 ++++-- src/file_crypt/mod.rs | 2 + src/file_crypt/stream.rs | 70 ++++++--- src/file_crypt/tests.rs | 254 ++++++++++++++++++++++--------- src/kdf/argon2.rs | 317 +++++++++++++++++++++++++++++++++++++++ src/kdf/compress.rs | 134 +++++++++++++++++ src/kdf/hash.rs | 164 ++++++++++++++++++++ src/kdf/kdf.rs | 101 +++++++++++-- src/kdf/mod.rs | 8 +- src/kdf/test.rs | 309 ++++++++++++++++++++++++++++++++++++-- src/main.rs | 9 +- 13 files changed, 1291 insertions(+), 148 deletions(-) create mode 100644 src/kdf/argon2.rs create mode 100644 src/kdf/compress.rs create mode 100644 src/kdf/hash.rs diff --git a/README.md b/README.md index 9d0b326..1728871 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # filecrypt-rs Cli tool in Rust to encrypt and decrypt files with an Ascon-Keccak AEAD. -The PBKDF is not yet implemented and is just SHA512(password) for now. +The PBKDF used is Argon2id. ## Commands diff --git a/src/file_crypt/file.rs b/src/file_crypt/file.rs index 6d0b355..8cb9949 100644 --- a/src/file_crypt/file.rs +++ b/src/file_crypt/file.rs @@ -2,21 +2,21 @@ use std::fs::File; use std::io::BufWriter; use std::path::Path; +use super::BUF_SIZE; use super::atomic::AtomicOutput; use super::error::FileCryptError; use super::stream::{decrypt_stream, encrypt_stream}; -use super::{BUF_SIZE, check_key}; /// Encrypt the file at `input` into `output`. /// +/// A random Argon2id salt, IV, and nonce are generated and stored in the encrypted file header. +/// /// `output` is only replaced if encryption succeeds. pub fn encrypt_file( input: impl AsRef, output: impl AsRef, - key: &[u8], + password: &str, ) -> Result<(), FileCryptError> { - check_key(key)?; - let input_file = File::open(input.as_ref())?; let plaintext_len = input_file.metadata()?.len(); @@ -25,7 +25,7 @@ pub fn encrypt_file( let mut writer = BufWriter::with_capacity(BUF_SIZE, out_file); - encrypt_stream(input_file, &mut writer, key, plaintext_len)?; + encrypt_stream(input_file, &mut writer, password, plaintext_len)?; let out_file = writer .into_inner() @@ -41,22 +41,21 @@ pub fn encrypt_file( /// Decrypt the file at `input` into `output`. /// -/// The destination is left untouched if authentication or any other part -/// of decryption fails. +/// The Argon2id salt is read from the encrypted file header. +/// +/// The destination is left untouched if authentication or any other part of decryption fails. pub fn decrypt_file( input: impl AsRef, output: impl AsRef, - key: &[u8], + password: &str, ) -> Result<(), FileCryptError> { - check_key(key)?; - let input_file = File::open(input.as_ref())?; let (guard, out_file) = AtomicOutput::create(output.as_ref())?; let mut writer = BufWriter::with_capacity(BUF_SIZE, out_file); - decrypt_stream(input_file, &mut writer, key)?; + decrypt_stream(input_file, &mut writer, password)?; let out_file = writer .into_inner() diff --git a/src/file_crypt/format.rs b/src/file_crypt/format.rs index 3552daf..2919d3a 100644 --- a/src/file_crypt/format.rs +++ b/src/file_crypt/format.rs @@ -1,27 +1,53 @@ +use crate::kdf::{DERIVED_KEY_LEN, SALT_LEN}; + use super::error::FileCryptError; pub const IV_LEN: usize = 16; pub const NONCE_LEN: usize = 16; -/// The tag is as long as the key. pub const MIN_KEY_LEN: usize = 16; -/// Must match `CAPACITY_SIZE` in `keccak_aead.rs`. -pub const MAX_KEY_LEN: usize = 24; +pub const MAX_KEY_LEN: usize = 64; -pub const HEADER_LEN: usize = 48; +pub const HEADER_LEN: usize = 64; const MAGIC: [u8; 4] = *b"KAEF"; -const VERSION: u8 = 1; +const VERSION: u8 = 2; pub(super) struct Header { pub(super) key_len: usize, pub(super) iv: [u8; IV_LEN], pub(super) nonce: [u8; NONCE_LEN], + pub(super) salt: [u8; SALT_LEN], pub(super) plaintext_len: u64, } impl Header { + /// Create a new header with a fresh random IV, nonce, and KDF salt. + pub(super) fn new(plaintext_len: u64) -> Result { + let mut random = [0u8; IV_LEN + NONCE_LEN + SALT_LEN]; + + getrandom::fill(&mut random).map_err(FileCryptError::Random)?; + + let iv = random[0..IV_LEN].try_into().expect("IV length is constant"); + + let nonce = random[IV_LEN..IV_LEN + NONCE_LEN] + .try_into() + .expect("nonce length is constant"); + + let salt = random[IV_LEN + NONCE_LEN..] + .try_into() + .expect("salt length is constant"); + + Ok(Self { + key_len: DERIVED_KEY_LEN, + iv, + nonce, + salt, + plaintext_len, + }) + } + pub(super) fn to_bytes(&self) -> [u8; HEADER_LEN] { let mut out = [0u8; HEADER_LEN]; @@ -33,7 +59,8 @@ impl Header { out[8..24].copy_from_slice(&self.iv); out[24..40].copy_from_slice(&self.nonce); - out[40..48].copy_from_slice(&self.plaintext_len.to_le_bytes()); + out[40..56].copy_from_slice(&self.salt); + out[56..64].copy_from_slice(&self.plaintext_len.to_le_bytes()); out } @@ -55,9 +82,12 @@ impl Header { Ok(Self { key_len, - iv: bytes[8..24].try_into().unwrap(), - nonce: bytes[24..40].try_into().unwrap(), - plaintext_len: u64::from_le_bytes(bytes[40..48].try_into().unwrap()), + iv: bytes[8..24].try_into().expect("IV length is constant"), + nonce: bytes[24..40].try_into().expect("nonce length is constant"), + salt: bytes[40..56].try_into().expect("salt length is constant"), + plaintext_len: u64::from_le_bytes( + bytes[56..64].try_into().expect("length is constant"), + ), }) } } diff --git a/src/file_crypt/mod.rs b/src/file_crypt/mod.rs index 711042b..3b9b379 100644 --- a/src/file_crypt/mod.rs +++ b/src/file_crypt/mod.rs @@ -7,6 +7,8 @@ mod stream; #[cfg(test)] mod tests; +pub use crate::kdf::SALT_LEN; + pub use error::FileCryptError; pub use file::{decrypt_file, encrypt_file}; pub use format::{HEADER_LEN, MAX_KEY_LEN, MIN_KEY_LEN}; diff --git a/src/file_crypt/stream.rs b/src/file_crypt/stream.rs index 20ba369..04f2b2d 100644 --- a/src/file_crypt/stream.rs +++ b/src/file_crypt/stream.rs @@ -2,12 +2,42 @@ use std::io::{self, Read, Write}; use subtle::ConstantTimeEq; +#[cfg(test)] +use crate::kdf::{Argon2Params, derive_file_key_with_params}; + +#[cfg(not(test))] +use crate::kdf::derive_file_key; + use crate::keccak_aead::KeccakAead; use super::error::FileCryptError; use super::format::Header; use super::{BUF_SIZE, HEADER_LEN, check_key}; +#[cfg(test)] +const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, +}; + +#[cfg(test)] +fn derive_stream_key( + password: &str, + salt: &[u8; crate::kdf::SALT_LEN], +) -> [u8; crate::kdf::DERIVED_KEY_LEN] { + derive_file_key_with_params(password, salt, TEST_ARGON2_PARAMS) + .expect("test Argon2id parameters are valid") +} + +#[cfg(not(test))] +fn derive_stream_key( + password: &str, + salt: &[u8; crate::kdf::SALT_LEN], +) -> [u8; crate::kdf::DERIVED_KEY_LEN] { + derive_file_key(password, salt) +} + /// `read_exact`, but EOF becomes `on_eof` instead of an `io::Error`. fn read_exact_or( reader: &mut R, @@ -41,34 +71,26 @@ fn at_eof(reader: &mut R) -> io::Result { /// Encrypt exactly `plaintext_len` bytes from `reader` into `writer`. /// -/// The length is authenticated as part of the header. +/// A new random IV, nonce, and Argon2id salt are generated for every encrypted file. +/// +/// The password is used with the header salt to derive the 64-byte KeccakAead key. pub fn encrypt_stream( mut reader: R, mut writer: W, - key: &[u8], + password: &str, plaintext_len: u64, ) -> Result<(), FileCryptError> { - check_key(key)?; + let header = Header::new(plaintext_len)?; - let mut iv = [0u8; 16]; - let mut nonce = [0u8; 16]; + let key = derive_stream_key(password, &header.salt); - getrandom::fill(&mut iv).map_err(FileCryptError::Random)?; - - getrandom::fill(&mut nonce).map_err(FileCryptError::Random)?; - - let header = Header { - key_len: key.len(), - iv, - nonce, - plaintext_len, - }; + check_key(&key)?; let header_bytes = header.to_bytes(); writer.write_all(&header_bytes)?; - let mut aead = KeccakAead::new(&header.iv, key, &header.nonce); + let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce); aead.associated_data_processing(&header_bytes); @@ -95,7 +117,7 @@ pub fn encrypt_stream( return Err(FileCryptError::InputLengthChanged); } - let tag = aead.finalize(key); + let tag = aead.finalize(&key); writer.write_all(&tag)?; writer.flush()?; @@ -105,20 +127,24 @@ pub fn encrypt_stream( /// Decrypt `reader` into `writer` and verify the authentication tag. /// +/// The Argon2id salt is read from the encrypted file's header and used together with `password` to derive the 64-byte AEAD key. +/// /// Plaintext is written before authentication succeeds. The file-level API therefore uses an atomic temporary output file. pub fn decrypt_stream( mut reader: R, mut writer: W, - key: &[u8], + password: &str, ) -> Result<(), FileCryptError> { - check_key(key)?; - let mut header_bytes = [0u8; HEADER_LEN]; read_exact_or(&mut reader, &mut header_bytes, FileCryptError::Truncated)?; let header = Header::from_bytes(&header_bytes)?; + let key = derive_stream_key(password, &header.salt); + + check_key(&key)?; + if header.key_len != key.len() { return Err(FileCryptError::KeyLengthMismatch { header: header.key_len, @@ -126,7 +152,7 @@ pub fn decrypt_stream( }); } - let mut aead = KeccakAead::new(&header.iv, key, &header.nonce); + let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce); aead.associated_data_processing(&header_bytes); @@ -153,7 +179,7 @@ pub fn decrypt_stream( return Err(FileCryptError::TrailingData); } - let computed_tag = aead.finalize(key); + let computed_tag = aead.finalize(&key); let tags_match: bool = computed_tag.as_slice().ct_eq(stored_tag.as_slice()).into(); diff --git a/src/file_crypt/tests.rs b/src/file_crypt/tests.rs index e147b9c..57f57e2 100644 --- a/src/file_crypt/tests.rs +++ b/src/file_crypt/tests.rs @@ -1,9 +1,18 @@ use super::*; use crate::file_crypt::stream::{decrypt_stream, encrypt_stream}; +use crate::kdf::{Argon2Params, DERIVED_KEY_LEN, SALT_LEN, derive_file_key_with_params}; +use crate::keccak_aead::KeccakAead; + use std::fs; use std::io::Cursor; -const KEY: [u8; 16] = [0x42; 16]; +const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, +}; + +const PASSWORD: &str = "test password"; fn sample(n: usize) -> Vec { (0..n) @@ -11,13 +20,13 @@ fn sample(n: usize) -> Vec { .collect() } -fn enc(key: &[u8], plaintext: &[u8]) -> Vec { +fn enc(password: &str, plaintext: &[u8]) -> Vec { let mut out = Vec::new(); encrypt_stream( Cursor::new(plaintext), &mut out, - key, + password, plaintext.len() as u64, ) .unwrap(); @@ -25,10 +34,10 @@ fn enc(key: &[u8], plaintext: &[u8]) -> Vec { out } -fn dec(key: &[u8], data: &[u8]) -> Result, FileCryptError> { +fn dec(password: &str, data: &[u8]) -> Result, FileCryptError> { let mut out = Vec::new(); - decrypt_stream(Cursor::new(data), &mut out, key)?; + decrypt_stream(Cursor::new(data), &mut out, password)?; Ok(out) } @@ -53,74 +62,109 @@ fn boundary_sizes() -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::keccak_aead::KeccakAead; #[test] fn roundtrip_various_sizes() { for n in boundary_sizes() { let plaintext = sample(n); - let encrypted = enc(&KEY, &plaintext); - assert_eq!(encrypted.len(), HEADER_LEN + n + KEY.len(), "size {n}"); - assert_eq!(dec(&KEY, &encrypted).unwrap(), plaintext, "size {n}"); + let encrypted = enc(PASSWORD, &plaintext); + + assert_eq!( + encrypted.len(), + HEADER_LEN + n + DERIVED_KEY_LEN, + "size {n}" + ); + + assert_eq!(dec(PASSWORD, &encrypted).unwrap(), plaintext, "size {n}"); } } #[test] - fn roundtrip_all_key_lengths() { - for key_len in MIN_KEY_LEN..=MAX_KEY_LEN { - let key = vec![0xA5u8; key_len]; - let plaintext = sample(100); - let encrypted = enc(&key, &plaintext); - assert_eq!(encrypted[5] as usize, key_len); - assert_eq!(encrypted.len(), HEADER_LEN + 100 + key_len); - assert_eq!(dec(&key, &encrypted).unwrap(), plaintext); - } + fn header_contains_derived_key_length() { + let encrypted = enc(PASSWORD, &sample(100)); + + assert_eq!(encrypted[5] as usize, DERIVED_KEY_LEN); + + assert_eq!(encrypted.len(), HEADER_LEN + 100 + DERIVED_KEY_LEN); } - /// Chunked streaming must be byte-identical to the one-shot AEAD. #[test] fn matches_one_shot_aead() { for n in boundary_sizes() { let plaintext = sample(n); - let encrypted = enc(&KEY, &plaintext); + let encrypted = enc(PASSWORD, &plaintext); let header = &encrypted[..HEADER_LEN]; + let iv = &header[8..24]; let nonce = &header[24..40]; - let expected = KeccakAead::encrypt(&KEY, &plaintext, iv, header, nonce); + let salt: [u8; SALT_LEN] = header[40..56].try_into().unwrap(); + + let key = derive_file_key_with_params(PASSWORD, &salt, TEST_ARGON2_PARAMS).unwrap(); + + let expected = KeccakAead::encrypt(&key, &plaintext, iv, header, nonce); assert_eq!( &encrypted[HEADER_LEN..HEADER_LEN + n], &expected.cipher[..], "size {n}" ); + assert_eq!(&encrypted[HEADER_LEN + n..], &expected.tag[..], "size {n}"); } } #[test] - fn iv_and_nonce_are_random() { - let a = enc(&KEY, b"same plaintext"); - let b = enc(&KEY, b"same plaintext"); + fn iv_nonce_and_salt_are_random() { + let a = enc(PASSWORD, b"same plaintext"); + let b = enc(PASSWORD, b"same plaintext"); + assert_ne!(a[8..24], b[8..24], "IV repeated"); + assert_ne!(a[24..40], b[24..40], "nonce repeated"); + + assert_ne!(a[40..56], b[40..56], "salt repeated"); + assert_ne!(a[HEADER_LEN..], b[HEADER_LEN..]); } + #[test] + fn different_salts_still_allow_decryption() { + let plaintext = sample(100); + + let a = enc(PASSWORD, &plaintext); + let b = enc(PASSWORD, &plaintext); + + // The salts must differ. + assert_ne!(a[40..56], b[40..56]); + + // Both files must nevertheless decrypt correctly + // because their respective salts are stored in the header. + assert_eq!(dec(PASSWORD, &a).unwrap(), plaintext); + + assert_eq!(dec(PASSWORD, &b).unwrap(), plaintext); + } + #[test] fn every_single_bit_flip_is_rejected() { let plaintext = sample(100); - let encrypted = enc(&KEY, &plaintext); + let encrypted = enc(PASSWORD, &plaintext); for pos in 0..encrypted.len() { for bit in [0x01u8, 0x80] { let mut tampered = encrypted.clone(); tampered[pos] ^= bit; - let result = dec(&KEY, &tampered); + + let result = dec(PASSWORD, &tampered); + assert!(result.is_err(), "flip at byte {pos} bit {bit:#x} accepted"); - // Everything after the header must fail specifically on the tag. + /* + * Everything after the header must fail specifically + * on authentication because ciphertext/tag corruption + * is detected by the AEAD. + */ if pos >= HEADER_LEN { assert!( matches!(result, Err(FileCryptError::AuthenticationFailed)), @@ -131,32 +175,34 @@ mod tests { } } - /// The AEAD absorbs a partial final ciphertext block zero-padded and - /// without a length, so `C || 00` yields the same tag as `C`. The - /// authenticated length in the header must stop that. #[test] fn trailing_zero_byte_forgery_is_rejected() { - let plaintext = sample(20); // final chunk has 4 bytes - let encrypted = enc(&KEY, &plaintext); + let plaintext = sample(20); + let encrypted = enc(PASSWORD, &plaintext); let mut forged = encrypted[..HEADER_LEN + 20].to_vec(); + forged.push(0x00); + forged.extend_from_slice(&encrypted[HEADER_LEN + 20..]); - // Without touching the header: structural error. - assert!(dec(&KEY, &forged).is_err()); + // Without modifying the authenticated length, + // the file structure is inconsistent. + assert!(dec(PASSWORD, &forged).is_err()); + + // Changing the authenticated length must also fail because + // the header is associated data. + forged[56..64].copy_from_slice(&21u64.to_le_bytes()); - // Also bumping the length field: the header is associated data. - forged[40..48].copy_from_slice(&21u64.to_le_bytes()); assert!(matches!( - dec(&KEY, &forged), + dec(PASSWORD, &forged), Err(FileCryptError::AuthenticationFailed) )); } #[test] fn truncation_and_trailing_data() { - let encrypted = enc(&KEY, &sample(100)); + let encrypted = enc(PASSWORD, &sample(100)); for cut in [ 0, @@ -167,109 +213,167 @@ mod tests { encrypted.len() - 1, ] { assert!( - matches!(dec(&KEY, &encrypted[..cut]), Err(FileCryptError::Truncated)), + matches!( + dec(PASSWORD, &encrypted[..cut]), + Err(FileCryptError::Truncated) + ), "cut at {cut}" ); } let mut extended = encrypted.clone(); + extended.push(0); + assert!(matches!( - dec(&KEY, &extended), + dec(PASSWORD, &extended), Err(FileCryptError::TrailingData) )); } #[test] - fn wrong_key_and_key_length() { - let encrypted = enc(&KEY, &sample(50)); + fn wrong_password() { + let encrypted = enc(PASSWORD, &sample(50)); - let mut other = KEY; - other[0] ^= 1; assert!(matches!( - dec(&other, &encrypted), + dec("wrong password", &encrypted), Err(FileCryptError::AuthenticationFailed) )); + } + + #[test] + fn malformed_key_length() { + let encrypted = enc(PASSWORD, &sample(50)); + + let mut malformed = encrypted.clone(); + + // 16 is structurally valid, but does not match + // the 64-byte key generated by Argon2id. + malformed[5] = 16; assert!(matches!( - dec(&[0x42u8; 24], &encrypted), + dec(PASSWORD, &malformed), Err(FileCryptError::KeyLengthMismatch { header: 16, - provided: 24 + provided: 64 }) )); assert!(matches!( - dec(&[0x42u8; 8], &encrypted), - Err(FileCryptError::InvalidKeyLength(8)) + super::check_key(&[0u8; 15]), + Err(FileCryptError::InvalidKeyLength(15)) )); + assert!(matches!( - encrypt_stream(Cursor::new(b"x"), Vec::new(), &[0u8; 25], 1), - Err(FileCryptError::InvalidKeyLength(25)) + super::check_key(&[0u8; 65]), + Err(FileCryptError::InvalidKeyLength(65)) )); } #[test] fn encrypt_detects_length_change() { - let too_short = encrypt_stream(Cursor::new(vec![0u8; 10]), Vec::new(), &KEY, 11); + let too_short = encrypt_stream(Cursor::new(vec![0u8; 10]), Vec::new(), PASSWORD, 11); + assert!(matches!(too_short, Err(FileCryptError::InputLengthChanged))); - let too_long = encrypt_stream(Cursor::new(vec![0u8; 12]), Vec::new(), &KEY, 11); + let too_long = encrypt_stream(Cursor::new(vec![0u8; 12]), Vec::new(), PASSWORD, 11); + assert!(matches!(too_long, Err(FileCryptError::InputLengthChanged))); } #[test] fn garbage_headers() { assert!(matches!( - dec(&KEY, &[0u8; HEADER_LEN]), + dec(PASSWORD, &[0u8; HEADER_LEN]), Err(FileCryptError::BadMagic) )); - let mut bad_version = enc(&KEY, b"x"); - bad_version[4] = 2; + let mut bad_version = enc(PASSWORD, b"x"); + + bad_version[4] = 3; + assert!(matches!( - dec(&KEY, &bad_version), - Err(FileCryptError::UnsupportedVersion(2)) + dec(PASSWORD, &bad_version), + Err(FileCryptError::UnsupportedVersion(3)) )); - // Absurd declared length on a tiny file: must fail, not allocate. - let mut huge = enc(&KEY, b"x"); - huge[40..48].copy_from_slice(&u64::MAX.to_le_bytes()); - assert!(matches!(dec(&KEY, &huge), Err(FileCryptError::Truncated))); + /* + * Absurd declared length on a tiny file: + * decryption must fail without trying to allocate + * based on that value. + */ + let mut huge = enc(PASSWORD, b"x"); + + huge[56..64].copy_from_slice(&u64::MAX.to_le_bytes()); + + assert!(matches!( + dec(PASSWORD, &huge), + Err(FileCryptError::Truncated) + )); + } + + #[test] + fn salt_is_authenticated() { + let encrypted = enc(PASSWORD, b"hello"); + + let mut tampered = encrypted.clone(); + + // Change one bit of the salt. + tampered[40] ^= 1; + + assert!(matches!( + dec(PASSWORD, &tampered), + Err(FileCryptError::AuthenticationFailed) + )); } #[test] fn file_roundtrip_and_atomic_failure() { let dir = tempfile::tempdir().unwrap(); + let plain = dir.path().join("plain.bin"); + let encrypted = dir.path().join("plain.bin.kaef"); + let decrypted = dir.path().join("decrypted.bin"); let data = sample(3 * BUF_SIZE + 5); + fs::write(&plain, &data).unwrap(); - encrypt_file(&plain, &encrypted, &KEY).unwrap(); - decrypt_file(&encrypted, &decrypted, &KEY).unwrap(); + encrypt_file(&plain, &encrypted, PASSWORD).unwrap(); + + decrypt_file(&encrypted, &decrypted, PASSWORD).unwrap(); + assert_eq!(fs::read(&decrypted).unwrap(), data); // Corrupt one ciphertext byte. let mut bytes = fs::read(&encrypted).unwrap(); + bytes[HEADER_LEN + 1000] ^= 1; + let corrupted = dir.path().join("corrupted.kaef"); + fs::write(&corrupted, &bytes).unwrap(); - // A failed decryption must not create the output + // A failed decryption must not create + // the output. let out_new = dir.path().join("out_new.bin"); + assert!(matches!( - decrypt_file(&corrupted, &out_new, &KEY), + decrypt_file(&corrupted, &out_new, PASSWORD,), Err(FileCryptError::AuthenticationFailed) )); + assert!(!out_new.exists()); - // nor clobber an existing file. + // Nor clobber an existing file. let out_existing = dir.path().join("out_existing.bin"); + fs::write(&out_existing, b"precious").unwrap(); - assert!(decrypt_file(&corrupted, &out_existing, &KEY).is_err()); + + assert!(decrypt_file(&corrupted, &out_existing, PASSWORD,).is_err()); + assert_eq!(fs::read(&out_existing).unwrap(), b"precious"); // No temporary files are left behind. @@ -278,23 +382,31 @@ mod tests { .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .filter(|n| n.ends_with(".tmp")) .collect(); + assert!(leftovers.is_empty(), "leftover temp files: {leftovers:?}"); } #[test] fn empty_file_roundtrip() { let dir = tempfile::tempdir().unwrap(); + let plain = dir.path().join("empty"); + let encrypted = dir.path().join("empty.kaef"); + let decrypted = dir.path().join("empty.out"); + fs::write(&plain, b"").unwrap(); - encrypt_file(&plain, &encrypted, &KEY).unwrap(); + encrypt_file(&plain, &encrypted, PASSWORD).unwrap(); + assert_eq!( fs::metadata(&encrypted).unwrap().len() as usize, - HEADER_LEN + KEY.len() + HEADER_LEN + DERIVED_KEY_LEN ); - decrypt_file(&encrypted, &decrypted, &KEY).unwrap(); + + decrypt_file(&encrypted, &decrypted, PASSWORD).unwrap(); + assert_eq!(fs::read(&decrypted).unwrap(), b""); } } diff --git a/src/kdf/argon2.rs b/src/kdf/argon2.rs new file mode 100644 index 0000000..995f517 --- /dev/null +++ b/src/kdf/argon2.rs @@ -0,0 +1,317 @@ +use super::compress::{Block, compress_block}; +use super::hash::{block_to_bytes, hash_long, initial_block, initial_hash}; +use super::kdf::ARGON2ID_TYPE; +use super::{Argon2Params, KdfError}; + +/// Core Argon2id function. +/// +/// This implements Argon2id v1.3 using BLAKE2b for H and H'. +pub(super) fn argon2id( + password: &[u8], + salt: &[u8], + params: Argon2Params, + output_len: usize, +) -> Result, KdfError> { + if params.time_cost == 0 { + return Err(KdfError::InvalidTimeCost); + } + + if params.parallelism == 0 { + return Err(KdfError::InvalidParallelism); + } + + let parallelism = params.parallelism as usize; + let requested_memory = params.memory_kib as usize; + + /* + * Argon2 requires at least 8 KiB per lane. + */ + if requested_memory < 8 * parallelism { + return Err(KdfError::InvalidMemoryCost); + } + + /* + * The actual memory size is rounded down to a multiple of 4 * parallelism. + */ + let memory_kib = 4 * parallelism * (requested_memory / (4 * parallelism)); + + /* + * Number of blocks in each lane. + */ + let lane_length = memory_kib / parallelism; + + /* + * Four segments per lane. + */ + let segment_length = lane_length / 4; + + /* + * Initial hash H_0. + */ + let h0 = initial_hash(password, salt, params, memory_kib as u32, output_len); + + /* + * Allocate the complete Argon2 memory matrix. + * + * Layout: + * + * lane 0: [block][block][block]... + * lane 1: [block][block][block]... + * ... + */ + let mut memory = vec![Block::ZERO; memory_kib]; + + /* + * Initialize the first two blocks of every lane: + * + * B[i][0] = H'(H_0 || LE32(0) || LE32(i)) + * B[i][1] = H'(H_0 || LE32(1) || LE32(i)) + */ + for lane in 0..parallelism { + memory[lane * lane_length] = initial_block(&h0, 0, lane); + + memory[lane * lane_length + 1] = initial_block(&h0, 1, lane); + } + + /* + * Fill the memory matrix. + * + * Argon2id: + * + * - pass 0, slices 0 and 1 use Argon2i style data independent addressing; + * - everything else uses Argon2d style addressing. + */ + for pass in 0..params.time_cost as usize { + for slice in 0..4 { + for lane in 0..parallelism { + process_segment( + &mut memory, + lane_length, + segment_length, + parallelism, + pass, + slice, + lane, + params.time_cost as usize, + memory_kib as u64, + ); + } + } + } + + /* + * Final block: + * + * C = B[0][q-1] XOR B[1][q-1] XOR ... XOR B[p-1][q-1] + */ + let mut final_block = Block::ZERO; + + for lane in 0..parallelism { + let offset = lane * lane_length + lane_length - 1; + + for (dst, src) in final_block.0.iter_mut().zip(memory[offset].0.iter()) { + *dst ^= *src; + } + } + + /* + * Final Argon2 output: + * + * H'(C, output_len) + */ + let final_bytes = block_to_bytes(&final_block); + + Ok(hash_long(&final_bytes, output_len)) +} + +/// Process one Argon2 segment. +fn process_segment( + memory: &mut [Block], + lane_length: usize, + segment_length: usize, + parallelism: usize, + pass: usize, + slice: usize, + lane: usize, + total_time: usize, + memory_kib: u64, +) { + /* + * Argon2id uses data-independent addressing only during the first two slices of the first pass. + */ + let data_independent = pass == 0 && slice < 2; + + let mut address_block = Block::ZERO; + let mut input_block = Block::ZERO; + + /* + * Position within this segment. + */ + let mut index = 0usize; + + if data_independent { + input_block.0[0] = pass as u64; + input_block.0[1] = lane as u64; + input_block.0[2] = slice as u64; + input_block.0[3] = memory_kib; + input_block.0[4] = total_time as u64; + input_block.0[5] = ARGON2ID_TYPE as u64; + + /* + * In the first segment, blocks 0 and 1 already exist, so generation starts at block 2. + */ + if pass == 0 && slice == 0 { + input_block.0[6] += 1; + + address_block = compress_block(&input_block, &Block::ZERO, None); + + address_block = compress_block(&address_block, &Block::ZERO, None); + + index = 2; + } + } + + while index < segment_length { + let current_offset = lane * lane_length + slice * segment_length + index; + + /* + * Previous block in this lane. + * + * At the beginning of a lane, the previous block is the final block of the same lane. + */ + let previous_offset = if slice == 0 && index == 0 { + current_offset + lane_length - 1 + } else { + current_offset - 1 + }; + + /* + * Obtain J_1/J_2. + */ + let pseudo_random = if data_independent { + /* + * Generate a new address block every 128 positions. + */ + if index.is_multiple_of(128) { + input_block.0[6] += 1; + + address_block = compress_block(&input_block, &Block::ZERO, None); + + address_block = compress_block(&address_block, &Block::ZERO, None); + } + + address_block.0[index % 128] + } else { + /* + * Argon2d addressing uses the first word of the previous block. + */ + memory[previous_offset].0[0] + }; + + /* + * Select the reference block. + */ + let reference_offset = index_alpha( + pseudo_random, + lane_length, + segment_length, + parallelism, + pass, + slice, + lane, + index, + ); + + /* + * Later passes XOR the newly generated block with the block that was already present at this position. + */ + let previous_block = memory[previous_offset]; + + let reference_block = memory[reference_offset]; + + let old_block = if pass == 0 { + None + } else { + Some(&memory[current_offset]) + }; + + let new_block = compress_block(&previous_block, &reference_block, old_block); + + memory[current_offset] = new_block; + + index += 1; + } +} + +/// Select the reference block for an Argon2 block. +/// +/// This implements the Argon2 mapping from J_1/J_2 to the reference lane and reference position. +fn index_alpha( + random: u64, + lane_length: usize, + segment_length: usize, + parallelism: usize, + pass: usize, + slice: usize, + lane: usize, + index: usize, +) -> usize { + /* + * J_2 selects the reference lane. + */ + let mut reference_lane = ((random >> 32) as usize) % parallelism; + + /* + * During the first slice of the first pass, references must remain in the same lane. + */ + if pass == 0 && slice == 0 { + reference_lane = lane; + } + + /* + * Compute the size of the allowed reference area. + */ + let mut reference_area_size = 3 * segment_length; + + let mut start_position = ((slice + 1) % 4) * segment_length; + + if lane == reference_lane { + reference_area_size += index; + } + + if pass == 0 { + reference_area_size = slice * segment_length; + + start_position = 0; + + if slice == 0 || lane == reference_lane { + reference_area_size += index; + } + } + + /* + * The current block cannot reference itself. + */ + if index == 0 || lane == reference_lane { + reference_area_size -= 1; + } + + /* + * Non-uniform mapping: + * + * x = J_1^2 / 2^32 + * y = |W| * x / 2^32 + */ + let mut x = (random as u32) as u64; + + x = (x * x) >> 32; + + let y = (x * reference_area_size as u64) >> 32; + + /* + * Map into the selected lane. + */ + let relative_position = (start_position + reference_area_size - (y as usize + 1)) % lane_length; + + reference_lane * lane_length + relative_position +} diff --git a/src/kdf/compress.rs b/src/kdf/compress.rs new file mode 100644 index 0000000..5297ed3 --- /dev/null +++ b/src/kdf/compress.rs @@ -0,0 +1,134 @@ +/// One Argon2 memory block is 1024 bytes = 128 × u64. +pub(super) const BLOCK_WORDS: usize = 128; + +/// One Argon2 memory block is 1024 bytes. +pub(super) const BLOCK_SIZE: usize = 1024; + +/// A single 1024-byte Argon2 memory block. +#[derive(Clone, Copy)] +pub(super) struct Block(pub(super) [u64; BLOCK_WORDS]); + +impl Block { + pub(super) const ZERO: Self = Self([0u64; BLOCK_WORDS]); +} + +/// Argon2 compression function G. +/// +/// R = X XOR Y +/// Z = P(R) +/// output = Z XOR R +/// +/// On passes after the first, the old contents of the destination block are also XORed into the result. +pub(super) fn compress_block(previous: &Block, reference: &Block, old: Option<&Block>) -> Block { + let mut r = [0u64; BLOCK_WORDS]; + + for (dst, (&a, &b)) in r.iter_mut().zip(previous.0.iter().zip(reference.0.iter())) { + *dst = a ^ b; + } + + /* + * P(R), first over rows and then over columns. + */ + let mut z = r; + + /* + * Row-wise permutation. + */ + for row in 0..8 { + let start = row * 16; + + let mut words = [0u64; 16]; + + words.copy_from_slice(&z[start..start + 16]); + + round(&mut words); + + z[start..start + 16].copy_from_slice(&words); + } + + /* + * Column-wise permutation. + * + * Each permutation operates on two adjacent 64-bit + * columns from each of the eight rows. + */ + for column_pair in 0..8 { + let mut words = [0u64; 16]; + + for row in 0..8 { + let source = row * 16 + column_pair * 2; + + words[row * 2] = z[source]; + + words[row * 2 + 1] = z[source + 1]; + } + + round(&mut words); + + for row in 0..8 { + let destination = row * 16 + column_pair * 2; + + z[destination] = words[row * 2]; + + z[destination + 1] = words[row * 2 + 1]; + } + } + + let mut output = [0u64; BLOCK_WORDS]; + + for i in 0..BLOCK_WORDS { + output[i] = z[i] ^ r[i] ^ old.map_or(0, |block| block.0[i]); + } + + Block(output) +} + +/// The Argon2/BLAKE2b quarter-round. +/// +/// This is the BlaMka operation used by Argon2. +#[inline(always)] +fn g(v: &mut [u64; 16], a: usize, b: usize, c: usize, d: usize) { + v[a] = blamka(v[a], v[b]); + + v[d] = (v[d] ^ v[a]).rotate_right(32); + + v[c] = blamka(v[c], v[d]); + + v[b] = (v[b] ^ v[c]).rotate_right(24); + + v[a] = blamka(v[a], v[b]); + + v[d] = (v[d] ^ v[a]).rotate_right(16); + + v[c] = blamka(v[c], v[d]); + + v[b] = (v[b] ^ v[c]).rotate_right(63); +} + +/// BlaMka addition: +/// +/// a + b + 2 * low32(a) * low32(b) mod 2^64. +#[inline(always)] +fn blamka(a: u64, b: u64) -> u64 { + let product = 2u64 + .wrapping_mul(a & 0xffff_ffff) + .wrapping_mul(b & 0xffff_ffff); + + a.wrapping_add(b).wrapping_add(product) +} + +/// One BLAKE2b style 16 word round used by Argon2. +#[inline(always)] +fn round(v: &mut [u64; 16]) { + // Column step. + g(v, 0, 4, 8, 12); + g(v, 1, 5, 9, 13); + g(v, 2, 6, 10, 14); + g(v, 3, 7, 11, 15); + + // Diagonal step. + g(v, 0, 5, 10, 15); + g(v, 1, 6, 11, 12); + g(v, 2, 7, 8, 13); + g(v, 3, 4, 9, 14); +} diff --git a/src/kdf/hash.rs b/src/kdf/hash.rs new file mode 100644 index 0000000..d70477e --- /dev/null +++ b/src/kdf/hash.rs @@ -0,0 +1,164 @@ +use crate::blake2b::Blake2b; + +use super::Argon2Params; +use super::compress::{BLOCK_SIZE, BLOCK_WORDS, Block}; +use super::kdf::{ARGON2_VERSION, ARGON2ID_TYPE}; + +/// Compute the Argon2 initial hash H_0. +/// +/// For our password KDF, the optional secret and associated-data +/// inputs are both empty, but their zero lengths are still encoded +/// because they are part of the Argon2 parameter structure. +pub(super) fn initial_hash( + password: &[u8], + salt: &[u8], + params: Argon2Params, + memory_kib: u32, + output_len: usize, +) -> [u8; 64] { + let mut input = Vec::with_capacity(6 * 4 + 4 + password.len() + 4 + salt.len() + 4 + 4); + + // p + append_u32(&mut input, params.parallelism); + + // T + append_u32(&mut input, output_len as u32); + + // m' + append_u32(&mut input, memory_kib); + + // t + append_u32(&mut input, params.time_cost); + + // version + append_u32(&mut input, ARGON2_VERSION); + + // type = Argon2id + append_u32(&mut input, ARGON2ID_TYPE); + + // |P| and P + append_u32(&mut input, password.len() as u32); + input.extend_from_slice(password); + + // |S| and S + append_u32(&mut input, salt.len() as u32); + input.extend_from_slice(salt); + + // |K| = 0 + append_u32(&mut input, 0); + + // |X| = 0 + append_u32(&mut input, 0); + + let digest = Blake2b::hash(&input); + + let mut output = [0u8; 64]; + output.copy_from_slice(&digest); + + output +} + +/// Compute one of the two initial memory blocks. +pub(super) fn initial_block(h0: &[u8; 64], index: usize, lane: usize) -> Block { + let mut input = Vec::with_capacity(72); + + input.extend_from_slice(h0); + + append_u32(&mut input, index as u32); + + append_u32(&mut input, lane as u32); + + hash_long_block(&input) +} + +/// Argon2 variable-length hash H'. +/// +/// This is the H' construction specified by Argon2. +/// It ultimately uses the project's BLAKE2b implementation. +pub(super) fn hash_long(input: &[u8], output_len: usize) -> Vec { + assert!(output_len >= 1); + + let mut prefixed = Vec::with_capacity(4 + input.len()); + + append_u32(&mut prefixed, output_len as u32); + + prefixed.extend_from_slice(input); + + /* + * For T <= 64: + * + * H'(T || A) = BLAKE2b-T(T || A) + */ + if output_len <= 64 { + return Blake2b::hash_with_output_length(&prefixed, output_len) + .expect("output length <= 64"); + } + + /* + * For T > 64: + * + * r = ceil(T / 32) - 2 + * + * V_1 = H^64(T || A) + * V_i = H^64(V_{i-1}) + * final = H^(T - 32*r)(V_r) + */ + let r = output_len.div_ceil(32) - 2; + + let mut output = Vec::with_capacity(output_len); + + let mut v = Blake2b::hash(&prefixed); + + output.extend_from_slice(&v[..32]); + + for _ in 1..r { + v = Blake2b::hash(&v); + output.extend_from_slice(&v[..32]); + } + + let final_len = output_len - 32 * r; + + v = Blake2b::hash_with_output_length(&v, final_len).expect("final H' output length is valid"); + + output.extend_from_slice(&v); + + debug_assert_eq!(output.len(), output_len); + + output +} + +/// Compute H'(input, 1024). +fn hash_long_block(input: &[u8]) -> Block { + let digest = hash_long(input, BLOCK_SIZE); + + let mut words = [0u64; BLOCK_WORDS]; + + for (i, word) in words.iter_mut().enumerate() { + let start = i * 8; + + *word = u64::from_le_bytes( + digest[start..start + 8] + .try_into() + .expect("Argon2 block is 1024 bytes"), + ); + } + + Block(words) +} + +/// Serialize an Argon2 block in little-endian order. +pub(super) fn block_to_bytes(block: &Block) -> Vec { + let mut output = Vec::with_capacity(BLOCK_SIZE); + + for word in block.0 { + output.extend_from_slice(&word.to_le_bytes()); + } + + output +} + +/// Append a little-endian u32. +#[inline] +fn append_u32(output: &mut Vec, value: u32) { + output.extend_from_slice(&value.to_le_bytes()); +} diff --git a/src/kdf/kdf.rs b/src/kdf/kdf.rs index 2d694f7..f8b4e14 100644 --- a/src/kdf/kdf.rs +++ b/src/kdf/kdf.rs @@ -1,24 +1,99 @@ -//! Password-to-key derivation. +//! PBKDF using Argon2id. //! -//! This currently uses SHA3-512 directly +//! Argon2id version 1.3 using the project's BLAKE2b implementation. //! -//! This is not a proper password-based KDF. There is currently no salt, -//! work factor, memory hardness, or iteration count. +//! The password is encoded as UTF-8 and combined with a random, +//! per file salt. The salt is not secret and must be stored alongside +//! the encrypted file so that the same key can be derived during +//! decryption. +//! +//! Default parameters: +//! - Memory: 64 MiB +//! - Passes: 3 +//! - Parallelism: 4 lanes +//! - Output: 64 bytes +//! +//! These defaults follow the memory-constrained recommendation from +//! RFC 9106. The implementation itself is single-threaded; the +//! `parallelism` parameter is still part of the Argon2 computation. -use crate::keccak::sha3_512; +use super::argon2::argon2id; /// Length of the key produced for `KeccakAead`. pub const DERIVED_KEY_LEN: usize = 64; -/// Derive a 64-byte encryption key from a password. +/// Argon2 salt length. /// -/// The password is encoded as UTF-8 and hashed using the project's -/// SHA3-512 implementation. -pub fn derive_file_key(password: &str) -> [u8; DERIVED_KEY_LEN] { - let digest = sha3_512(password.as_bytes()); +/// RFC 9106 recommends a 16-byte salt. +pub const SALT_LEN: usize = 16; - let mut key = [0u8; DERIVED_KEY_LEN]; - key.copy_from_slice(&digest[..DERIVED_KEY_LEN]); +/// Default Argon2id memory cost in KiB. +/// +/// 64 MiB. +pub const ARGON2_MEMORY_KIB: u32 = 64 * 1024; - key +/// Default Argon2id time cost. +pub const ARGON2_TIME_COST: u32 = 3; + +/// Default Argon2id parallelism. +pub const ARGON2_PARALLELISM: u32 = 4; + +/// Argon2 version 1.3. +pub(super) const ARGON2_VERSION: u32 = 0x13; + +/// Argon2id type identifier. +pub(super) const ARGON2ID_TYPE: u32 = 2; + +/// Argon2 parameter set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Argon2Params { + /// Memory cost in KiB. + pub memory_kib: u32, + + /// Number of passes. + pub time_cost: u32, + + /// Number of lanes. + pub parallelism: u32, +} + +impl Default for Argon2Params { + fn default() -> Self { + Self { + memory_kib: ARGON2_MEMORY_KIB, + time_cost: ARGON2_TIME_COST, + parallelism: ARGON2_PARALLELISM, + } + } +} + +#[allow(clippy::enum_variant_names)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KdfError { + InvalidMemoryCost, + InvalidTimeCost, + InvalidParallelism, +} + +/// Derive a 64-byte encryption key from a password and a 16-byte salt. +/// +/// The salt is randomly generated for each encrypted file and stored in its header. It is not secret. +pub fn derive_file_key(password: &str, salt: &[u8; SALT_LEN]) -> [u8; DERIVED_KEY_LEN] { + derive_file_key_with_params(password, salt, Argon2Params::default()) + .expect("default Argon2 parameters are valid") +} + +/// Derive a 64-byte key with explicit Argon2 parameters. +/// +/// This is mainly useful for testing and benchmarking. +pub fn derive_file_key_with_params( + password: &str, + salt: &[u8; SALT_LEN], + params: Argon2Params, +) -> Result<[u8; DERIVED_KEY_LEN], KdfError> { + let digest = argon2id(password.as_bytes(), salt, params, DERIVED_KEY_LEN)?; + + Ok(digest + .try_into() + .expect("Argon2 output length must be DERIVED_KEY_LEN")) } diff --git a/src/kdf/mod.rs b/src/kdf/mod.rs index 12767e4..741086d 100644 --- a/src/kdf/mod.rs +++ b/src/kdf/mod.rs @@ -1,6 +1,12 @@ +mod argon2; +mod compress; +mod hash; mod kdf; #[cfg(test)] mod test; -pub use kdf::derive_file_key; +pub use kdf::{ + ARGON2_MEMORY_KIB, ARGON2_PARALLELISM, ARGON2_TIME_COST, Argon2Params, DERIVED_KEY_LEN, + KdfError, SALT_LEN, derive_file_key, derive_file_key_with_params, +}; diff --git a/src/kdf/test.rs b/src/kdf/test.rs index 6639358..9845172 100644 --- a/src/kdf/test.rs +++ b/src/kdf/test.rs @@ -1,26 +1,309 @@ use super::*; -use crate::kdf::kdf::DERIVED_KEY_LEN; -#[test] -fn key_has_expected_length() { - assert_eq!(DERIVED_KEY_LEN, 64); +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} - let key = derive_file_key("test password"); - assert_eq!(key.len(), DERIVED_KEY_LEN); +fn salt() -> [u8; SALT_LEN] { + *b"0123456789abcdef" } #[test] -fn same_password_same_key() { - let a = derive_file_key("correct horse battery staple"); - let b = derive_file_key("correct horse battery staple"); +fn argon2id_known_vector() { + /* + * Password: "abc" + * Salt: "0123456789abcdef" + * Memory: 32 KiB + * Passes: 3 + * Parallelism: 4 + * Output: 64 bytes + * Version: 1.3 + * Type: Argon2id + */ + let key = derive_file_key_with_params( + "abc", + &salt(), + Argon2Params { + memory_kib: 32, + time_cost: 3, + parallelism: 4, + }, + ) + .unwrap(); - assert_eq!(a, b); + assert_eq!( + hex(&key), + "db750d0e8a56f66f4b70c0b24ee453c03f35c90edfa90715b203396b0be4bd4c\ + ed0884204bbb1e5221db12de6554cdb3b7624f9bbf3336b7797a90871be1f11a" + .replace(' ', "") + .replace('\n', "") + ); +} + +#[test] +fn argon2id_empty_password() { + let key = derive_file_key_with_params( + "", + &salt(), + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_eq!( + hex(&key), + "9a32bc5205d518e7692d11451a825efd8c6a6dcb5cfaa1d511c08f3961a701ff\ + a0e9bfc5163d4e90af944ec81a2fbc37afc2e6ed04807e96f66cd7737cb037c4" + .replace(' ', "") + .replace('\n', "") + ); } #[test] fn different_passwords_produce_different_keys() { - let a = derive_file_key("password1"); - let b = derive_file_key("password2"); + let salt = salt(); - assert_ne!(a, b); + let key1 = derive_file_key_with_params( + "password1", + &salt, + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + let key2 = derive_file_key_with_params( + "password2", + &salt, + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_ne!(key1, key2); +} + +#[test] +fn different_salts_produce_different_keys() { + let salt1 = [0x01u8; SALT_LEN]; + let salt2 = [0x02u8; SALT_LEN]; + + let key1 = derive_file_key_with_params( + "password", + &salt1, + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + let key2 = derive_file_key_with_params( + "password", + &salt2, + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_ne!(key1, key2); +} + +#[test] +fn same_password_and_salt_are_deterministic() { + let salt = salt(); + + let params = Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }; + + let key1 = derive_file_key_with_params("password", &salt, params).unwrap(); + + let key2 = derive_file_key_with_params("password", &salt, params).unwrap(); + + assert_eq!(key1, key2); +} + +#[test] +fn unicode_password_is_supported() { + let salt = salt(); + + let key = derive_file_key_with_params( + "mot de passe 🔐", + &salt, + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_eq!(key.len(), DERIVED_KEY_LEN); +} + +#[test] +fn output_is_always_64_bytes() { + let salt = salt(); + + let key = derive_file_key_with_params( + "password", + &salt, + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_eq!(key.len(), DERIVED_KEY_LEN); + assert_eq!(DERIVED_KEY_LEN, 64); +} + +#[test] +fn invalid_time_cost() { + let result = derive_file_key_with_params( + "password", + &salt(), + Argon2Params { + memory_kib: 32, + time_cost: 0, + parallelism: 1, + }, + ); + + assert_eq!(result.unwrap_err(), KdfError::InvalidTimeCost); +} + +#[test] +fn invalid_parallelism() { + let result = derive_file_key_with_params( + "password", + &salt(), + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 0, + }, + ); + + assert_eq!(result.unwrap_err(), KdfError::InvalidParallelism); +} + +#[test] +fn invalid_memory_cost() { + let result = derive_file_key_with_params( + "password", + &salt(), + Argon2Params { + memory_kib: 31, + time_cost: 1, + parallelism: 4, + }, + ); + + assert_eq!(result.unwrap_err(), KdfError::InvalidMemoryCost); +} + +#[test] +fn minimum_memory_cost() { + /* + * Argon2 requires at least 8 KiB per lane. + * + * With p = 1, 8 KiB is therefore the minimum valid + * memory cost. + */ + let result = derive_file_key_with_params( + "password", + &salt(), + Argon2Params { + memory_kib: 8, + time_cost: 1, + parallelism: 1, + }, + ); + + assert!(result.is_ok()); +} + +#[test] +fn memory_cost_is_rounded_down() { + /* + * m' must be a multiple of 4*p. + * + * 33 KiB with p=1 is therefore rounded to 32 KiB. + */ + let rounded = derive_file_key_with_params( + "password", + &salt(), + Argon2Params { + memory_kib: 33, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + let exact = derive_file_key_with_params( + "password", + &salt(), + Argon2Params { + memory_kib: 32, + time_cost: 1, + parallelism: 1, + }, + ) + .unwrap(); + + assert_eq!(rounded, exact); +} + +#[test] +fn default_parameters_produce_64_byte_key() { + /* + * This deliberately runs the real production parameters: + * + * 64 MiB, 3 passes, 4 lanes. + * + */ + let key = derive_file_key("password", &salt()); + + assert_eq!(key.len(), DERIVED_KEY_LEN); +} + +#[test] +fn default_parameters_match_reference() { + /* + * Production parameters: + * m = 65536 KiB + * t = 3 + * p = 4 + * T = 64 + * + * This vector was independently generated with a reference Argon2id implementation. + */ + let key = derive_file_key("password", &salt()); + + assert_eq!( + hex(&key), + "ceb3d36b4076a38bd44d73139c58916ed33708a99e2941137c64ef51d38d3195\ + 8e2dd94fcbbce2b62aeb049ab43781dc0351fcf3071e7b3cb3031fe0a71cffc9" + .replace(' ', "") + .replace('\n', "") + ); } diff --git a/src/main.rs b/src/main.rs index 62e88df..36d9014 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,6 @@ mod keccak; mod keccak_aead; use file_crypt::{decrypt_file, encrypt_file}; -use kdf::derive_file_key; /// Encrypt and decrypt files using KeccakAead. #[derive(Parser, Debug)] @@ -66,9 +65,7 @@ fn main() -> Result<(), Box> { output, password, } => { - let key = derive_file_key(&password); - - encrypt_file(&input, &output, &key)?; + encrypt_file(&input, &output, &password)?; println!("Encrypted '{}' -> '{}'", input.display(), output.display()); } @@ -78,9 +75,7 @@ fn main() -> Result<(), Box> { output, password, } => { - let key = derive_file_key(&password); - - decrypt_file(&input, &output, &key)?; + decrypt_file(&input, &output, &password)?; println!("Decrypted '{}' -> '{}'", input.display(), output.display()); }