diff --git a/Cargo.lock b/Cargo.lock index ae5f4eb..06dd106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,10 +63,19 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.102" +name = "assert_cmd" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] [[package]] name = "bitflags" @@ -74,6 +83,17 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -88,18 +108,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", ] [[package]] @@ -109,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", "zeroize", @@ -181,15 +190,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -197,30 +197,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "difflib" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] -name = "fcry" -version = "0.9.0" +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "chacha20poly1305", - "clap", - "rand", + "libc", + "windows-sys", ] [[package]] -name = "foldhash" -version = "0.1.5" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fcry" +version = "0.10.0" +dependencies = [ + "assert_cmd", + "chacha20poly1305", + "clap", + "getrandom 0.3.4", + "tempfile", +] [[package]] name = "generic-array" @@ -245,57 +257,22 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", "wasip2", - "wasip3", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - [[package]] name = "inout" version = "0.1.4" @@ -311,18 +288,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -330,10 +295,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "log" -version = "0.4.29" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "memchr" @@ -341,6 +306,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[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" @@ -359,19 +330,36 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", "opaque-debug", "universal-hash", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "predicates" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ - "proc-macro2", - "syn", + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", ] [[package]] @@ -394,20 +382,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "6.0.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", - "rand_core 0.10.1", -] +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand_core" @@ -419,16 +396,23 @@ dependencies = [ ] [[package]] -name = "rand_core" -version = "0.10.1" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" [[package]] -name = "semver" -version = "1.0.28" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] [[package]] name = "serde" @@ -459,19 +443,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - [[package]] name = "strsim" version = "0.11.1" @@ -495,6 +466,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "typenum" version = "1.20.0" @@ -507,12 +497,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -535,6 +519,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -547,50 +540,7 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", + "wit-bindgen", ] [[package]] @@ -608,108 +558,14 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2b69506..dcaf66c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,16 @@ authors = ["ddidderr "] edition = "2024" name = "fcry" -version = "0.9.0" +version = "0.10.0" [dependencies] chacha20poly1305 = {version = "0.10", features = ["stream"]} clap = {version = "4", features = ["derive"]} -rand = {version = "0.10"} +getrandom = {version = "0.3"} + +[dev-dependencies] +assert_cmd = "2" +tempfile = "3" [profile.release] lto = false diff --git a/TODO.md b/TODO.md index 163ac61..d16f62e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,28 +1,7 @@ -# Roadmap 1.0 -## Summary -Make the program real-world usable and stable. - -## Knowledge and Design -* understand `encrypt_next_in_place()`'s first argument better - * current understanding: - * associated data is used for parts of the data that cannot be - encrypted but should also be integrity protected by the authentication tag - * since there are no parts that cannot be encrypted in the context of `fcry` it is correct - to pass an empty slice to the first argument of `encrypt_next_in_place()` -* currently `fcry` uses 64 KiB blocks as single AEAD messages - * as stated [here](https://pycryptodome.readthedocs.io/en/latest/src/cipher/chacha20_poly1305.html) (limit of 13 billion messages) would imply a maximum file-size of `64 KiB * 13e9 = 832e9 KiB = 774.86 TiB`. While a file this size could be considered a special (and unsupported) use case anyway, performance is also a consideration. Does performance improve noticably with larger message sizes? -* unit tests - -## Features -* password hashing - * configurable algorithm (sane default) - * configurable nr of rounds (sand default) - * a way to enter the password securely in a prompt while still being able to handle `stdin` data -* add usage examples to README.md - -# Roadmap 2.0 -* parallel processing: use all available (or configurable) CPU cores - -# Roadmap later or never -* split into `lib` and `bin` -* other AEAD algorithms \ No newline at end of file +**Deferred to follow-up commits** (in order): +1. Switch single `EncryptorBE32` for manual STREAM nonces (preparation for parallelism) +2. `secrets` crate for key handling + `rlimit` to disable core dumps +3. Atomic file output (`.tmp` + rename) +4. `argon2id` KDF + passphrase prompt + CLI flags +5. Multi-threaded pipeline (worker pool + ordered writer) +6. Length-committed mode + random-access decrypt fast path for files diff --git a/src/crypto.rs b/src/crypto.rs index 8569a33..a757c1a 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,57 +1,60 @@ // SPDX-License-Identifier: GPL-3.0-only use chacha20poly1305::{KeyInit, XChaCha20Poly1305, aead::stream}; -use rand::{RngCore, rngs::OsRng}; use crate::error::*; -use crate::reader::ReadInfoChunk; -use crate::utils::BUFSIZE; +use crate::header::{AlgId, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN}; +use crate::reader::{AheadReader, ReadInfoChunk}; use crate::utils::*; pub fn encrypt>( input_file: Option, output_file: Option, key: [u8; 32], + chunk_size: u32, ) -> Result<(), FcryError> { - let mut f_plain = read_from_file_or_stdin(input_file, BUFSIZE); - let mut f_encrypted = write_to_file_or_stdout(output_file); + let chunk_sz = chunk_size as usize; + let mut f_plain = AheadReader::from(open_input(input_file)?, chunk_sz); + let mut f_encrypted = open_output(output_file)?; - let mut nonce = [0u8; 19]; - OsRng.fill_bytes(&mut nonce); + let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; + getrandom::fill(&mut nonce_prefix)?; - // let key = XChaCha20Poly1305::generate_key(&mut OsRng); - - f_encrypted.write_all(&nonce)?; + let header = Header { + alg: AlgId::XChaCha20Poly1305, + flags: 0, + chunk_size, + kdf: KdfParams::Raw, + nonce_prefix, + }; + let aad = header.encode(); + f_encrypted.write_all(&aad)?; let aead = XChaCha20Poly1305::new(&key.into()); - let mut stream_encryptor = stream::EncryptorBE32::from_aead(aead, &nonce.into()); + let mut stream_encryptor = stream::EncryptorBE32::from_aead(aead, &nonce_prefix.into()); - let mut buf = vec![0; BUFSIZE]; + let mut buf = vec![0u8; chunk_sz]; loop { - let read_result = f_plain.read_ahead(&mut buf)?; - - match read_result { - ReadInfoChunk::Normal(n) => { - assert_eq!(n, BUFSIZE); - assert_eq!(buf.len(), BUFSIZE); - eprintln!("[encrypt]: read normal chunk"); - stream_encryptor.encrypt_next_in_place(&[], &mut buf)?; + match f_plain.read_ahead(&mut buf)? { + ReadInfoChunk::Normal(_) => { + stream_encryptor.encrypt_next_in_place(&aad, &mut buf)?; f_encrypted.write_all(&buf)?; - // buf grows after encrypt_next_in_place because of tag that is added - // we shrink it to the BUFSIZE in order to read the correct size - buf.truncate(BUFSIZE); + buf.truncate(chunk_sz); } ReadInfoChunk::Last(n) => { - eprintln!("[encrypt]: read last chunk"); buf.truncate(n); - stream_encryptor.encrypt_last_in_place(&[], &mut buf)?; + stream_encryptor.encrypt_last_in_place(&aad, &mut buf)?; f_encrypted.write_all(&buf)?; break; } ReadInfoChunk::Empty => { - eprintln!("[encrypt]: read empty chunk"); - panic!("[ERROR] Empty Chunk while reading"); + // Empty plaintext: still emit a final "last" tag so the decryptor + // authenticates the (empty) stream rather than silently producing nothing. + buf.clear(); + stream_encryptor.encrypt_last_in_place(&aad, &mut buf)?; + f_encrypted.write_all(&buf)?; + break; } } } @@ -64,38 +67,38 @@ pub fn decrypt>( output_file: Option, key: [u8; 32], ) -> Result<(), FcryError> { - let mut f_encrypted = read_from_file_or_stdin(input_file, BUFSIZE + 16); - let mut f_plain = write_to_file_or_stdout(output_file); + let mut reader = open_input(input_file)?; + let header = Header::read(&mut reader)?; + let aad = header.encode(); - let mut nonce = [0u8; 19]; - f_encrypted.read_exact(&mut nonce)?; + let chunk_sz = header.chunk_size as usize; + let cipher_chunk = chunk_sz + TAG_LEN; + + let mut f_encrypted = AheadReader::from(reader, cipher_chunk); + let mut f_plain = open_output(output_file)?; let aead = XChaCha20Poly1305::new(&key.into()); - let mut stream_decryptor = stream::DecryptorBE32::from_aead(aead, &nonce.into()); + let mut stream_decryptor = stream::DecryptorBE32::from_aead(aead, &header.nonce_prefix.into()); - let mut buf = vec![0; BUFSIZE + 16]; + let mut buf = vec![0u8; cipher_chunk]; loop { - let read_result = f_encrypted.read_ahead(&mut buf)?; - - match read_result { - ReadInfoChunk::Normal(n) => { - assert_eq!(n, BUFSIZE + 16); - eprintln!("[decrypt]: read normal chunk"); - stream_decryptor.decrypt_next_in_place(&[], &mut buf)?; + match f_encrypted.read_ahead(&mut buf)? { + ReadInfoChunk::Normal(_) => { + stream_decryptor.decrypt_next_in_place(&aad, &mut buf)?; f_plain.write_all(&buf)?; - buf.resize(BUFSIZE + 16, 0); + buf.resize(cipher_chunk, 0); } ReadInfoChunk::Last(n) => { - eprintln!("[decrypt]: read last chunk"); buf.truncate(n); - stream_decryptor.decrypt_last_in_place(&[], &mut buf)?; + stream_decryptor.decrypt_last_in_place(&aad, &mut buf)?; f_plain.write_all(&buf)?; break; } ReadInfoChunk::Empty => { - eprintln!("[decrypt]: read empty chunk"); - panic!("Empty Chunk while reading"); + return Err(FcryError::Format( + "truncated ciphertext: missing final chunk".into(), + )); } } } diff --git a/src/error.rs b/src/error.rs index 9b93dcd..574f5cb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,7 +8,8 @@ use std::io; pub enum FcryError { Io(io::Error), Crypto(aead::Error), - Rng(rand::Error), + Rng(getrandom::Error), + Format(String), } impl From for FcryError { @@ -23,8 +24,8 @@ impl From for FcryError { } } -impl From for FcryError { - fn from(e: rand::Error) -> Self { +impl From for FcryError { + fn from(e: getrandom::Error) -> Self { FcryError::Rng(e) } } diff --git a/src/header.rs b/src/header.rs new file mode 100644 index 0000000..16b9d64 --- /dev/null +++ b/src/header.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! On-disk file format for fcry. +//! +//! Layout: +//! ```text +//! magic "fcry" 4 bytes +//! version u8 1 +//! alg_id u8 1 +//! flags u8 1 +//! reserved u8 1 (must be 0) +//! chunk_size u32 LE 4 (plaintext bytes per chunk) +//! kdf_id u8 1 +//! kdf_params variable (depends on kdf_id) +//! nonce_prefix [u8; 19] 19 (STREAM nonce prefix) +//! --- end of header --- +//! chunk[0..N] each chunk_size + 16 bytes, +//! last may be shorter +//! ``` +//! +//! The full encoded header is fed as AAD to every chunk, so any tampering +//! with chunk_size, nonce_prefix, kdf params, etc. causes authentication +//! failure on every chunk. + +use std::io::Read; + +use crate::error::FcryError; + +const MAGIC: [u8; 4] = *b"fcry"; +const VERSION: u8 = 1; + +pub const NONCE_PREFIX_LEN: usize = 19; +pub const TAG_LEN: usize = 16; + +#[repr(u8)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum AlgId { + XChaCha20Poly1305 = 1, +} + +impl AlgId { + fn from_u8(v: u8) -> Result { + match v { + 1 => Ok(Self::XChaCha20Poly1305), + _ => Err(FcryError::Format(format!("unknown alg id: {v}"))), + } + } +} + +/// Key-derivation parameters stored in the header. +/// +/// `Raw` means the key was supplied directly (no KDF). Future variants +/// (e.g. Argon2id) will carry their salt + cost parameters here. +#[derive(Clone, Debug)] +pub enum KdfParams { + Raw, +} + +impl KdfParams { + fn id(&self) -> u8 { + match self { + Self::Raw => 0, + } + } + + fn write_into(&self, _out: &mut Vec) { + match self { + Self::Raw => {} + } + } + + fn read_from(id: u8, _r: &mut impl Read) -> Result { + match id { + 0 => Ok(Self::Raw), + _ => Err(FcryError::Format(format!("unknown kdf id: {id}"))), + } + } +} + +#[derive(Clone, Debug)] +pub struct Header { + pub alg: AlgId, + pub flags: u8, + pub chunk_size: u32, + pub kdf: KdfParams, + pub nonce_prefix: [u8; NONCE_PREFIX_LEN], +} + +impl Header { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(64); + out.extend_from_slice(&MAGIC); + out.push(VERSION); + out.push(self.alg as u8); + out.push(self.flags); + out.push(0); // reserved + out.extend_from_slice(&self.chunk_size.to_le_bytes()); + out.push(self.kdf.id()); + self.kdf.write_into(&mut out); + out.extend_from_slice(&self.nonce_prefix); + out + } + + pub fn read(r: &mut impl Read) -> Result { + let mut magic = [0u8; 4]; + r.read_exact(&mut magic)?; + if magic != MAGIC { + return Err(FcryError::Format("not an fcry file (bad magic)".into())); + } + + let mut fixed = [0u8; 4]; + r.read_exact(&mut fixed)?; + let [version, alg_id, flags, reserved] = fixed; + if version != VERSION { + return Err(FcryError::Format(format!("unsupported version: {version}"))); + } + if reserved != 0 { + return Err(FcryError::Format("reserved byte must be zero".into())); + } + let alg = AlgId::from_u8(alg_id)?; + + let mut chunk_size_bytes = [0u8; 4]; + r.read_exact(&mut chunk_size_bytes)?; + let chunk_size = u32::from_le_bytes(chunk_size_bytes); + if chunk_size == 0 { + return Err(FcryError::Format("chunk_size must be > 0".into())); + } + + let mut kdf_id = [0u8; 1]; + r.read_exact(&mut kdf_id)?; + let kdf = KdfParams::read_from(kdf_id[0], r)?; + + let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN]; + r.read_exact(&mut nonce_prefix)?; + + Ok(Self { + alg, + flags, + chunk_size, + kdf, + nonce_prefix, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn roundtrip() { + let h = Header { + alg: AlgId::XChaCha20Poly1305, + flags: 0, + chunk_size: 1024 * 1024, + kdf: KdfParams::Raw, + nonce_prefix: [7u8; NONCE_PREFIX_LEN], + }; + let bytes = h.encode(); + let mut cur = Cursor::new(&bytes); + let parsed = Header::read(&mut cur).unwrap(); + assert_eq!(parsed.alg, h.alg); + assert_eq!(parsed.flags, h.flags); + assert_eq!(parsed.chunk_size, h.chunk_size); + assert_eq!(parsed.nonce_prefix, h.nonce_prefix); + assert_eq!(cur.position() as usize, bytes.len()); + } + + #[test] + fn rejects_bad_magic() { + let mut bytes = Header { + alg: AlgId::XChaCha20Poly1305, + flags: 0, + chunk_size: 4096, + kdf: KdfParams::Raw, + nonce_prefix: [0u8; NONCE_PREFIX_LEN], + } + .encode(); + bytes[0] ^= 1; + assert!(matches!( + Header::read(&mut Cursor::new(&bytes)), + Err(FcryError::Format(_)) + )); + } +} diff --git a/src/main.rs b/src/main.rs index d828831..b5f7a64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,13 @@ mod crypto; mod error; +mod header; mod reader; mod utils; use crypto::*; use error::FcryError; +use utils::DEFAULT_CHUNK_SIZE; use clap::Parser; @@ -27,25 +29,31 @@ struct Cli { #[clap(short, long)] output_file: Option, - /// The raw bytes of the crypto key. - /// Has to be exactly 32 bytes - /// *** DANGEROUS, use for testing purposes only! *** + /// The raw bytes of the crypto key. Has to be exactly 32 bytes. + /// *** DANGEROUS: visible in process listings (ps/proc). Testing only. *** #[clap(short, long)] raw_key: String, + + /// Plaintext chunk size in bytes (encryption only; decryption reads it from the header). + #[clap(long, default_value_t = DEFAULT_CHUNK_SIZE)] + chunk_size: u32, } fn run(cli: Cli) -> Result<(), FcryError> { - let input_file = cli.input_file; - let output_file = cli.output_file; - + let raw = cli.raw_key.as_bytes(); + if raw.len() != 32 { + return Err(FcryError::Format(format!( + "raw_key must be exactly 32 bytes, got {}", + raw.len() + ))); + } let mut key = [0u8; 32]; - dbg!(&cli.raw_key); - key.clone_from_slice(cli.raw_key.as_bytes()); + key.copy_from_slice(raw); if cli.decrypt { - decrypt(input_file, output_file, key)? + decrypt(cli.input_file, cli.output_file, key)? } else { - encrypt(input_file, output_file, key)? + encrypt(cli.input_file, cli.output_file, key, cli.chunk_size)? } Ok(()) @@ -55,5 +63,6 @@ fn main() { let cli = Cli::parse(); if let Err(e) = run(cli) { eprintln!("Error: {:?}", e); + std::process::exit(1); } } diff --git a/src/reader.rs b/src/reader.rs index b3aac78..0531ada 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -4,7 +4,7 @@ use std::io; use std::io::{BufRead, Read}; pub enum ReadInfoChunk { - Normal(usize), + Normal(#[allow(dead_code)] usize), Last(usize), Empty, } @@ -47,21 +47,12 @@ impl AheadReader { } pub fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result { - // 1st read if self.bufsz == 0 { - eprintln!("[reader] first read"); return self.first_read(userbuf); } - - eprintln!("[reader] normal read"); - // normal read (not the 1st one) self.normal_read(userbuf) } - pub fn read_exact(&mut self, userbuf: &mut [u8]) -> io::Result<()> { - self.inner.read_exact(userbuf) - } - fn first_read(&mut self, userbuf: &mut [u8]) -> io::Result { // 1st read directly to userbuf (we have no cached data yet) let n = self.read_until_full(userbuf)?; diff --git a/src/utils.rs b/src/utils.rs index 317c1bd..c9b78e7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,31 +1,24 @@ // SPDX-License-Identifier: GPL-3.0-only -use crate::reader::AheadReader; +use std::fs::File; +use std::io::{self, BufRead, BufReader, Write}; -use std::io::BufReader; -use std::{ - fs::File, - io::{self, Write}, -}; +/// Default plaintext chunk size: 1 MiB. +/// +/// Stored in the header per file, so callers may override via CLI without +/// breaking older files (the decryptor reads the size from the header). +pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024; -pub const BUFSIZE: usize = 64 * 1024; // 64 KiB - -pub(crate) fn read_from_file_or_stdin>( - input_file: Option, - bufsz: usize, -) -> AheadReader { - match input_file { - Some(f) => AheadReader::from( - Box::new(BufReader::new(File::open(f.as_ref()).unwrap())), - bufsz, - ), - None => AheadReader::from(Box::new(io::stdin().lock()), bufsz), - } +pub(crate) fn open_input>(input_file: Option) -> io::Result> { + Ok(match input_file { + Some(f) => Box::new(BufReader::new(File::open(f.as_ref())?)), + None => Box::new(io::stdin().lock()), + }) } -pub(crate) fn write_to_file_or_stdout>(output_file: Option) -> Box { - match output_file { - Some(f) => Box::new(File::create(f.as_ref()).unwrap()), +pub(crate) fn open_output>(output_file: Option) -> io::Result> { + Ok(match output_file { + Some(f) => Box::new(File::create(f.as_ref())?), None => Box::new(io::stdout()), - } + }) } diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..366079f --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: GPL-3.0-only +// +// Integration tests for the `fcry` binary. +// +// These exercise the CLI as a black box: encrypt then decrypt and check that +// plaintext bytes are preserved, plus a handful of failure cases (tampering, +// wrong key, truncation, bad magic). + +use std::fs; +use std::io::Write; +use std::process::{Command, Stdio}; + +use assert_cmd::cargo::CommandCargoExt; +use tempfile::TempDir; + +const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef"; +const KEY_STR: &str = "0123456789abcdef0123456789abcdef"; + +fn fcry() -> Command { + Command::cargo_bin("fcry").unwrap() +} + +/// Deterministic pseudo-random plaintext of `n` bytes (xorshift, seedable). +/// We avoid `/dev/urandom` so tests are reproducible on failure. +fn pseudo_random(seed: u64, n: usize) -> Vec { + let mut s = seed.wrapping_add(0x9E3779B97F4A7C15); + let mut out = Vec::with_capacity(n); + while out.len() < n { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + out.extend_from_slice(&s.to_le_bytes()); + } + out.truncate(n); + out +} + +fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Option) { + let mut cmd = fcry(); + cmd.arg("-i") + .arg(plain) + .arg("-o") + .arg(ct) + .arg("--raw-key") + .arg(KEY_STR); + if let Some(cs) = chunk_size { + cmd.arg("--chunk-size").arg(cs.to_string()); + } + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "encrypt failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn decrypt_file(ct: &std::path::Path, rt: &std::path::Path) { + let out = fcry() + .arg("-d") + .arg("-i") + .arg(ct) + .arg("-o") + .arg(rt) + .arg("--raw-key") + .arg(KEY_STR) + .output() + .unwrap(); + assert!( + out.status.success(), + "decrypt failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn roundtrip_with_size(plaintext_size: usize, chunk_size: Option) { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("plain.bin"); + let ct = dir.path().join("ct.bin"); + let rt = dir.path().join("rt.bin"); + + let data = pseudo_random(plaintext_size as u64, plaintext_size); + fs::write(&plain, &data).unwrap(); + + encrypt_file(&plain, &ct, chunk_size); + decrypt_file(&ct, &rt); + + let got = fs::read(&rt).unwrap(); + assert_eq!(got, data, "roundtrip mismatch at size {plaintext_size}"); +} + +#[test] +fn roundtrip_empty() { + roundtrip_with_size(0, None); +} + +#[test] +fn roundtrip_one_byte() { + roundtrip_with_size(1, None); +} + +#[test] +fn roundtrip_smaller_than_chunk() { + roundtrip_with_size(100, None); +} + +#[test] +fn roundtrip_exactly_one_chunk() { + roundtrip_with_size(1024 * 1024, None); +} + +#[test] +fn roundtrip_just_over_one_chunk() { + roundtrip_with_size(1024 * 1024 + 1, None); +} + +#[test] +fn roundtrip_multi_chunk() { + roundtrip_with_size(5 * 1024 * 1024 + 12345, None); +} + +#[test] +fn roundtrip_custom_small_chunk_size() { + // forces many chunks for a small input + roundtrip_with_size(50_000, Some(4096)); +} + +#[test] +fn roundtrip_chunk_size_one_byte() { + // pathological but should still work + roundtrip_with_size(257, Some(1)); +} + +#[test] +fn roundtrip_pipe_stdin_stdout() { + let data = pseudo_random(42, 200_000); + + let mut enc = fcry() + .arg("--raw-key") + .arg(KEY_STR) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + enc.stdin.as_mut().unwrap().write_all(&data).unwrap(); + let enc_out = enc.wait_with_output().unwrap(); + assert!( + enc_out.status.success(), + "pipe encrypt failed: {}", + String::from_utf8_lossy(&enc_out.stderr) + ); + + let mut dec = fcry() + .arg("-d") + .arg("--raw-key") + .arg(KEY_STR) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + dec.stdin + .as_mut() + .unwrap() + .write_all(&enc_out.stdout) + .unwrap(); + let dec_out = dec.wait_with_output().unwrap(); + assert!( + dec_out.status.success(), + "pipe decrypt failed: {}", + String::from_utf8_lossy(&dec_out.stderr) + ); + + assert_eq!(dec_out.stdout, data); +} + +#[test] +fn rejects_wrong_key() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, pseudo_random(1, 1000)).unwrap(); + encrypt_file(&plain, &ct, None); + + let wrong = "ffffffffffffffffffffffffffffffff"; + assert_ne!(wrong.as_bytes(), KEY); + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(dir.path().join("rt.bin")) + .arg("--raw-key") + .arg(wrong) + .output() + .unwrap(); + assert!(!out.status.success(), "decrypt with wrong key should fail"); +} + +#[test] +fn rejects_tampered_header() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, pseudo_random(2, 1000)).unwrap(); + encrypt_file(&plain, &ct, None); + + // Flip a byte in the chunk_size field of the header (offset 8: 4 magic + 4 fixed). + let mut bytes = fs::read(&ct).unwrap(); + bytes[8] ^= 0xff; + fs::write(&ct, &bytes).unwrap(); + + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(dir.path().join("rt.bin")) + .arg("--raw-key") + .arg(KEY_STR) + .output() + .unwrap(); + assert!( + !out.status.success(), + "decrypt with tampered header should fail" + ); +} + +#[test] +fn rejects_tampered_ciphertext() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, pseudo_random(3, 5000)).unwrap(); + encrypt_file(&plain, &ct, None); + + // Flip a byte well past the header (in the first ciphertext chunk). + let mut bytes = fs::read(&ct).unwrap(); + let off = bytes.len() / 2; + bytes[off] ^= 0x01; + fs::write(&ct, &bytes).unwrap(); + + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(dir.path().join("rt.bin")) + .arg("--raw-key") + .arg(KEY_STR) + .output() + .unwrap(); + assert!( + !out.status.success(), + "decrypt of tampered ciphertext should fail" + ); +} + +#[test] +fn rejects_truncated_ciphertext() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + fs::write(&plain, pseudo_random(4, 3 * 1024 * 1024)).unwrap(); + encrypt_file(&plain, &ct, None); + + // Drop the trailing 16-byte tag of the last chunk (and then some). + let mut bytes = fs::read(&ct).unwrap(); + bytes.truncate(bytes.len() - 32); + fs::write(&ct, &bytes).unwrap(); + + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&ct) + .arg("-o") + .arg(dir.path().join("rt.bin")) + .arg("--raw-key") + .arg(KEY_STR) + .output() + .unwrap(); + assert!( + !out.status.success(), + "decrypt of truncated ciphertext should fail" + ); +} + +#[test] +fn rejects_bad_magic() { + let dir = TempDir::new().unwrap(); + let bogus = dir.path().join("bogus.bin"); + fs::write(&bogus, b"NOPE\x01\x01\x00\x00\x00\x10\x00\x00\x00").unwrap(); + let out = fcry() + .arg("-d") + .arg("-i") + .arg(&bogus) + .arg("-o") + .arg(dir.path().join("rt.bin")) + .arg("--raw-key") + .arg(KEY_STR) + .output() + .unwrap(); + assert!( + !out.status.success(), + "decrypt of file with bad magic should fail" + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("magic"), + "expected 'magic' in stderr, got: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn rejects_short_raw_key() { + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + fs::write(&plain, b"hello").unwrap(); + let out = fcry() + .arg("-i") + .arg(&plain) + .arg("-o") + .arg(dir.path().join("c.bin")) + .arg("--raw-key") + .arg("tooshort") + .output() + .unwrap(); + assert!( + !out.status.success(), + "encrypt with short raw_key should fail" + ); +} + +#[test] +fn header_chunk_size_is_authoritative_on_decrypt() { + // Encrypt with a non-default chunk size; decrypt without specifying one. + // The decryptor must read chunk_size from the header. + let dir = TempDir::new().unwrap(); + let plain = dir.path().join("p.bin"); + let ct = dir.path().join("c.bin"); + let rt = dir.path().join("r.bin"); + let data = pseudo_random(5, 100_000); + fs::write(&plain, &data).unwrap(); + encrypt_file(&plain, &ct, Some(7919)); // prime, deliberately weird + decrypt_file(&ct, &rt); + assert_eq!(fs::read(&rt).unwrap(), data); +}