feat!: add file-format header, configurable chunks, integration tests
Introduce a self-describing on-disk format and use it to address several
shortcomings of the 0.9 file layout, where the file simply began with a
raw 19-byte STREAM nonce prefix and used a hardcoded 64 KiB chunk size.
What changed for users
----------------------
* fcry files now start with a 16-byte header: magic ("fcry"), version,
algorithm id, flags, reserved byte, plaintext chunk_size (u32 LE),
KDF id + params, then the 19-byte nonce prefix. The full encoded
header is bound as AAD to every chunk, so tampering with chunk_size,
algorithm id, nonce prefix, or any future KDF parameter causes
authentication failure on every chunk -- not just the first.
* New `--chunk-size` CLI flag (encryption only). The decryptor reads
the chunk size from the header, so files encrypted with a non-default
size decrypt without the user having to remember it.
* Default plaintext chunk size raised from 64 KiB to 1 MiB.
* Bad input is now reported as an error instead of panicking: empty
ciphertext, truncated final chunk, wrong magic, bad version, zero
chunk_size, unknown algorithm id, and short --raw-key all return a
non-zero exit status with a diagnostic on stderr.
* Empty plaintext now produces a valid (authenticated) empty
ciphertext instead of panicking; the decryptor verifies it.
* `main` exits with status 1 on error (previously it printed and
returned 0).
This is a breaking change to the file format: 0.9.x files have no magic
or header and cannot be read by 0.10.x. Version bumped to 0.10.0.
Why this approach
-----------------
The header-as-AAD pattern is the standard way to make file-format
metadata tamper-evident without a separate signature: any bit-flip in
the header propagates into every chunk's authentication tag check, so
an attacker cannot, for example, change chunk_size to mis-frame the
stream or downgrade the algorithm id.
Storing chunk_size in the header (rather than fixing it at compile
time) lets us experiment with chunk sizes without breaking decrypt
compatibility, and is preparation for the parallel-pipeline work in
Roadmap 1.0 where worker count and chunk size interact.
The KDF section is a tagged variant (currently only `Raw`) so that
adding Argon2id later only adds a new variant + its salt/cost fields;
existing files keep decrypting because they carry `kdf_id = 0`.
Other changes bundled in
------------------------
* Switch RNG from `rand` (0.10) to `getrandom` (0.3). We only need
OS-provided random bytes for the nonce prefix; pulling in the full
`rand` crate for one `OsRng.fill_bytes` call was overkill, and
`rand` 0.10's `OsRng` API churn makes `getrandom` the cleaner fit.
* `FcryError` gains a `Format(String)` variant for header / framing
errors and a `From<getrandom::Error>` impl (replacing the
`rand::Error` impl).
* Drop the noisy `[reader]` / `[encrypt]` / `[decrypt]` stderr
tracing prints and the `dbg!(&cli.raw_key)` (which leaked the key
to stderr).
* Replace `unwrap()` on file open / create with `?` so I/O errors
surface as structured `FcryError::Io` instead of aborting.
* Remove the unused `AheadReader::read_exact` wrapper -- the
decryptor now reads the header through the underlying `BufRead`
directly before wrapping it in `AheadReader`.
Tests
-----
Add `tests/roundtrip.rs` (assert_cmd + tempfile) covering: empty
input, single byte, sub-chunk, exact chunk, chunk+1, multi-chunk,
custom small chunk size (4096), pathological 1-byte chunk size,
stdin/stdout pipe mode, wrong key rejection, tampered header,
tampered ciphertext, truncated ciphertext, bad magic, short raw key,
and the header-is-authoritative property (encrypt with a weird chunk
size, decrypt without specifying one). Also adds a unit test in
`header.rs` for header encode/decode roundtrip and bad-magic rejection.
TODO.md trimmed to the concrete follow-up sequence (manual STREAM
nonces, secrets/rlimit, atomic output, argon2id KDF + prompt,
multi-threaded pipeline, length-committed mode).
Test plan
---------
* `cargo clippy && cargo clippy --tests` -- clean.
* `cargo +nightly fmt` -- no diff.
* `cargo test` -- 16 integration + 2 header unit tests pass.
* Manual: `echo hi | fcry --raw-key 0123456789abcdef0123456789abcdef
| fcry -d --raw-key 0123456789abcdef0123456789abcdef` prints `hi`.
Trailers
--------
Refs: TODO.md (Roadmap 1.0 follow-up sequence)
Breaking-Change: file format; 0.9.x files cannot be decrypted by 0.10.x
This commit is contained in:
Generated
+127
-271
@@ -63,10 +63,19 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "assert_cmd"
|
||||||
version = "1.0.102"
|
version = "2.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
@@ -74,6 +83,17 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
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]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -88,18 +108,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -109,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"chacha20 0.9.1",
|
"chacha20",
|
||||||
"cipher",
|
"cipher",
|
||||||
"poly1305",
|
"poly1305",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -181,15 +190,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -197,30 +197,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core 0.6.4",
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "difflib"
|
||||||
version = "1.0.2"
|
version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fcry"
|
name = "errno"
|
||||||
version = "0.9.0"
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20poly1305",
|
"libc",
|
||||||
"clap",
|
"windows-sys",
|
||||||
"rand",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "fastrand"
|
||||||
version = "0.1.5"
|
version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
@@ -245,57 +257,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.3.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"rand_core 0.10.1",
|
|
||||||
"wasip2",
|
"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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -311,18 +288,6 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.186"
|
||||||
@@ -330,10 +295,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.29"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
@@ -341,6 +306,12 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -359,19 +330,36 @@ version = "0.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures",
|
||||||
"opaque-debug",
|
"opaque-debug",
|
||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "predicates"
|
||||||
version = "0.2.37"
|
version = "3.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"anstyle",
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
@@ -394,20 +382,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "5.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
@@ -419,16 +396,23 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "regex-automata"
|
||||||
version = "0.10.1"
|
version = "0.4.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "rustix"
|
||||||
version = "1.0.28"
|
version = "1.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
@@ -459,19 +443,6 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -495,6 +466,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@@ -507,12 +497,6 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-xid"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -535,6 +519,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen 0.57.1",
|
"wit-bindgen",
|
||||||
]
|
|
||||||
|
|
||||||
[[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",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -608,108 +558,14 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
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]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zmij"
|
|
||||||
version = "1.0.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
|
||||||
|
|||||||
+6
-2
@@ -2,12 +2,16 @@
|
|||||||
authors = ["ddidderr <ddidderr@paul.network>"]
|
authors = ["ddidderr <ddidderr@paul.network>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
name = "fcry"
|
name = "fcry"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chacha20poly1305 = {version = "0.10", features = ["stream"]}
|
chacha20poly1305 = {version = "0.10", features = ["stream"]}
|
||||||
clap = {version = "4", features = ["derive"]}
|
clap = {version = "4", features = ["derive"]}
|
||||||
rand = {version = "0.10"}
|
getrandom = {version = "0.3"}
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = false
|
lto = false
|
||||||
|
|||||||
@@ -1,28 +1,7 @@
|
|||||||
# Roadmap 1.0
|
**Deferred to follow-up commits** (in order):
|
||||||
## Summary
|
1. Switch single `EncryptorBE32` for manual STREAM nonces (preparation for parallelism)
|
||||||
Make the program real-world usable and stable.
|
2. `secrets` crate for key handling + `rlimit` to disable core dumps
|
||||||
|
3. Atomic file output (`.tmp` + rename)
|
||||||
## Knowledge and Design
|
4. `argon2id` KDF + passphrase prompt + CLI flags
|
||||||
* understand `encrypt_next_in_place()`'s first argument better
|
5. Multi-threaded pipeline (worker pool + ordered writer)
|
||||||
* current understanding:
|
6. Length-committed mode + random-access decrypt fast path for files
|
||||||
* 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
|
|
||||||
|
|||||||
+48
-45
@@ -1,57 +1,60 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-only
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, aead::stream};
|
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, aead::stream};
|
||||||
use rand::{RngCore, rngs::OsRng};
|
|
||||||
|
|
||||||
use crate::error::*;
|
use crate::error::*;
|
||||||
use crate::reader::ReadInfoChunk;
|
use crate::header::{AlgId, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN};
|
||||||
use crate::utils::BUFSIZE;
|
use crate::reader::{AheadReader, ReadInfoChunk};
|
||||||
use crate::utils::*;
|
use crate::utils::*;
|
||||||
|
|
||||||
pub fn encrypt<S: AsRef<str>>(
|
pub fn encrypt<S: AsRef<str>>(
|
||||||
input_file: Option<S>,
|
input_file: Option<S>,
|
||||||
output_file: Option<S>,
|
output_file: Option<S>,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
|
chunk_size: u32,
|
||||||
) -> Result<(), FcryError> {
|
) -> Result<(), FcryError> {
|
||||||
let mut f_plain = read_from_file_or_stdin(input_file, BUFSIZE);
|
let chunk_sz = chunk_size as usize;
|
||||||
let mut f_encrypted = write_to_file_or_stdout(output_file);
|
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];
|
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
||||||
OsRng.fill_bytes(&mut nonce);
|
getrandom::fill(&mut nonce_prefix)?;
|
||||||
|
|
||||||
// let key = XChaCha20Poly1305::generate_key(&mut OsRng);
|
let header = Header {
|
||||||
|
alg: AlgId::XChaCha20Poly1305,
|
||||||
f_encrypted.write_all(&nonce)?;
|
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 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 {
|
loop {
|
||||||
let read_result = f_plain.read_ahead(&mut buf)?;
|
match f_plain.read_ahead(&mut buf)? {
|
||||||
|
ReadInfoChunk::Normal(_) => {
|
||||||
match read_result {
|
stream_encryptor.encrypt_next_in_place(&aad, &mut buf)?;
|
||||||
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)?;
|
|
||||||
f_encrypted.write_all(&buf)?;
|
f_encrypted.write_all(&buf)?;
|
||||||
// buf grows after encrypt_next_in_place because of tag that is added
|
buf.truncate(chunk_sz);
|
||||||
// we shrink it to the BUFSIZE in order to read the correct size
|
|
||||||
buf.truncate(BUFSIZE);
|
|
||||||
}
|
}
|
||||||
ReadInfoChunk::Last(n) => {
|
ReadInfoChunk::Last(n) => {
|
||||||
eprintln!("[encrypt]: read last chunk");
|
|
||||||
buf.truncate(n);
|
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)?;
|
f_encrypted.write_all(&buf)?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ReadInfoChunk::Empty => {
|
ReadInfoChunk::Empty => {
|
||||||
eprintln!("[encrypt]: read empty chunk");
|
// Empty plaintext: still emit a final "last" tag so the decryptor
|
||||||
panic!("[ERROR] Empty Chunk while reading");
|
// 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<S: AsRef<str>>(
|
|||||||
output_file: Option<S>,
|
output_file: Option<S>,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
) -> Result<(), FcryError> {
|
) -> Result<(), FcryError> {
|
||||||
let mut f_encrypted = read_from_file_or_stdin(input_file, BUFSIZE + 16);
|
let mut reader = open_input(input_file)?;
|
||||||
let mut f_plain = write_to_file_or_stdout(output_file);
|
let header = Header::read(&mut reader)?;
|
||||||
|
let aad = header.encode();
|
||||||
|
|
||||||
let mut nonce = [0u8; 19];
|
let chunk_sz = header.chunk_size as usize;
|
||||||
f_encrypted.read_exact(&mut nonce)?;
|
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 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 {
|
loop {
|
||||||
let read_result = f_encrypted.read_ahead(&mut buf)?;
|
match f_encrypted.read_ahead(&mut buf)? {
|
||||||
|
ReadInfoChunk::Normal(_) => {
|
||||||
match read_result {
|
stream_decryptor.decrypt_next_in_place(&aad, &mut buf)?;
|
||||||
ReadInfoChunk::Normal(n) => {
|
|
||||||
assert_eq!(n, BUFSIZE + 16);
|
|
||||||
eprintln!("[decrypt]: read normal chunk");
|
|
||||||
stream_decryptor.decrypt_next_in_place(&[], &mut buf)?;
|
|
||||||
f_plain.write_all(&buf)?;
|
f_plain.write_all(&buf)?;
|
||||||
buf.resize(BUFSIZE + 16, 0);
|
buf.resize(cipher_chunk, 0);
|
||||||
}
|
}
|
||||||
ReadInfoChunk::Last(n) => {
|
ReadInfoChunk::Last(n) => {
|
||||||
eprintln!("[decrypt]: read last chunk");
|
|
||||||
buf.truncate(n);
|
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)?;
|
f_plain.write_all(&buf)?;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ReadInfoChunk::Empty => {
|
ReadInfoChunk::Empty => {
|
||||||
eprintln!("[decrypt]: read empty chunk");
|
return Err(FcryError::Format(
|
||||||
panic!("Empty Chunk while reading");
|
"truncated ciphertext: missing final chunk".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -8,7 +8,8 @@ use std::io;
|
|||||||
pub enum FcryError {
|
pub enum FcryError {
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
Crypto(aead::Error),
|
Crypto(aead::Error),
|
||||||
Rng(rand::Error),
|
Rng(getrandom::Error),
|
||||||
|
Format(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<io::Error> for FcryError {
|
impl From<io::Error> for FcryError {
|
||||||
@@ -23,8 +24,8 @@ impl From<aead::Error> for FcryError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<rand::Error> for FcryError {
|
impl From<getrandom::Error> for FcryError {
|
||||||
fn from(e: rand::Error) -> Self {
|
fn from(e: getrandom::Error) -> Self {
|
||||||
FcryError::Rng(e)
|
FcryError::Rng(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+186
@@ -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<Self, FcryError> {
|
||||||
|
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<u8>) {
|
||||||
|
match self {
|
||||||
|
Self::Raw => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_from(id: u8, _r: &mut impl Read) -> Result<Self, FcryError> {
|
||||||
|
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<u8> {
|
||||||
|
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<Self, FcryError> {
|
||||||
|
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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-10
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod header;
|
||||||
mod reader;
|
mod reader;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use crypto::*;
|
use crypto::*;
|
||||||
use error::FcryError;
|
use error::FcryError;
|
||||||
|
use utils::DEFAULT_CHUNK_SIZE;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
@@ -27,25 +29,31 @@ struct Cli {
|
|||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
output_file: Option<String>,
|
output_file: Option<String>,
|
||||||
|
|
||||||
/// The raw bytes of the crypto key.
|
/// The raw bytes of the crypto key. Has to be exactly 32 bytes.
|
||||||
/// Has to be exactly 32 bytes
|
/// *** DANGEROUS: visible in process listings (ps/proc). Testing only. ***
|
||||||
/// *** DANGEROUS, use for testing purposes only! ***
|
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
raw_key: String,
|
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> {
|
fn run(cli: Cli) -> Result<(), FcryError> {
|
||||||
let input_file = cli.input_file;
|
let raw = cli.raw_key.as_bytes();
|
||||||
let output_file = cli.output_file;
|
if raw.len() != 32 {
|
||||||
|
return Err(FcryError::Format(format!(
|
||||||
|
"raw_key must be exactly 32 bytes, got {}",
|
||||||
|
raw.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
dbg!(&cli.raw_key);
|
key.copy_from_slice(raw);
|
||||||
key.clone_from_slice(cli.raw_key.as_bytes());
|
|
||||||
|
|
||||||
if cli.decrypt {
|
if cli.decrypt {
|
||||||
decrypt(input_file, output_file, key)?
|
decrypt(cli.input_file, cli.output_file, key)?
|
||||||
} else {
|
} else {
|
||||||
encrypt(input_file, output_file, key)?
|
encrypt(cli.input_file, cli.output_file, key, cli.chunk_size)?
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -55,5 +63,6 @@ fn main() {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
if let Err(e) = run(cli) {
|
if let Err(e) = run(cli) {
|
||||||
eprintln!("Error: {:?}", e);
|
eprintln!("Error: {:?}", e);
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-10
@@ -4,7 +4,7 @@ use std::io;
|
|||||||
use std::io::{BufRead, Read};
|
use std::io::{BufRead, Read};
|
||||||
|
|
||||||
pub enum ReadInfoChunk {
|
pub enum ReadInfoChunk {
|
||||||
Normal(usize),
|
Normal(#[allow(dead_code)] usize),
|
||||||
Last(usize),
|
Last(usize),
|
||||||
Empty,
|
Empty,
|
||||||
}
|
}
|
||||||
@@ -47,21 +47,12 @@ impl AheadReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
|
pub fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
|
||||||
// 1st read
|
|
||||||
if self.bufsz == 0 {
|
if self.bufsz == 0 {
|
||||||
eprintln!("[reader] first read");
|
|
||||||
return self.first_read(userbuf);
|
return self.first_read(userbuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[reader] normal read");
|
|
||||||
// normal read (not the 1st one)
|
|
||||||
self.normal_read(userbuf)
|
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<ReadInfoChunk> {
|
fn first_read(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
|
||||||
// 1st read directly to userbuf (we have no cached data yet)
|
// 1st read directly to userbuf (we have no cached data yet)
|
||||||
let n = self.read_until_full(userbuf)?;
|
let n = self.read_until_full(userbuf)?;
|
||||||
|
|||||||
+16
-23
@@ -1,31 +1,24 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-only
|
// 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;
|
/// Default plaintext chunk size: 1 MiB.
|
||||||
use std::{
|
///
|
||||||
fs::File,
|
/// Stored in the header per file, so callers may override via CLI without
|
||||||
io::{self, Write},
|
/// 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 open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Box<dyn BufRead>> {
|
||||||
|
Ok(match input_file {
|
||||||
pub(crate) fn read_from_file_or_stdin<S: AsRef<str>>(
|
Some(f) => Box::new(BufReader::new(File::open(f.as_ref())?)),
|
||||||
input_file: Option<S>,
|
None => Box::new(io::stdin().lock()),
|
||||||
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 write_to_file_or_stdout<S: AsRef<str>>(output_file: Option<S>) -> Box<dyn Write> {
|
pub(crate) fn open_output<S: AsRef<str>>(output_file: Option<S>) -> io::Result<Box<dyn Write>> {
|
||||||
match output_file {
|
Ok(match output_file {
|
||||||
Some(f) => Box::new(File::create(f.as_ref()).unwrap()),
|
Some(f) => Box::new(File::create(f.as_ref())?),
|
||||||
None => Box::new(io::stdout()),
|
None => Box::new(io::stdout()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<u8> {
|
||||||
|
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<u32>) {
|
||||||
|
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<u32>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user