encrypt decrypt file, clap command line
This commit is contained in:
1 parent
44fe608942
commit
cbce59293d
11 files changed
+1190
-10
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+95
-2
@@ -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<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(())
|
||||
}
|
||||
Reference in new issue
Block a user