encrypt decrypt file, clap command line

This commit is contained in:
2026-09-24 09:36:43 +02:00
parent 44fe608942
commit cbce59293d
11 changed files with 1190 additions and 10 deletions
Generated
+277
View File
@@ -2,6 +2,283 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "filecrypt-rs" name = "filecrypt-rs"
version = "0.1.0" 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",
]
+6
View File
@@ -4,3 +4,9 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
clap = { version = "4.6.7", features = ["derive"] }
getrandom = "0.4.3"
subtle = "2.6.1"
[dev-dependencies]
tempfile = "3.27.0"
+73
View File
@@ -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);
}
}
}
+102
View File
@@ -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<io::Error> for FileCryptError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
+71
View File
@@ -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<Path>,
output: impl AsRef<Path>,
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<Path>,
output: impl AsRef<Path>,
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(())
}
+63
View File
@@ -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<Self, FileCryptError> {
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()),
})
}
}
+28
View File
@@ -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()))
}
}
+167
View File
@@ -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<R: Read>(
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<R: Read>(reader: &mut R) -> io::Result<bool> {
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<R: Read, W: Write>(
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<R: Read, W: Write>(
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(())
}
+300
View File
@@ -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<u8> {
(0..n)
.map(|i| (i.wrapping_mul(31).wrapping_add(7)) as u8)
.collect()
}
fn enc(key: &[u8], plaintext: &[u8]) -> Vec<u8> {
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<Vec<u8>, FileCryptError> {
let mut out = Vec::new();
decrypt_stream(Cursor::new(data), &mut out, key)?;
Ok(out)
}
fn boundary_sizes() -> Vec<usize> {
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"");
}
}
+8 -8
View File
@@ -56,8 +56,8 @@ impl KeccakAead {
let mut input = [0u8; STATE_SIZE]; let mut input = [0u8; STATE_SIZE];
// r = chunk XOR state[0..16] // r = chunk XOR state[0..16]
for i in 0..chunk.len() { for (i, &byte) in chunk.iter().enumerate() {
input[i] = chunk[i] ^ self.state[i]; input[i] = byte ^ self.state[i];
} }
// c = state[16..40] // c = state[16..40]
@@ -78,8 +78,8 @@ impl KeccakAead {
let mut input = [0u8; STATE_SIZE]; let mut input = [0u8; STATE_SIZE];
// r = chunk XOR state[0..16] // r = chunk XOR state[0..16]
for i in 0..chunk.len() { for (i, &byte) in chunk.iter().enumerate() {
ciphertext.push(chunk[i] ^ self.state[i]); ciphertext.push(byte ^ self.state[i]);
} }
// input = r || c // input = r || c
@@ -105,8 +105,8 @@ impl KeccakAead {
let mut input = [0u8; STATE_SIZE]; let mut input = [0u8; STATE_SIZE];
// plaintext = ciphertext XOR state[0..16] // plaintext = ciphertext XOR state[0..16]
for i in 0..chunk.len() { for (i, &byte) in chunk.iter().enumerate() {
plaintext.push(chunk[i] ^ self.state[i]); plaintext.push(byte ^ self.state[i]);
} }
// input = ciphertext || c // input = ciphertext || c
@@ -137,8 +137,8 @@ impl KeccakAead {
padded_key[padding_len..].copy_from_slice(key); padded_key[padding_len..].copy_from_slice(key);
// XOR padded key into c. // XOR padded key into c.
for i in 0..CAPACITY_SIZE { for (i, &byte) in padded_key.iter().enumerate().take(CAPACITY_SIZE) {
self.state[RATE_SIZE + i] ^= padded_key[i]; self.state[RATE_SIZE + i] ^= byte;
} }
// SHAKE256(state, 40) // SHAKE256(state, 40)
+95 -2
View File
@@ -1,6 +1,99 @@
use std::error::Error;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
mod file_crypt;
mod keccak; mod keccak;
mod keccak_aead; mod keccak_aead;
fn main() { use file_crypt::{MAX_KEY_LEN, decrypt_file, encrypt_file};
println!("Hello, world!");
/// 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<dyn Error>> {
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(())
} }