encrypt decrypt file, clap command line
This commit is contained in:
Generated
+277
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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];
|
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
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user