diff --git a/src/file_crypt/format.rs b/src/file_crypt/format.rs index 2919d3a..3214492 100644 --- a/src/file_crypt/format.rs +++ b/src/file_crypt/format.rs @@ -1,43 +1,50 @@ +use std::io; + use crate::kdf::{DERIVED_KEY_LEN, SALT_LEN}; +use crate::keccak::hmac_sha3_256; use super::error::FileCryptError; +const MAGIC: [u8; 4] = *b"KAEF"; +const VERSION: u8 = 2; + pub const IV_LEN: usize = 16; pub const NONCE_LEN: usize = 16; pub const MIN_KEY_LEN: usize = 16; +pub const MAX_KEY_LEN: usize = DERIVED_KEY_LEN; -pub const MAX_KEY_LEN: usize = 64; +pub const HEADER_AUTH_LEN: usize = 16; +pub const HEADER_AUTH_OFFSET: usize = 64; +pub const HEADER_LEN: usize = HEADER_AUTH_OFFSET + HEADER_AUTH_LEN; -pub const HEADER_LEN: usize = 64; - -const MAGIC: [u8; 4] = *b"KAEF"; -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, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub(crate) key_len: usize, + pub(crate) iv: [u8; IV_LEN], + pub(crate) nonce: [u8; NONCE_LEN], + pub(crate) salt: [u8; SALT_LEN], + pub(crate) 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]; + /// Create a new v2 header with a fixed-size Argon2-derived key. + pub fn new(plaintext_len: u64) -> Result { + let mut iv = [0u8; IV_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + let mut salt = [0u8; SALT_LEN]; - getrandom::fill(&mut random).map_err(FileCryptError::Random)?; + getrandom::fill(&mut iv).map_err(|e| { + FileCryptError::Io(io::Error::other(format!("failed to generate IV: {e}"))) + })?; - let iv = random[0..IV_LEN].try_into().expect("IV length is constant"); + getrandom::fill(&mut nonce).map_err(|e| { + FileCryptError::Io(io::Error::other(format!("failed to generate nonce: {e}"))) + })?; - 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"); + getrandom::fill(&mut salt).map_err(|e| { + FileCryptError::Io(io::Error::other(format!("failed to generate salt: {e}"))) + })?; Ok(Self { key_len: DERIVED_KEY_LEN, @@ -48,46 +55,104 @@ impl Header { }) } - pub(super) fn to_bytes(&self) -> [u8; HEADER_LEN] { + /// Serialize the header. + /// + /// The authentication field at `64..80` is deliberately left zeroed. + /// `compute_header_auth()` computes the HMAC over this representation, + /// then the caller inserts the resulting authenticator. + pub fn to_bytes(&self) -> [u8; HEADER_LEN] { let mut out = [0u8; HEADER_LEN]; out[0..4].copy_from_slice(&MAGIC); out[4] = VERSION; out[5] = self.key_len as u8; - // out[6..8] reserved, zero. + // 6..8: reserved, must remain zero. out[8..24].copy_from_slice(&self.iv); out[24..40].copy_from_slice(&self.nonce); out[40..56].copy_from_slice(&self.salt); out[56..64].copy_from_slice(&self.plaintext_len.to_le_bytes()); + // 64..80: header authentication field. + // Left as zero while computing the HMAC. + out } - pub(super) fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result { + /// Parse and structurally validate a v2 header. + /// + /// The HMAC field itself is not validated here. Authentication is + /// performed after deriving the password-based key. + pub fn from_bytes(bytes: &[u8; HEADER_LEN]) -> Result { if bytes[0..4] != MAGIC { return Err(FileCryptError::BadMagic); } - if bytes[4] != VERSION { - return Err(FileCryptError::UnsupportedVersion(bytes[4])); + let version = bytes[4]; + + if version != VERSION { + return Err(FileCryptError::UnsupportedVersion(version)); } let key_len = bytes[5] as usize; - if !(MIN_KEY_LEN..=MAX_KEY_LEN).contains(&key_len) || bytes[6..8] != [0, 0] { + if !(MIN_KEY_LEN..=MAX_KEY_LEN).contains(&key_len) { return Err(FileCryptError::MalformedHeader); } + // Bytes 6..8 are reserved and must be zero. + if bytes[6] != 0 || bytes[7] != 0 { + return Err(FileCryptError::MalformedHeader); + } + + let iv: [u8; IV_LEN] = bytes[8..24] + .try_into() + .map_err(|_| FileCryptError::MalformedHeader)?; + + let nonce: [u8; NONCE_LEN] = bytes[24..40] + .try_into() + .map_err(|_| FileCryptError::MalformedHeader)?; + + let salt: [u8; SALT_LEN] = bytes[40..56] + .try_into() + .map_err(|_| FileCryptError::MalformedHeader)?; + + let plaintext_len_bytes: [u8; 8] = bytes[56..64] + .try_into() + .map_err(|_| FileCryptError::MalformedHeader)?; + + let plaintext_len = u64::from_le_bytes(plaintext_len_bytes); + + // 64..80 is the stored header HMAC. + // It must NOT be required to contain zeroes here. + Ok(Self { key_len, - 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"), - ), + iv, + nonce, + salt, + plaintext_len, }) } } + +/// Compute the truncated HMAC-SHA3-256 authenticator for a header. +/// +/// The authentication field itself is replaced with zeroes before hashing. +/// Only the first `HEADER_AUTH_LEN` bytes of the 32-byte HMAC are stored. +pub fn compute_header_auth( + key: &[u8; DERIVED_KEY_LEN], + header: &[u8; HEADER_LEN], +) -> [u8; HEADER_AUTH_LEN] { + let mut authenticated_header = *header; + + authenticated_header[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN].fill(0); + + let mac = hmac_sha3_256(key, &authenticated_header); + + let mut auth = [0u8; HEADER_AUTH_LEN]; + auth.copy_from_slice(&mac[..HEADER_AUTH_LEN]); + + auth +} diff --git a/src/file_crypt/stream.rs b/src/file_crypt/stream.rs index 04f2b2d..16dc140 100644 --- a/src/file_crypt/stream.rs +++ b/src/file_crypt/stream.rs @@ -11,8 +11,8 @@ 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}; +use super::format::{HEADER_AUTH_LEN, HEADER_AUTH_OFFSET, Header, compute_header_auth}; +use super::{BUF_SIZE, HEADER_LEN}; #[cfg(test)] const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params { @@ -74,6 +74,8 @@ fn at_eof(reader: &mut R) -> io::Result { /// 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. +/// +/// A truncated HMAC-SHA3-256 authenticator is stored in the header. The HMAC covers the complete header except for the HMAC field itself. pub fn encrypt_stream( mut reader: R, mut writer: W, @@ -82,16 +84,23 @@ pub fn encrypt_stream( ) -> Result<(), FileCryptError> { let header = Header::new(plaintext_len)?; + // Argon2id always produces DERIVED_KEY_LEN bytes. let key = derive_stream_key(password, &header.salt); - check_key(&key)?; + // Header::to_bytes() leaves the authentication field zeroed. + let mut header_bytes = header.to_bytes(); - let header_bytes = header.to_bytes(); + // Authenticate the header before inserting the authenticator itself. + let header_auth = compute_header_auth(&key, &header_bytes); + + header_bytes[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN] + .copy_from_slice(&header_auth); writer.write_all(&header_bytes)?; let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce); + // Authenticate the complete header, including its HMAC, as AEAD associated data. aead.associated_data_processing(&header_bytes); let mut buf = vec![0u8; BUF_SIZE]; @@ -127,9 +136,15 @@ 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. +/// 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. +/// The header HMAC is verified immediately after key derivation and +/// BEFORE any ciphertext is read. Thus a wrong password can be rejected +/// without processing a potentially very large encrypted file. +/// +/// Plaintext is written before the final AEAD tag has been checked. +/// The file-level API therefore uses an atomic temporary output file. pub fn decrypt_stream( mut reader: R, mut writer: W, @@ -143,8 +158,7 @@ pub fn decrypt_stream( let key = derive_stream_key(password, &header.salt); - check_key(&key)?; - + // The password KDF always produces a DERIVED_KEY_LEN-byte key. if header.key_len != key.len() { return Err(FileCryptError::KeyLengthMismatch { header: header.key_len, @@ -152,8 +166,25 @@ pub fn decrypt_stream( }); } + // Early authentication + let expected_header_auth = compute_header_auth(&key, &header_bytes); + + let stored_header_auth = + &header_bytes[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN]; + + let header_auth_matches: bool = expected_header_auth + .as_slice() + .ct_eq(stored_header_auth) + .into(); + + if !header_auth_matches { + return Err(FileCryptError::AuthenticationFailed); + } + + // At this point the password and all HMAC-authenticated header fields are valid. Only now do we start reading the ciphertext. let mut aead = KeccakAead::new(&header.iv, &key, &header.nonce); + // Authenticate the complete header, including the HMAC field, with AEAD. aead.associated_data_processing(&header_bytes); let mut buf = vec![0u8; BUF_SIZE]; diff --git a/src/file_crypt/tests.rs b/src/file_crypt/tests.rs index 57f57e2..fe74ae0 100644 --- a/src/file_crypt/tests.rs +++ b/src/file_crypt/tests.rs @@ -1,10 +1,12 @@ use super::*; + +use crate::file_crypt::format::{HEADER_AUTH_LEN, HEADER_AUTH_OFFSET}; 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; +use std::io::{self, Cursor, Read}; const TEST_ARGON2_PARAMS: Argon2Params = Argon2Params { memory_kib: 32, @@ -88,6 +90,16 @@ mod tests { assert_eq!(encrypted.len(), HEADER_LEN + 100 + DERIVED_KEY_LEN); } + #[test] + fn header_contains_authentication_tag() { + let encrypted = enc(PASSWORD, b"hello"); + + let header_auth = &encrypted[HEADER_AUTH_OFFSET..HEADER_AUTH_OFFSET + HEADER_AUTH_LEN]; + + assert_eq!(header_auth.len(), HEADER_AUTH_LEN); + assert_ne!(header_auth, &[0u8; HEADER_AUTH_LEN]); + } + #[test] fn matches_one_shot_aead() { for n in boundary_sizes() { @@ -136,11 +148,8 @@ mod tests { 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); @@ -154,21 +163,20 @@ mod tests { for pos in 0..encrypted.len() { for bit in [0x01u8, 0x80] { let mut tampered = encrypted.clone(); + tampered[pos] ^= bit; 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 authentication because ciphertext/tag corruption - * is detected by the AEAD. - */ + // Header structure errors are expected for some header + // fields. Once ciphertext/tag processing is reached, any + // modification must fail authentication. if pos >= HEADER_LEN { assert!( matches!(result, Err(FileCryptError::AuthenticationFailed)), - "byte {pos}" + "byte {pos}, bit {bit:#x}: {result:?}" ); } } @@ -186,12 +194,11 @@ mod tests { forged.extend_from_slice(&encrypted[HEADER_LEN + 20..]); - // Without modifying the authenticated length, - // the file structure is inconsistent. - assert!(dec(PASSWORD, &forged).is_err()); + assert!(matches!( + dec(PASSWORD, &forged), + Err(FileCryptError::TrailingData) + )); - // Changing the authenticated length must also fail because - // the header is associated data. forged[56..64].copy_from_slice(&21u64.to_le_bytes()); assert!(matches!( @@ -241,14 +248,54 @@ mod tests { )); } + #[test] + fn wrong_password_is_rejected_before_ciphertext_read() { + struct HeaderOnlyReader { + data: Vec, + position: usize, + } + + impl Read for HeaderOnlyReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.position >= self.data.len() { + return Err(io::Error::other( + "ciphertext was read after header authentication", + )); + } + + let remaining = self.data.len() - self.position; + let n = buf.len().min(remaining); + + buf[..n].copy_from_slice(&self.data[self.position..self.position + n]); + + self.position += n; + + Ok(n) + } + } + + let encrypted = enc(PASSWORD, &sample(BUF_SIZE * 10)); + + let reader = HeaderOnlyReader { + data: encrypted[..HEADER_LEN].to_vec(), + position: 0, + }; + + let mut output = Vec::new(); + + let result = decrypt_stream(reader, &mut output, "wrong password"); + + assert!(matches!(result, Err(FileCryptError::AuthenticationFailed))); + + assert!(output.is_empty()); + } + #[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!( @@ -298,9 +345,7 @@ mod tests { )); /* - * Absurd declared length on a tiny file: - * decryption must fail without trying to allocate - * based on that value. + * The plaintext length field is HMAC-authenticated, so changing it causes AuthenticationFailed before ciphertext processing. */ let mut huge = enc(PASSWORD, b"x"); @@ -308,7 +353,7 @@ mod tests { assert!(matches!( dec(PASSWORD, &huge), - Err(FileCryptError::Truncated) + Err(FileCryptError::AuthenticationFailed) )); } @@ -318,7 +363,6 @@ mod tests { let mut tampered = encrypted.clone(); - // Change one bit of the salt. tampered[40] ^= 1; assert!(matches!( @@ -327,14 +371,26 @@ mod tests { )); } + #[test] + fn authentication_field_tampering_is_rejected() { + let encrypted = enc(PASSWORD, b"hello"); + + let mut tampered = encrypted.clone(); + + tampered[HEADER_AUTH_OFFSET] ^= 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); @@ -347,7 +403,6 @@ mod tests { assert_eq!(fs::read(&decrypted).unwrap(), data); - // Corrupt one ciphertext byte. let mut bytes = fs::read(&encrypted).unwrap(); bytes[HEADER_LEN + 1000] ^= 1; @@ -356,27 +411,23 @@ mod tests { fs::write(&corrupted, &bytes).unwrap(); - // A failed decryption must not create - // the output. let out_new = dir.path().join("out_new.bin"); assert!(matches!( - decrypt_file(&corrupted, &out_new, PASSWORD,), + decrypt_file(&corrupted, &out_new, PASSWORD), Err(FileCryptError::AuthenticationFailed) )); assert!(!out_new.exists()); - // 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, PASSWORD,).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. let leftovers: Vec<_> = fs::read_dir(dir.path()) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) @@ -391,9 +442,7 @@ mod tests { 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(); diff --git a/src/keccak/hmac.rs b/src/keccak/hmac.rs new file mode 100644 index 0000000..614f3bf --- /dev/null +++ b/src/keccak/hmac.rs @@ -0,0 +1,56 @@ +//! HMAC-SHA3-256 implementation. +//! +//! HMAC construction: +//! H(K, m) = H((K' xor opad) || H((K' xor ipad) || m)) +//! +//! SHA3-256 has a 136-byte rate, which is the block size used here. + +use super::sha3_256; + +/// SHA3-256 HMAC block size in bytes. +/// +/// SHA3-256 has a rate of 1088 bits = 136 bytes. +pub const HMAC_SHA3_256_BLOCK_SIZE: usize = 136; + +/// SHA3-256 HMAC output size in bytes. +pub const HMAC_SHA3_256_OUTPUT_SIZE: usize = 32; + +/// HMAC-SHA3-256. +/// +/// The key is normalized according to the HMAC construction: +/// - keys longer than the hash block size are hashed first; +/// - shorter keys are zero-padded to the block size. +pub fn hmac_sha3_256(key: &[u8], message: &[u8]) -> [u8; HMAC_SHA3_256_OUTPUT_SIZE] { + let mut key_block = [0u8; HMAC_SHA3_256_BLOCK_SIZE]; + + if key.len() > HMAC_SHA3_256_BLOCK_SIZE { + let hashed_key = sha3_256(key); + key_block[..hashed_key.len()].copy_from_slice(&hashed_key); + } else { + key_block[..key.len()].copy_from_slice(key); + } + + let mut ipad = [0u8; HMAC_SHA3_256_BLOCK_SIZE]; + let mut opad = [0u8; HMAC_SHA3_256_BLOCK_SIZE]; + + for ((ipad_byte, opad_byte), &key_byte) in + ipad.iter_mut().zip(opad.iter_mut()).zip(key_block.iter()) + { + *ipad_byte = key_byte ^ 0x36; + *opad_byte = key_byte ^ 0x5c; + } + + // inner = SHA3-256((K' xor ipad) || message) + let mut inner_input = Vec::with_capacity(HMAC_SHA3_256_BLOCK_SIZE + message.len()); + inner_input.extend_from_slice(&ipad); + inner_input.extend_from_slice(message); + + let inner_hash = sha3_256(&inner_input); + + // outer = SHA3-256((K' xor opad) || inner) + let mut outer_input = Vec::with_capacity(HMAC_SHA3_256_BLOCK_SIZE + inner_hash.len()); + outer_input.extend_from_slice(&opad); + outer_input.extend_from_slice(&inner_hash); + + sha3_256(&outer_input) +} diff --git a/src/keccak/mod.rs b/src/keccak/mod.rs index 4bebdd9..4c9181b 100644 --- a/src/keccak/mod.rs +++ b/src/keccak/mod.rs @@ -1,10 +1,13 @@ -//! Keccak-f[1600], SHA-3, and SHAKE implementations. +//! Keccak-f[1600], SHA-3, SHAKE, and HMAC implementations. +mod hmac; mod keccak; #[cfg(test)] mod test; +pub use hmac::{HMAC_SHA3_256_BLOCK_SIZE, HMAC_SHA3_256_OUTPUT_SIZE, hmac_sha3_256}; + pub use keccak::{ keccak, keccak_f1600, sha3_224, sha3_256, sha3_384, sha3_512, shake128, shake256, }; diff --git a/src/keccak/test.rs b/src/keccak/test.rs index 0fcdd64..19e4b22 100644 --- a/src/keccak/test.rs +++ b/src/keccak/test.rs @@ -217,8 +217,59 @@ fn shake_multiple_squeeze_blocks() { assert_hex( &output256[..64], "483366601360a8771c6863080cc4114d\ -8db44530f8f1e1ee4f94ea37e78b5739\ -d5a15bef186a5386c75744c0527e1faa\ -9f8726e462a12a4feb06bd8801e751e4", + 8db44530f8f1e1ee4f94ea37e78b5739\ + d5a15bef186a5386c75744c0527e1faa\ + 9f8726e462a12a4feb06bd8801e751e4", ); } + +#[test] +fn hmac_sha3_256_test_vectors() { + let vectors = [ + ( + vec![0x0b; 20], + b"Hi There".to_vec(), + "ba85192310dffa96e2a3a40e69774351140bb7185e1202cdcc917589f95e16bb", + ), + ( + b"Jefe".to_vec(), + b"what do ya want for nothing?".to_vec(), + "c7d4072e788877ae3596bbb0da73b887c9171f93095b294ae857fbe2645e1ba5", + ), + ( + vec![0xaa; 20], + vec![0xdd; 50], + "84ec79124a27107865cedd8bd82da9965e5ed8c37b0ac98005a7f39ed58a4207", + ), + ( + (1u8..=25).collect(), + vec![0xcd; 50], + "57366a45e2305321a4bc5aa5fe2ef8a921f6af8273d7fe7be6cfedb3f0aea6d7", + ), + ]; + + for (key, message, expected) in vectors { + let actual = hmac_sha3_256(&key, &message); + assert_hex(&actual, expected); + } +} + +#[test] +fn hmac_sha3_256_empty_key_and_message() { + let actual = hmac_sha3_256(b"", b""); + + assert_hex( + &actual, + "e841c164e5b4f10c9f3985587962af72fd607a951196fc92fb3a5251941784ea", + ); +} + +#[test] +fn long_key_is_hashed() { + let key = vec![0x42; HMAC_SHA3_256_BLOCK_SIZE + 1]; + let message = b"test message"; + + let actual = hmac_sha3_256(&key, message); + + assert_eq!(actual.len(), HMAC_SHA3_256_OUTPUT_SIZE); +}