diff --git a/Cargo.lock b/Cargo.lock index 621797f..af6aa9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,283 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "filecrypt-rs" version = "0.1.0" +dependencies = [ + "clap", + "getrandom", + "subtle", + "tempfile", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index 2aa2a9c..9cb48d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.6.7", features = ["derive"] } +getrandom = "0.4.3" +subtle = "2.6.1" + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/src/file_crypt/atomic.rs b/src/file_crypt/atomic.rs new file mode 100644 index 0000000..d73b9aa --- /dev/null +++ b/src/file_crypt/atomic.rs @@ -0,0 +1,73 @@ +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::path::{Path, PathBuf}; + +use super::error::FileCryptError; + +pub(super) struct AtomicOutput { + tmp_path: PathBuf, + final_path: PathBuf, + committed: bool, +} + +impl AtomicOutput { + pub(super) fn create(final_path: &Path) -> Result<(Self, File), FileCryptError> { + let file_name = final_path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "output path has no file name") + })?; + + let mut suffix = [0u8; 8]; + + getrandom::fill(&mut suffix).map_err(FileCryptError::Random)?; + + let mut tmp_name: OsString = file_name.to_os_string(); + + tmp_name.push("."); + + for byte in suffix { + tmp_name.push(format!("{byte:02x}")); + } + + tmp_name.push(".tmp"); + + let tmp_path = final_path.with_file_name(tmp_name); + + let mut options = OpenOptions::new(); + + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let file = options.open(&tmp_path)?; + + Ok(( + Self { + tmp_path, + final_path: final_path.to_path_buf(), + committed: false, + }, + file, + )) + } + + pub(super) fn commit(mut self) -> io::Result<()> { + fs::rename(&self.tmp_path, &self.final_path)?; + + self.committed = true; + + Ok(()) + } +} + +impl Drop for AtomicOutput { + fn drop(&mut self) { + if !self.committed { + let _ = fs::remove_file(&self.tmp_path); + } + } +} diff --git a/src/file_crypt/error.rs b/src/file_crypt/error.rs new file mode 100644 index 0000000..d1d226f --- /dev/null +++ b/src/file_crypt/error.rs @@ -0,0 +1,102 @@ +use std::error::Error; +use std::fmt; +use std::io; + +#[derive(Debug)] +pub enum FileCryptError { + Io(io::Error), + Random(getrandom::Error), + + /// Key length outside `MIN_KEY_LEN..=MAX_KEY_LEN`. + InvalidKeyLength(usize), + + /// The key passed in does not have the length recorded in the header. + KeyLengthMismatch { + header: usize, + provided: usize, + }, + + BadMagic, + UnsupportedVersion(u8), + MalformedHeader, + + /// The file ends before the header, ciphertext or tag is complete. + Truncated, + + /// Bytes follow the tag. + TrailingData, + + /// The input yielded a different number of bytes than expected. + InputLengthChanged, + + /// The tag does not match: wrong key, or the file was modified. + AuthenticationFailed, +} + +impl fmt::Display for FileCryptError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "I/O error: {e}"), + + Self::Random(e) => { + write!(f, "random number generator error: {e}") + } + + Self::InvalidKeyLength(n) => write!( + f, + "key length {n} is invalid (must be {min}..={max} bytes)", + min = super::MIN_KEY_LEN, + max = super::MAX_KEY_LEN, + ), + + Self::KeyLengthMismatch { header, provided } => write!( + f, + "file was encrypted with a {header}-byte key, \ + but a {provided}-byte key was provided" + ), + + Self::BadMagic => { + write!(f, "not a KAEF file (bad magic)") + } + + Self::UnsupportedVersion(v) => { + write!(f, "unsupported format version {v}") + } + + Self::MalformedHeader => { + write!(f, "malformed header") + } + + Self::Truncated => { + write!(f, "file is truncated") + } + + Self::TrailingData => { + write!(f, "unexpected data after the authentication tag") + } + + Self::InputLengthChanged => { + write!(f, "input length changed while it was being read") + } + + Self::AuthenticationFailed => { + write!(f, "authentication failed (wrong key or corrupted file)") + } + } + } +} + +impl Error for FileCryptError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for FileCryptError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} diff --git a/src/file_crypt/file.rs b/src/file_crypt/file.rs new file mode 100644 index 0000000..6d0b355 --- /dev/null +++ b/src/file_crypt/file.rs @@ -0,0 +1,71 @@ +use std::fs::File; +use std::io::BufWriter; +use std::path::Path; + +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`. +/// +/// `output` is only replaced if encryption succeeds. +pub fn encrypt_file( + input: impl AsRef, + output: impl AsRef, + key: &[u8], +) -> Result<(), FileCryptError> { + check_key(key)?; + + let input_file = File::open(input.as_ref())?; + + let plaintext_len = input_file.metadata()?.len(); + + let (guard, out_file) = AtomicOutput::create(output.as_ref())?; + + let mut writer = BufWriter::with_capacity(BUF_SIZE, out_file); + + encrypt_stream(input_file, &mut writer, key, plaintext_len)?; + + let out_file = writer + .into_inner() + .map_err(|e| FileCryptError::Io(e.into_error()))?; + + out_file.sync_all()?; + drop(out_file); + + guard.commit()?; + + Ok(()) +} + +/// Decrypt the file at `input` into `output`. +/// +/// 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], +) -> 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)?; + + let out_file = writer + .into_inner() + .map_err(|e| FileCryptError::Io(e.into_error()))?; + + out_file.sync_all()?; + drop(out_file); + + guard.commit()?; + + Ok(()) +} diff --git a/src/file_crypt/format.rs b/src/file_crypt/format.rs new file mode 100644 index 0000000..3552daf --- /dev/null +++ b/src/file_crypt/format.rs @@ -0,0 +1,63 @@ +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 HEADER_LEN: usize = 48; + +const MAGIC: [u8; 4] = *b"KAEF"; +const VERSION: u8 = 1; + +pub(super) struct Header { + pub(super) key_len: usize, + pub(super) iv: [u8; IV_LEN], + pub(super) nonce: [u8; NONCE_LEN], + pub(super) plaintext_len: u64, +} + +impl Header { + pub(super) 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. + + 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 + } + + pub(super) 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 key_len = bytes[5] as usize; + + if !(MIN_KEY_LEN..=MAX_KEY_LEN).contains(&key_len) || bytes[6..8] != [0, 0] { + return Err(FileCryptError::MalformedHeader); + } + + 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()), + }) + } +} diff --git a/src/file_crypt/mod.rs b/src/file_crypt/mod.rs new file mode 100644 index 0000000..711042b --- /dev/null +++ b/src/file_crypt/mod.rs @@ -0,0 +1,28 @@ +mod atomic; +mod error; +mod file; +mod format; +mod stream; + +#[cfg(test)] +mod tests; + +pub use error::FileCryptError; +pub use file::{decrypt_file, encrypt_file}; +pub use format::{HEADER_LEN, MAX_KEY_LEN, MIN_KEY_LEN}; + +/// Chunk size for file I/O. +/// +/// Must be a multiple of the AEAD's 16-byte chunk. +pub(super) const BUF_SIZE: usize = 64 * 1024; + +const _: () = assert!(HEADER_LEN.is_multiple_of(16)); +const _: () = assert!(BUF_SIZE.is_multiple_of(16)); + +pub(super) fn check_key(key: &[u8]) -> Result<(), FileCryptError> { + if (MIN_KEY_LEN..=MAX_KEY_LEN).contains(&key.len()) { + Ok(()) + } else { + Err(FileCryptError::InvalidKeyLength(key.len())) + } +} diff --git a/src/file_crypt/stream.rs b/src/file_crypt/stream.rs new file mode 100644 index 0000000..20ba369 --- /dev/null +++ b/src/file_crypt/stream.rs @@ -0,0 +1,167 @@ +use std::io::{self, Read, Write}; + +use subtle::ConstantTimeEq; + +use crate::keccak_aead::KeccakAead; + +use super::error::FileCryptError; +use super::format::Header; +use super::{BUF_SIZE, HEADER_LEN, check_key}; + +/// `read_exact`, but EOF becomes `on_eof` instead of an `io::Error`. +fn read_exact_or( + reader: &mut R, + buf: &mut [u8], + on_eof: FileCryptError, +) -> Result<(), FileCryptError> { + reader.read_exact(buf).map_err(|e| { + if e.kind() == io::ErrorKind::UnexpectedEof { + on_eof + } else { + FileCryptError::Io(e) + } + }) +} + +fn at_eof(reader: &mut R) -> io::Result { + let mut byte = [0u8; 1]; + + loop { + match reader.read(&mut byte) { + Ok(n) => return Ok(n == 0), + + Err(e) if e.kind() == io::ErrorKind::Interrupted => { + continue; + } + + Err(e) => return Err(e), + } + } +} + +/// Encrypt exactly `plaintext_len` bytes from `reader` into `writer`. +/// +/// The length is authenticated as part of the header. +pub fn encrypt_stream( + mut reader: R, + mut writer: W, + key: &[u8], + plaintext_len: u64, +) -> Result<(), FileCryptError> { + check_key(key)?; + + let mut iv = [0u8; 16]; + let mut nonce = [0u8; 16]; + + 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, + }; + + let header_bytes = header.to_bytes(); + + writer.write_all(&header_bytes)?; + + let mut aead = KeccakAead::new(&header.iv, key, &header.nonce); + + aead.associated_data_processing(&header_bytes); + + let mut buf = vec![0u8; BUF_SIZE]; + let mut remaining = plaintext_len; + + while remaining > 0 { + let n = remaining.min(BUF_SIZE as u64) as usize; + + read_exact_or( + &mut reader, + &mut buf[..n], + FileCryptError::InputLengthChanged, + )?; + + let ciphertext = aead.plaintext_processing(&buf[..n]); + + writer.write_all(&ciphertext)?; + + remaining -= n as u64; + } + + if !at_eof(&mut reader)? { + return Err(FileCryptError::InputLengthChanged); + } + + let tag = aead.finalize(key); + + writer.write_all(&tag)?; + writer.flush()?; + + Ok(()) +} + +/// Decrypt `reader` into `writer` and verify the authentication tag. +/// +/// 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], +) -> 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)?; + + if header.key_len != key.len() { + return Err(FileCryptError::KeyLengthMismatch { + header: header.key_len, + provided: key.len(), + }); + } + + let mut aead = KeccakAead::new(&header.iv, key, &header.nonce); + + aead.associated_data_processing(&header_bytes); + + let mut buf = vec![0u8; BUF_SIZE]; + let mut remaining = header.plaintext_len; + + while remaining > 0 { + let n = remaining.min(BUF_SIZE as u64) as usize; + + read_exact_or(&mut reader, &mut buf[..n], FileCryptError::Truncated)?; + + let plaintext = aead.ciphertext_processing(&buf[..n]); + + writer.write_all(&plaintext)?; + + remaining -= n as u64; + } + + let mut stored_tag = vec![0u8; header.key_len]; + + read_exact_or(&mut reader, &mut stored_tag, FileCryptError::Truncated)?; + + if !at_eof(&mut reader)? { + return Err(FileCryptError::TrailingData); + } + + let computed_tag = aead.finalize(key); + + let tags_match: bool = computed_tag.as_slice().ct_eq(stored_tag.as_slice()).into(); + + if !tags_match { + return Err(FileCryptError::AuthenticationFailed); + } + + writer.flush()?; + + Ok(()) +} diff --git a/src/file_crypt/tests.rs b/src/file_crypt/tests.rs new file mode 100644 index 0000000..e147b9c --- /dev/null +++ b/src/file_crypt/tests.rs @@ -0,0 +1,300 @@ +use super::*; +use crate::file_crypt::stream::{decrypt_stream, encrypt_stream}; +use std::fs; +use std::io::Cursor; + +const KEY: [u8; 16] = [0x42; 16]; + +fn sample(n: usize) -> Vec { + (0..n) + .map(|i| (i.wrapping_mul(31).wrapping_add(7)) as u8) + .collect() +} + +fn enc(key: &[u8], plaintext: &[u8]) -> Vec { + let mut out = Vec::new(); + + encrypt_stream( + Cursor::new(plaintext), + &mut out, + key, + plaintext.len() as u64, + ) + .unwrap(); + + out +} + +fn dec(key: &[u8], data: &[u8]) -> Result, FileCryptError> { + let mut out = Vec::new(); + + decrypt_stream(Cursor::new(data), &mut out, key)?; + + Ok(out) +} + +fn boundary_sizes() -> Vec { + vec![ + 0, + 1, + 15, + 16, + 17, + 31, + 32, + 33, + BUF_SIZE - 1, + BUF_SIZE, + BUF_SIZE + 1, + 3 * BUF_SIZE + 5, + ] +} + +#[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}"); + } + } + + #[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); + } + } + + /// 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 header = &encrypted[..HEADER_LEN]; + let iv = &header[8..24]; + let nonce = &header[24..40]; + + 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"); + assert_ne!(a[8..24], b[8..24], "IV repeated"); + assert_ne!(a[24..40], b[24..40], "nonce repeated"); + assert_ne!(a[HEADER_LEN..], b[HEADER_LEN..]); + } + + #[test] + fn every_single_bit_flip_is_rejected() { + let plaintext = sample(100); + let encrypted = enc(&KEY, &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); + assert!(result.is_err(), "flip at byte {pos} bit {bit:#x} accepted"); + + // Everything after the header must fail specifically on the tag. + if pos >= HEADER_LEN { + assert!( + matches!(result, Err(FileCryptError::AuthenticationFailed)), + "byte {pos}" + ); + } + } + } + } + + /// 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 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()); + + // 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), + Err(FileCryptError::AuthenticationFailed) + )); + } + + #[test] + fn truncation_and_trailing_data() { + let encrypted = enc(&KEY, &sample(100)); + + for cut in [ + 0, + 1, + HEADER_LEN - 1, + HEADER_LEN, + HEADER_LEN + 50, + encrypted.len() - 1, + ] { + assert!( + matches!(dec(&KEY, &encrypted[..cut]), Err(FileCryptError::Truncated)), + "cut at {cut}" + ); + } + + let mut extended = encrypted.clone(); + extended.push(0); + assert!(matches!( + dec(&KEY, &extended), + Err(FileCryptError::TrailingData) + )); + } + + #[test] + fn wrong_key_and_key_length() { + let encrypted = enc(&KEY, &sample(50)); + + let mut other = KEY; + other[0] ^= 1; + assert!(matches!( + dec(&other, &encrypted), + Err(FileCryptError::AuthenticationFailed) + )); + + assert!(matches!( + dec(&[0x42u8; 24], &encrypted), + Err(FileCryptError::KeyLengthMismatch { + header: 16, + provided: 24 + }) + )); + + assert!(matches!( + dec(&[0x42u8; 8], &encrypted), + Err(FileCryptError::InvalidKeyLength(8)) + )); + assert!(matches!( + encrypt_stream(Cursor::new(b"x"), Vec::new(), &[0u8; 25], 1), + Err(FileCryptError::InvalidKeyLength(25)) + )); + } + + #[test] + fn encrypt_detects_length_change() { + let too_short = encrypt_stream(Cursor::new(vec![0u8; 10]), Vec::new(), &KEY, 11); + assert!(matches!(too_short, Err(FileCryptError::InputLengthChanged))); + + let too_long = encrypt_stream(Cursor::new(vec![0u8; 12]), Vec::new(), &KEY, 11); + assert!(matches!(too_long, Err(FileCryptError::InputLengthChanged))); + } + + #[test] + fn garbage_headers() { + assert!(matches!( + dec(&KEY, &[0u8; HEADER_LEN]), + Err(FileCryptError::BadMagic) + )); + + let mut bad_version = enc(&KEY, b"x"); + bad_version[4] = 2; + assert!(matches!( + dec(&KEY, &bad_version), + Err(FileCryptError::UnsupportedVersion(2)) + )); + + // 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))); + } + + #[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(); + 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 + let out_new = dir.path().join("out_new.bin"); + assert!(matches!( + decrypt_file(&corrupted, &out_new, &KEY), + 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, &KEY).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()) + .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(); + assert_eq!( + fs::metadata(&encrypted).unwrap().len() as usize, + HEADER_LEN + KEY.len() + ); + decrypt_file(&encrypted, &decrypted, &KEY).unwrap(); + assert_eq!(fs::read(&decrypted).unwrap(), b""); + } +} diff --git a/src/keccak_aead.rs b/src/keccak_aead.rs index 611d73e..2f16a07 100644 --- a/src/keccak_aead.rs +++ b/src/keccak_aead.rs @@ -56,8 +56,8 @@ impl KeccakAead { let mut input = [0u8; STATE_SIZE]; // r = chunk XOR state[0..16] - for i in 0..chunk.len() { - input[i] = chunk[i] ^ self.state[i]; + for (i, &byte) in chunk.iter().enumerate() { + input[i] = byte ^ self.state[i]; } // c = state[16..40] @@ -78,8 +78,8 @@ impl KeccakAead { let mut input = [0u8; STATE_SIZE]; // r = chunk XOR state[0..16] - for i in 0..chunk.len() { - ciphertext.push(chunk[i] ^ self.state[i]); + for (i, &byte) in chunk.iter().enumerate() { + ciphertext.push(byte ^ self.state[i]); } // input = r || c @@ -105,8 +105,8 @@ impl KeccakAead { let mut input = [0u8; STATE_SIZE]; // plaintext = ciphertext XOR state[0..16] - for i in 0..chunk.len() { - plaintext.push(chunk[i] ^ self.state[i]); + for (i, &byte) in chunk.iter().enumerate() { + plaintext.push(byte ^ self.state[i]); } // input = ciphertext || c @@ -137,8 +137,8 @@ impl KeccakAead { padded_key[padding_len..].copy_from_slice(key); // XOR padded key into c. - for i in 0..CAPACITY_SIZE { - self.state[RATE_SIZE + i] ^= padded_key[i]; + for (i, &byte) in padded_key.iter().enumerate().take(CAPACITY_SIZE) { + self.state[RATE_SIZE + i] ^= byte; } // SHAKE256(state, 40) diff --git a/src/main.rs b/src/main.rs index efcd85a..8c99790 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,99 @@ +use std::error::Error; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +mod file_crypt; mod keccak; mod keccak_aead; -fn main() { - println!("Hello, world!"); +use file_crypt::{MAX_KEY_LEN, decrypt_file, encrypt_file}; + +/// File encryption utility using KeccakAead. +#[derive(Parser, Debug)] +#[command( + name = "filecrypt-rs", + version, + about = "Encrypt and decrypt files using KeccakAead" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Encrypt a file. + Encrypt { + /// Input plaintext file. + #[arg(short, long, value_name = "FILE")] + input: PathBuf, + + /// Output encrypted file. + #[arg(short, long, value_name = "FILE")] + output: PathBuf, + + /// Password used to derive the encryption key. + #[arg(short, long, value_name = "PASSWORD")] + password: String, + }, + + /// Decrypt a file. + Decrypt { + /// Input encrypted file. + #[arg(short, long, value_name = "FILE")] + input: PathBuf, + + /// Output plaintext file. + #[arg(short, long, value_name = "FILE")] + output: PathBuf, + + /// Password used to derive the encryption key. + #[arg(short, long, value_name = "PASSWORD")] + password: String, + }, +} + +/// Derive the fixed-length AEAD key from a password. +/// +/// SHA-3-256 produces 32 bytes, while `KeccakAead` currently accepts keys from 16 through 24 bytes. Therefore we use the first 24 bytes. +fn derive_key(password: &str) -> [u8; MAX_KEY_LEN] { + let digest = keccak::sha3_256(password.as_bytes()); + + let mut key = [0u8; MAX_KEY_LEN]; + key.copy_from_slice(&digest[..MAX_KEY_LEN]); + + key +} + +fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + match cli.command { + Command::Encrypt { + input, + output, + password, + } => { + let key = derive_key(&password); + + encrypt_file(&input, &output, &key)?; + + println!("Encrypted '{}' -> '{}'", input.display(), output.display()); + } + + Command::Decrypt { + input, + output, + password, + } => { + let key = derive_key(&password); + + decrypt_file(&input, &output, &key)?; + + println!("Decrypted '{}' -> '{}'", input.display(), output.display()); + } + } + + Ok(()) }