Compare commits
9 Commits
acd2712ade
...
v0.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
d79e96c498
|
|||
|
99705afa9e
|
|||
|
3f53c221c8
|
|||
|
725d33939e
|
|||
|
81ac1475ad
|
|||
|
d7b0127d20
|
|||
|
67b412a1a5
|
|||
|
6898297973
|
|||
|
45571c98fe
|
Generated
+129
-21
@@ -76,15 +76,27 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_cmd"
|
||||
version = "2.2.1"
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "assert_cmd"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"bstr",
|
||||
@@ -103,9 +115,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
@@ -116,6 +128,20 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -136,6 +162,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -150,7 +186,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -223,6 +259,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -232,6 +274,15 @@ 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 = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@@ -299,22 +350,31 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fcry"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"assert_cmd",
|
||||
"blake3",
|
||||
"chacha20poly1305",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"getrandom 0.4.2",
|
||||
"libc",
|
||||
"rlimit",
|
||||
"same-file",
|
||||
"secrets",
|
||||
"tempfile",
|
||||
"unicode-normalization",
|
||||
"windows-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -366,9 +426,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -389,7 +449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -435,15 +495,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
@@ -496,7 +556,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -599,6 +659,15 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "1.3.0"
|
||||
@@ -648,9 +717,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -659,6 +728,12 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -702,10 +777,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
@@ -713,6 +803,15 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -830,6 +929,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
||||
+17
-12
@@ -1,8 +1,7 @@
|
||||
[package]
|
||||
authors = ["ddidderr <ddidderr@paul.network>"]
|
||||
edition = "2024"
|
||||
name = "fcry"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5"
|
||||
@@ -10,28 +9,34 @@ chacha20poly1305 = "0.10"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
crossbeam-channel = "0.5"
|
||||
getrandom = { version = "0.4" }
|
||||
blake3 = "1"
|
||||
protected-secrets = { package = "secrets", version = "1.3" }
|
||||
same-file = "1"
|
||||
unicode-normalization = "0.1"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
rlimit = "0.11"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = {version = "0.61", features = [
|
||||
"Win32_System_Console",
|
||||
windows-sys = {
|
||||
version = "0.61",
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_Security",
|
||||
]}
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
tempfile = "3"
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Console",
|
||||
]
|
||||
}
|
||||
|
||||
[profile.release]
|
||||
lto = false
|
||||
debug = false
|
||||
strip = true
|
||||
lto = true
|
||||
panic = "unwind"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
I read every file, traced the STREAM construction, the parallel pipeline's ordering/cancellation logic, and the I/O paths, and cross-checked the existing `REVIEW_SECURITY_GPT5.5.md`. Verdict first: **the cryptographic core is correct.** The problems are in input validation and file handling, and two of them can destroy user data without any attacker involved.
|
||||
|
||||
## What's cryptographically sound (verified, not assumed)
|
||||
|
||||
The construction is the STREAM online-AE scheme (Hoang–Reyhanitabar–Rogaway–Vizár), the same shape age and Tink use: XChaCha20-Poly1305, 19-byte random prefix + 4-byte BE counter + last-block byte = 24-byte nonce, full header as AAD on every chunk. I checked the failure modes individually:
|
||||
|
||||
- **Truncation** at a chunk boundary: the new final chunk was encrypted with `last=false`, decryptor computes the nonce with `last=true` → tag failure. Mid-chunk truncation and appended garbage also fail (a trailing partial read flips the real last chunk to `Normal`, wrong nonce again).
|
||||
- **Reorder/duplicate/drop** of interior chunks: counter is in the nonce → fails.
|
||||
- **Cross-file chunk splicing** under the same key: per-file random 19-byte prefix plus per-file header AAD → fails.
|
||||
- **Header tampering** (chunk_size, KDF params, version, flags, length): header is the AAD → every chunk fails. The v1 regression test shows you understood the encode-stability trap; `version` is preserved as-read, so no downgrade is possible.
|
||||
- **Empty plaintext** is authenticated via an explicit empty last chunk; a ciphertext that is only a header errors instead of silently producing an empty file.
|
||||
- **Counter overflow** is checked (`bump_counter`), nonce prefix collision across files is 2⁻¹⁵² territory, `getrandom` failure propagates instead of falling back.
|
||||
- **Parallel pipeline**: I traced the permit/reorder/cancel logic. The writer only writes in counter order, a failed chunk leaves `pending` non-empty → error, and `commit()` is only reachable after all three thread groups joined with no error. There is no path where a tampered chunk ends up in a committed file output.
|
||||
|
||||
The README's worry that it "could theoretically be not effective at all" is unfounded for the construction itself.
|
||||
|
||||
## New findings (not in the GPT-5.5 review)
|
||||
|
||||
**1. High — `--chunk-size 0` with stdin silently discards all data.**
|
||||
There is no encrypt-side validation of `chunk_size`. With `0`, `AheadReader` gets capacity 0, `read_until_full` on an empty slice returns `Ok(0)` immediately (BufReader fills its internal buffer, then copies 0 bytes), so the very first `read_ahead` returns `Empty`. `encrypt` then writes a single authenticated empty last chunk and **commits successfully**. For a regular file input, the `plaintext_length` cross-check saves you (`committed != 0` → error before commit). For `cat backup.tar | fcry --chunk-size 0 ...` there is no committed length, so you get a valid-looking output file that encrypts zero bytes — and since `Header::read` rejects `chunk_size == 0`, the file is additionally undecryptable. If the user deletes the source afterwards, the data is gone. Fix: validate `chunk_size` in `run()` (reject 0, set a sane max), independent of the header-side check.
|
||||
|
||||
**2. High — `OutSink` can destroy the input file before reading it.**
|
||||
`fcry -d -i backup.fcry.tmp -o backup.fcry` computes `tmp_path = backup.fcry.tmp` and calls `File::create` on it — which truncates your encrypted input to zero bytes before any read happens beyond what `BufReader` already buffered. The run then fails with "truncated ciphertext", but the original file is already destroyed. This is the sharpest consequence of the predictable `<name>.tmp` scheme; the GPT review only covered the symlink and permissions angle (both of which I confirm: `File::create` follows symlinks, and the decrypted plaintext gets umask-default 0644). One fix kills all three: `O_CREAT|O_EXCL` with a random suffix (`tempfile::NamedTempFile::new_in(parent)` + `persist`), mode 0600 for decrypt output, and optionally refuse to clobber an existing `final_path` without `--force`.
|
||||
|
||||
**3. Medium/Low — no key commitment.**
|
||||
ChaCha20-Poly1305 is not key-committing: a ciphertext chunk can be constructed that authenticates under multiple keys (invisible-salamander / partitioning-oracle class). Practical exploitability here is low — it requires a victim decrypting attacker-supplied files and leaking success/failure, and each candidate key costs a full Argon2 run — but fixing it is nearly free and buys you something users feel daily: right now a wrong passphrase burns 1 GiB / seconds of Argon2 and then fails with the exact same `aead::Error` as a corrupted file. Derive a key-check value from the *stretched* key (e.g. first 16 bytes of BLAKE2b(key, "fcry-kcv") in the header, or encrypt a fixed zero block as chunk −1) and you get both key commitment in practice and a clean "wrong passphrase" vs. "corrupt file" distinction. Deriving it from the Argon2 output means it does not cheapen offline guessing — an attacker with the file can already test guesses against chunk 0's tag at identical cost.
|
||||
|
||||
**4. Low — passphrase byte-encoding is not portable.**
|
||||
The Unix path reads UTF-8 bytes from `/dev/tty`. The Windows path reads bytes via `ReadFile` on `CONIN$`, which yields the console's ANSI/OEM codepage — for any non-ASCII passphrase those byte sequences differ, and Argon2 hashes bytes. A file encrypted on Linux with `pässwört` can be undecryptable on Windows and vice versa. There's also no Unicode normalization (NFC vs. NFD — macOS input methods differ from Linux). Either read UTF-16 via `ReadConsoleW` and convert to UTF-8 + normalize to NFC on all platforms, or document ASCII-only passphrases.
|
||||
|
||||
**5. Low — unchecked `u64` multiply in `decrypt_range`.**
|
||||
`chunk_offset = header_len + i * cipher_chunk`: with a forged header committing `plaintext_length` near 2⁶⁴ and a small `chunk_size`, `i` can approach 2³²−1 while `cipher_chunk` is 2³²+15, and the product wraps in release (your release profile has default `overflow-checks = false`). The result is a seek to a wrong offset and a guaranteed tag failure, so it's not exploitable — but it's pre-authentication arithmetic on attacker bytes and should be `checked_mul`/`checked_add` like the rest of that function already is.
|
||||
|
||||
## GPT-5.5 findings I confirm, with sharpening
|
||||
|
||||
- **Pre-auth resource consumption from header fields** — real, and worse in the parallel path than stated: the pipeline reader allocates a fresh `vec![0u8; chunk_sz]` per job with up to `4 × threads` chunks in flight, so a forged `chunk_size = u32::MAX` on a 16-core box attempts ~64 × 4 GiB before any tag is checked. Argon2 `m_cost` from the header can demand up to 4 TiB; the argon2 crate's block allocation will abort the process on OOM. Cap both at parse time (e.g. chunk_size ≤ 256 MiB, m_cost ≤ some GiB ceiling with an override flag).
|
||||
- **Stdout streams authenticated-but-possibly-truncated prefixes** — correct, and inherent to chunked streaming AE; every released byte is authentic, but a downstream consumer can act on a verified prefix before the truncation error lands. Document it prominently; a `--buffer-verify` mode is the only real alternative.
|
||||
- **`--length 0` range decrypt succeeds with zero authentication** (doesn't even prove the key) and range success ≠ whole-file integrity — both correct as written.
|
||||
- **No floors on passphrase/KDF choices** — confirmed; empty passphrase plus `--argon-memory 1 --argon-passes 1` is accepted (the argon2 crate clamps only at m_cost ≥ 8·p_cost).
|
||||
- **`--raw-key` design** — confirmed; beyond the `/proc/*/cmdline` leak, the keyspace is restricted to valid UTF-8 of exactly 32 bytes, which both blocks legitimate random keys (~⅓ of random 32-byte strings aren't UTF-8, and clap's `String` parsing rejects them) and invites typing a 32-char password with zero stretching. Hex/base64 from a file or fd is the right replacement.
|
||||
- **Plaintext chunk buffers unprotected** — confirmed (plain `Vec<u8>` through channels, no zeroize/mlock). I'd rank this lower than the review does: the plaintext's home is the disk anyway; the meaningful residual exposure is swap, and core dumps are already disabled on Unix.
|
||||
|
||||
Two more one-liners: `commit()` doesn't fsync the parent directory after the rename, so a crash right after a successful run can lose the rename (durability, not confidentiality); and `plaintext_length` sits in cleartext in the header, which leaks nothing beyond what the unpadded ciphertext length already reveals — but if you ever care about size metadata, that's where a padding scheme (à la covert padding / PADMÉ) would slot in.
|
||||
|
||||
If you want, I can write the patch set for the top items — chunk-size validation, the `OutSink` rework with `create_new` + random suffix + 0600, and header-side caps are all small, self-contained diffs.
|
||||
+185
-36
@@ -7,12 +7,15 @@ use std::sync::Arc;
|
||||
|
||||
use crate::error::*;
|
||||
use crate::header::{
|
||||
AlgId, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN, VERSION_CURRENT,
|
||||
AlgId, FLAG_KEY_COMMITTED, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN,
|
||||
VERSION_CURRENT,
|
||||
};
|
||||
use crate::pipeline;
|
||||
use crate::policy;
|
||||
use crate::reader::{AheadReader, ReadInfoChunk};
|
||||
use crate::secrets::{SecretBytes32, SecretVec};
|
||||
use crate::utils::*;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// XChaCha20Poly1305 nonce: 24 bytes total. STREAM splits the trailing 5 bytes
|
||||
/// into a 4-byte big-endian counter and a 1-byte "last block" flag.
|
||||
@@ -40,7 +43,7 @@ pub fn derive_key(
|
||||
match kdf {
|
||||
KdfParams::Raw => {
|
||||
let raw =
|
||||
raw_key.ok_or_else(|| FcryError::Format("raw kdf requires --raw-key".into()))?;
|
||||
raw_key.ok_or_else(|| FcryError::Format("raw kdf requires --key-file".into()))?;
|
||||
raw.with_array(|raw| out.with_mut_array(|out| out.copy_from_slice(raw)));
|
||||
}
|
||||
KdfParams::Argon2id {
|
||||
@@ -67,6 +70,32 @@ fn build_aead(key: &SecretBytes32) -> Arc<XChaCha20Poly1305> {
|
||||
Arc::new(key.with_array(|key| XChaCha20Poly1305::new(key.into())))
|
||||
}
|
||||
|
||||
fn compute_key_commitment(key: &SecretBytes32, header: &Header) -> [u8; 32] {
|
||||
key.with_array(|key| {
|
||||
let mut hasher = blake3::Hasher::new_keyed(key);
|
||||
hasher.update(b"fcry-kcv-v3");
|
||||
hasher.update(&[0]);
|
||||
hasher.update(&header.commitment_input_encoding());
|
||||
*hasher.finalize().as_bytes()
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_key_commitment(header: &Header, key: &SecretBytes32) -> Result<(), FcryError> {
|
||||
let Some(expected) = header.key_commitment else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = compute_key_commitment(key, header);
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in actual.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FcryError::WrongKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bump the per-chunk counter; surface a domain error on overflow rather than
|
||||
/// panicking on debug or wrapping in release.
|
||||
pub(crate) fn bump_counter(counter: u32) -> Result<u32, FcryError> {
|
||||
@@ -75,6 +104,7 @@ pub(crate) fn bump_counter(counter: u32) -> Result<u32, FcryError> {
|
||||
.ok_or_else(|| FcryError::Format("STREAM counter overflow (input too large)".into()))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn encrypt<S: AsRef<str>>(
|
||||
input_file: Option<S>,
|
||||
output_file: Option<S>,
|
||||
@@ -83,11 +113,31 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
kdf: KdfParams,
|
||||
threads: usize,
|
||||
) -> Result<(), FcryError> {
|
||||
let chunk_sz = chunk_size as usize;
|
||||
encrypt_with_output_options(
|
||||
input_file,
|
||||
output_file,
|
||||
key,
|
||||
chunk_size,
|
||||
kdf,
|
||||
threads,
|
||||
&OutSinkOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn encrypt_with_output_options<S: AsRef<str>>(
|
||||
input_file: Option<S>,
|
||||
output_file: Option<S>,
|
||||
key: &SecretBytes32,
|
||||
chunk_size: u32,
|
||||
kdf: KdfParams,
|
||||
threads: usize,
|
||||
output_options: &OutSinkOptions,
|
||||
) -> Result<(), FcryError> {
|
||||
let chunk_sz = policy::validate_chunk_size(chunk_size)?;
|
||||
let input = open_input(input_file)?;
|
||||
let plaintext_length = input.length;
|
||||
let mut f_plain = AheadReader::from(input.reader, chunk_sz);
|
||||
let mut f_encrypted = OutSink::open(output_file)?;
|
||||
let mut f_encrypted = OutSink::open_with_options(output_file, output_options)?;
|
||||
|
||||
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
||||
getrandom::fill(&mut nonce_prefix)?;
|
||||
@@ -96,8 +146,8 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
FLAG_LENGTH_COMMITTED
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let header = Header {
|
||||
} | FLAG_KEY_COMMITTED;
|
||||
let mut header = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags,
|
||||
@@ -105,7 +155,9 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
kdf,
|
||||
nonce_prefix,
|
||||
plaintext_length,
|
||||
key_commitment: None,
|
||||
};
|
||||
header.key_commitment = Some(compute_key_commitment(key, &header));
|
||||
let aad = Arc::new(header.encode());
|
||||
f_encrypted.write_all(&aad)?;
|
||||
|
||||
@@ -124,7 +176,7 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
);
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; chunk_sz];
|
||||
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
|
||||
let mut counter: u32 = 0;
|
||||
let mut bytes_seen: u64 = 0;
|
||||
|
||||
@@ -132,18 +184,18 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
match f_plain.read_ahead(&mut buf)? {
|
||||
ReadInfoChunk::Normal(_) => {
|
||||
let nonce = make_nonce(&nonce_prefix, counter, false);
|
||||
aead.encrypt_in_place(&nonce, &aad, &mut buf)?;
|
||||
aead.encrypt_in_place(&nonce, &aad, &mut *buf)?;
|
||||
f_encrypted.write_all(&buf)?;
|
||||
buf.truncate(chunk_sz);
|
||||
bytes_seen = bytes_seen.saturating_add(chunk_sz as u64);
|
||||
bytes_seen = policy::checked_count_add(bytes_seen, chunk_sz, "bytes read")?;
|
||||
counter = bump_counter(counter)?;
|
||||
}
|
||||
ReadInfoChunk::Last(n) => {
|
||||
buf.truncate(n);
|
||||
let nonce = make_nonce(&nonce_prefix, counter, true);
|
||||
aead.encrypt_in_place(&nonce, &aad, &mut buf)?;
|
||||
aead.encrypt_in_place(&nonce, &aad, &mut *buf)?;
|
||||
f_encrypted.write_all(&buf)?;
|
||||
bytes_seen = bytes_seen.saturating_add(n as u64);
|
||||
bytes_seen = policy::checked_count_add(bytes_seen, n, "bytes read")?;
|
||||
break;
|
||||
}
|
||||
ReadInfoChunk::Empty => {
|
||||
@@ -151,7 +203,7 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
// authenticates the (empty) stream rather than silently producing nothing.
|
||||
buf.clear();
|
||||
let nonce = make_nonce(&nonce_prefix, counter, true);
|
||||
aead.encrypt_in_place(&nonce, &aad, &mut buf)?;
|
||||
aead.encrypt_in_place(&nonce, &aad, &mut *buf)?;
|
||||
f_encrypted.write_all(&buf)?;
|
||||
break;
|
||||
}
|
||||
@@ -173,24 +225,65 @@ pub fn encrypt<S: AsRef<str>>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn decrypt<S: AsRef<str>>(
|
||||
input_file: Option<S>,
|
||||
output_file: Option<S>,
|
||||
raw_key: Option<&SecretBytes32>,
|
||||
passphrase: Option<&SecretVec>,
|
||||
threads: usize,
|
||||
) -> Result<(), FcryError> {
|
||||
decrypt_with_argon_cap(
|
||||
input_file,
|
||||
output_file,
|
||||
raw_key,
|
||||
passphrase,
|
||||
threads,
|
||||
policy::default_argon_decrypt_cap_mib(),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn decrypt_with_argon_cap<S: AsRef<str>>(
|
||||
input_file: Option<S>,
|
||||
output_file: Option<S>,
|
||||
raw_key: Option<&SecretBytes32>,
|
||||
passphrase: Option<&SecretVec>,
|
||||
threads: usize,
|
||||
max_argon_memory_mib: u32,
|
||||
) -> Result<(), FcryError> {
|
||||
decrypt_with_output_options(
|
||||
input_file,
|
||||
output_file,
|
||||
raw_key,
|
||||
passphrase,
|
||||
threads,
|
||||
max_argon_memory_mib,
|
||||
&OutSinkOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decrypt_with_output_options<S: AsRef<str>>(
|
||||
input_file: Option<S>,
|
||||
output_file: Option<S>,
|
||||
raw_key: Option<&SecretBytes32>,
|
||||
passphrase: Option<&SecretVec>,
|
||||
threads: usize,
|
||||
max_argon_memory_mib: u32,
|
||||
output_options: &OutSinkOptions,
|
||||
) -> Result<(), FcryError> {
|
||||
let mut reader = open_input(input_file)?.reader;
|
||||
let header = Header::read(&mut reader)?;
|
||||
let header = Header::read_with_argon_cap(&mut reader, max_argon_memory_mib)?;
|
||||
let aad = Arc::new(header.encode());
|
||||
|
||||
let key = derive_key(&header.kdf, raw_key, passphrase)?;
|
||||
verify_key_commitment(&header, &key)?;
|
||||
|
||||
let chunk_sz = header.chunk_size as usize;
|
||||
let cipher_chunk = chunk_sz + TAG_LEN;
|
||||
let chunk_sz = policy::validate_chunk_size(header.chunk_size)?;
|
||||
let cipher_chunk = policy::cipher_chunk_len(chunk_sz)?;
|
||||
|
||||
let mut f_encrypted = AheadReader::from(reader, cipher_chunk);
|
||||
let mut f_plain = OutSink::open(output_file)?;
|
||||
let mut f_plain = OutSink::open_with_options(output_file, output_options)?;
|
||||
|
||||
let aead = build_aead(&key);
|
||||
|
||||
@@ -207,7 +300,7 @@ pub fn decrypt<S: AsRef<str>>(
|
||||
);
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; cipher_chunk];
|
||||
let mut buf = Zeroizing::new(vec![0u8; cipher_chunk]);
|
||||
let mut counter: u32 = 0;
|
||||
let mut bytes_written: u64 = 0;
|
||||
|
||||
@@ -215,18 +308,20 @@ pub fn decrypt<S: AsRef<str>>(
|
||||
match f_encrypted.read_ahead(&mut buf)? {
|
||||
ReadInfoChunk::Normal(_) => {
|
||||
let nonce = make_nonce(&header.nonce_prefix, counter, false);
|
||||
aead.decrypt_in_place(&nonce, &aad, &mut buf)?;
|
||||
aead.decrypt_in_place(&nonce, &aad, &mut *buf)?;
|
||||
f_plain.write_all(&buf)?;
|
||||
bytes_written = bytes_written.saturating_add(buf.len() as u64);
|
||||
bytes_written =
|
||||
policy::checked_count_add(bytes_written, buf.len(), "bytes written")?;
|
||||
buf.resize(cipher_chunk, 0);
|
||||
counter = bump_counter(counter)?;
|
||||
}
|
||||
ReadInfoChunk::Last(n) => {
|
||||
buf.truncate(n);
|
||||
let nonce = make_nonce(&header.nonce_prefix, counter, true);
|
||||
aead.decrypt_in_place(&nonce, &aad, &mut buf)?;
|
||||
aead.decrypt_in_place(&nonce, &aad, &mut *buf)?;
|
||||
f_plain.write_all(&buf)?;
|
||||
bytes_written = bytes_written.saturating_add(buf.len() as u64);
|
||||
bytes_written =
|
||||
policy::checked_count_add(bytes_written, buf.len(), "bytes written")?;
|
||||
break;
|
||||
}
|
||||
ReadInfoChunk::Empty => {
|
||||
@@ -253,6 +348,7 @@ pub fn decrypt<S: AsRef<str>>(
|
||||
/// whose header has `FLAG_LENGTH_COMMITTED` set, so we know exactly where
|
||||
/// each ciphertext chunk lives and which chunk is the last (its nonce uses
|
||||
/// the STREAM last-block flag).
|
||||
#[allow(dead_code)]
|
||||
pub fn decrypt_range<S: AsRef<str>>(
|
||||
input_file: &str,
|
||||
output_file: Option<S>,
|
||||
@@ -261,9 +357,56 @@ pub fn decrypt_range<S: AsRef<str>>(
|
||||
offset: u64,
|
||||
length: u64,
|
||||
) -> Result<(), FcryError> {
|
||||
decrypt_range_with_argon_cap(
|
||||
input_file,
|
||||
output_file,
|
||||
raw_key,
|
||||
passphrase,
|
||||
offset,
|
||||
length,
|
||||
policy::default_argon_decrypt_cap_mib(),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn decrypt_range_with_argon_cap<S: AsRef<str>>(
|
||||
input_file: &str,
|
||||
output_file: Option<S>,
|
||||
raw_key: Option<&SecretBytes32>,
|
||||
passphrase: Option<&SecretVec>,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
max_argon_memory_mib: u32,
|
||||
) -> Result<(), FcryError> {
|
||||
decrypt_range_with_output_options(
|
||||
input_file,
|
||||
output_file,
|
||||
raw_key,
|
||||
passphrase,
|
||||
offset,
|
||||
length,
|
||||
max_argon_memory_mib,
|
||||
&OutSinkOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn decrypt_range_with_output_options<S: AsRef<str>>(
|
||||
input_file: &str,
|
||||
output_file: Option<S>,
|
||||
raw_key: Option<&SecretBytes32>,
|
||||
passphrase: Option<&SecretVec>,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
max_argon_memory_mib: u32,
|
||||
output_options: &OutSinkOptions,
|
||||
) -> Result<(), FcryError> {
|
||||
if length == 0 {
|
||||
return Err(FcryError::Format("--length 0 is not allowed".into()));
|
||||
}
|
||||
let file = File::open(input_file)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let header = Header::read(&mut reader)?;
|
||||
let header = Header::read_with_argon_cap(&mut reader, max_argon_memory_mib)?;
|
||||
let aad = header.encode();
|
||||
let header_len = aad.len() as u64;
|
||||
|
||||
@@ -283,10 +426,13 @@ pub fn decrypt_range<S: AsRef<str>>(
|
||||
}
|
||||
|
||||
let key = derive_key(&header.kdf, raw_key, passphrase)?;
|
||||
verify_key_commitment(&header, &key)?;
|
||||
let aead = build_aead(&key);
|
||||
|
||||
let chunk_sz = header.chunk_size as u64;
|
||||
let cipher_chunk = chunk_sz + TAG_LEN as u64;
|
||||
let chunk_sz_usize = policy::validate_chunk_size(header.chunk_size)?;
|
||||
let cipher_chunk_usize = policy::cipher_chunk_len(chunk_sz_usize)?;
|
||||
let chunk_sz = chunk_sz_usize as u64;
|
||||
let cipher_chunk = cipher_chunk_usize as u64;
|
||||
|
||||
// Layout invariants:
|
||||
// n_chunks = ceil(total / chunk_sz), but always ≥ 1 (the empty file
|
||||
@@ -297,23 +443,21 @@ pub fn decrypt_range<S: AsRef<str>>(
|
||||
(1u64, 0u64)
|
||||
} else {
|
||||
let n = total.div_ceil(chunk_sz);
|
||||
let last = total - (n - 1) * chunk_sz;
|
||||
let before_last = policy::checked_mul_u64(n - 1, chunk_sz, "last chunk offset")?;
|
||||
let last = total
|
||||
.checked_sub(before_last)
|
||||
.ok_or_else(|| FcryError::Format("last chunk length underflow".into()))?;
|
||||
(n, last)
|
||||
};
|
||||
let last_idx = n_chunks - 1;
|
||||
|
||||
let mut out = OutSink::open(output_file)?;
|
||||
|
||||
if length == 0 {
|
||||
out.commit()?;
|
||||
return Ok(());
|
||||
}
|
||||
let mut out = OutSink::open_with_options(output_file, output_options)?;
|
||||
|
||||
let start_chunk = offset / chunk_sz;
|
||||
let end_chunk = (end - 1) / chunk_sz;
|
||||
|
||||
// Reusable buffer sized to a full chunk + tag.
|
||||
let mut buf = Vec::with_capacity(cipher_chunk as usize);
|
||||
let mut buf = Zeroizing::new(Vec::with_capacity(cipher_chunk_usize));
|
||||
|
||||
let mut file = reader.into_inner();
|
||||
|
||||
@@ -329,19 +473,23 @@ pub fn decrypt_range<S: AsRef<str>>(
|
||||
let cipher_len_usz =
|
||||
usize::try_from(cipher_len).map_err(|_| FcryError::Format("chunk too big".into()))?;
|
||||
|
||||
let chunk_offset = header_len + i * cipher_chunk;
|
||||
let chunk_offset = policy::checked_add_u64(
|
||||
header_len,
|
||||
policy::checked_mul_u64(i, cipher_chunk, "ciphertext chunk offset")?,
|
||||
"ciphertext chunk offset",
|
||||
)?;
|
||||
file.seek(SeekFrom::Start(chunk_offset))?;
|
||||
buf.clear();
|
||||
buf.resize(cipher_len_usz, 0);
|
||||
file.read_exact(&mut buf)?;
|
||||
|
||||
let nonce = make_nonce(&header.nonce_prefix, i_u32, is_last);
|
||||
aead.decrypt_in_place(&nonce, &aad, &mut buf)?;
|
||||
aead.decrypt_in_place(&nonce, &aad, &mut *buf)?;
|
||||
|
||||
// `buf` is now plaintext for this chunk. Compute the chunk's plaintext
|
||||
// window in absolute bytes and intersect with the requested range.
|
||||
let chunk_start = i * chunk_sz;
|
||||
let chunk_end = chunk_start + buf.len() as u64;
|
||||
let chunk_start = policy::checked_mul_u64(i, chunk_sz, "plaintext chunk offset")?;
|
||||
let chunk_end = policy::checked_count_add(chunk_start, buf.len(), "plaintext chunk end")?;
|
||||
let lo = offset.max(chunk_start) - chunk_start;
|
||||
let hi = end.min(chunk_end) - chunk_start;
|
||||
out.write_all(&buf[lo as usize..hi as usize])?;
|
||||
@@ -375,6 +523,7 @@ mod tests {
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix,
|
||||
plaintext_length: None,
|
||||
key_commitment: None,
|
||||
};
|
||||
let aad = header.encode();
|
||||
// First byte after MAGIC is the version — verify our fixture really
|
||||
|
||||
@@ -12,6 +12,7 @@ pub enum FcryError {
|
||||
Format(String),
|
||||
Kdf(String),
|
||||
Passphrase(String),
|
||||
WrongKey,
|
||||
}
|
||||
|
||||
impl From<io::Error> for FcryError {
|
||||
|
||||
+91
-10
@@ -14,6 +14,7 @@
|
||||
//! kdf_params variable (depends on kdf_id)
|
||||
//! nonce_prefix [u8; 19] 19 (STREAM nonce prefix)
|
||||
//! plaintext_length u64 LE 8 (only if version >= 2 and flags & 0x01)
|
||||
//! key_commitment [u8; 32] 32 (only if version >= 3 and flags & 0x02)
|
||||
//! --- end of header ---
|
||||
//! chunk[0..N] each chunk_size + 16 bytes,
|
||||
//! last may be shorter
|
||||
@@ -28,13 +29,16 @@
|
||||
//! * v2 — adds `FLAG_LENGTH_COMMITTED` (bit 0); when set, the total plaintext
|
||||
//! length is appended after `nonce_prefix`. This enables random-access
|
||||
//! decryption without scanning predecessors.
|
||||
//! * v3 — adds `FLAG_KEY_COMMITTED` (bit 1) and an authenticated key
|
||||
//! commitment for fast wrong-key detection before chunk processing.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use crate::error::FcryError;
|
||||
use crate::policy;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"fcry";
|
||||
pub const VERSION_CURRENT: u8 = 2;
|
||||
pub const VERSION_CURRENT: u8 = 3;
|
||||
const VERSION_MIN: u8 = 1;
|
||||
|
||||
pub const NONCE_PREFIX_LEN: usize = 19;
|
||||
@@ -43,9 +47,11 @@ pub const TAG_LEN: usize = 16;
|
||||
/// Set in `flags` when the header carries an authenticated `plaintext_length`
|
||||
/// field. Required for random-access decryption.
|
||||
pub const FLAG_LENGTH_COMMITTED: u8 = 0x01;
|
||||
pub const FLAG_KEY_COMMITTED: u8 = 0x02;
|
||||
|
||||
/// Mask of all flag bits this build understands. Unknown bits → reject.
|
||||
const FLAG_KNOWN_MASK: u8 = FLAG_LENGTH_COMMITTED;
|
||||
const FLAG_KNOWN_MASK: u8 = FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED;
|
||||
pub const KEY_COMMITMENT_LEN: usize = 32;
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
@@ -142,11 +148,13 @@ pub struct Header {
|
||||
pub nonce_prefix: [u8; NONCE_PREFIX_LEN],
|
||||
/// Total plaintext byte count. `Some` iff `flags & FLAG_LENGTH_COMMITTED`.
|
||||
pub plaintext_length: Option<u64>,
|
||||
/// v3 key commitment. `Some` iff `flags & FLAG_KEY_COMMITTED`.
|
||||
pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(72);
|
||||
fn encode_without_commitment(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(104);
|
||||
out.extend_from_slice(&MAGIC);
|
||||
out.push(self.version);
|
||||
out.push(self.alg as u8);
|
||||
@@ -165,7 +173,30 @@ impl Header {
|
||||
out
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = self.encode_without_commitment();
|
||||
if (self.flags & FLAG_KEY_COMMITTED) != 0 {
|
||||
let commitment = self
|
||||
.key_commitment
|
||||
.expect("FLAG_KEY_COMMITTED set but key_commitment is None");
|
||||
out.extend_from_slice(&commitment);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn commitment_input_encoding(&self) -> Vec<u8> {
|
||||
self.encode_without_commitment()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn read(r: &mut impl Read) -> Result<Self, FcryError> {
|
||||
Self::read_with_argon_cap(r, policy::default_argon_decrypt_cap_mib())
|
||||
}
|
||||
|
||||
pub fn read_with_argon_cap(
|
||||
r: &mut impl Read,
|
||||
max_argon_memory_mib: u32,
|
||||
) -> Result<Self, FcryError> {
|
||||
let mut magic = [0u8; 4];
|
||||
r.read_exact(&mut magic)?;
|
||||
if magic != MAGIC {
|
||||
@@ -189,18 +220,25 @@ impl Header {
|
||||
if version < 2 && flags != 0 {
|
||||
return Err(FcryError::Format("v1 header must have flags == 0".into()));
|
||||
}
|
||||
if version < 3 && (flags & FLAG_KEY_COMMITTED) != 0 {
|
||||
return Err(FcryError::Format(
|
||||
"key commitment flag requires v3 header".into(),
|
||||
));
|
||||
}
|
||||
if version >= 3 && (flags & FLAG_KEY_COMMITTED) == 0 {
|
||||
return Err(FcryError::Format("v3 header must commit the key".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()));
|
||||
}
|
||||
policy::validate_chunk_size(chunk_size)?;
|
||||
|
||||
let mut kdf_id = [0u8; 1];
|
||||
r.read_exact(&mut kdf_id)?;
|
||||
let kdf = KdfParams::read_from(kdf_id[0], r)?;
|
||||
policy::validate_header_kdf(&kdf, max_argon_memory_mib)?;
|
||||
|
||||
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
||||
r.read_exact(&mut nonce_prefix)?;
|
||||
@@ -213,6 +251,14 @@ impl Header {
|
||||
None
|
||||
};
|
||||
|
||||
let key_commitment = if (flags & FLAG_KEY_COMMITTED) != 0 {
|
||||
let mut b = [0u8; KEY_COMMITMENT_LEN];
|
||||
r.read_exact(&mut b)?;
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
version,
|
||||
alg,
|
||||
@@ -221,6 +267,7 @@ impl Header {
|
||||
kdf,
|
||||
nonce_prefix,
|
||||
plaintext_length,
|
||||
key_commitment,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -235,11 +282,12 @@ mod tests {
|
||||
let h = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: 0,
|
||||
flags: FLAG_KEY_COMMITTED,
|
||||
chunk_size: 1024 * 1024,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [7u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: None,
|
||||
key_commitment: Some([1u8; KEY_COMMITMENT_LEN]),
|
||||
};
|
||||
let bytes = h.encode();
|
||||
let mut cur = Cursor::new(&bytes);
|
||||
@@ -250,6 +298,7 @@ mod tests {
|
||||
assert_eq!(parsed.chunk_size, h.chunk_size);
|
||||
assert_eq!(parsed.nonce_prefix, h.nonce_prefix);
|
||||
assert_eq!(parsed.plaintext_length, None);
|
||||
assert_eq!(parsed.key_commitment, h.key_commitment);
|
||||
assert_eq!(cur.position() as usize, bytes.len());
|
||||
}
|
||||
|
||||
@@ -258,20 +307,49 @@ mod tests {
|
||||
let h = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: FLAG_LENGTH_COMMITTED,
|
||||
flags: FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED,
|
||||
chunk_size: 65536,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [9u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: Some(123_456_789),
|
||||
key_commitment: Some([2u8; KEY_COMMITMENT_LEN]),
|
||||
};
|
||||
let bytes = h.encode();
|
||||
let mut cur = Cursor::new(&bytes);
|
||||
let parsed = Header::read(&mut cur).unwrap();
|
||||
assert_eq!(parsed.flags, FLAG_LENGTH_COMMITTED);
|
||||
assert_eq!(parsed.flags, FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED);
|
||||
assert_eq!(parsed.plaintext_length, Some(123_456_789));
|
||||
assert_eq!(parsed.key_commitment, h.key_commitment);
|
||||
assert_eq!(cur.position() as usize, bytes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_encoding_layout_stable() {
|
||||
let h = Header {
|
||||
version: VERSION_CURRENT,
|
||||
alg: AlgId::XChaCha20Poly1305,
|
||||
flags: FLAG_LENGTH_COMMITTED | FLAG_KEY_COMMITTED,
|
||||
chunk_size: 0x0102_0304,
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [0x55u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: Some(0x0807_0605_0403_0201),
|
||||
key_commitment: Some([0xaau8; KEY_COMMITMENT_LEN]),
|
||||
};
|
||||
let commitment_input = h.commitment_input_encoding();
|
||||
assert_eq!(commitment_input.len(), 40);
|
||||
assert_eq!(&commitment_input[..4], b"fcry");
|
||||
assert_eq!(commitment_input[4], 3);
|
||||
assert_eq!(
|
||||
&commitment_input[32..40],
|
||||
&0x0807_0605_0403_0201u64.to_le_bytes()
|
||||
);
|
||||
|
||||
let aad = h.encode();
|
||||
assert_eq!(aad.len(), 72);
|
||||
assert_eq!(&aad[..40], &commitment_input);
|
||||
assert_eq!(&aad[40..], &[0xaau8; KEY_COMMITMENT_LEN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() {
|
||||
let mut bytes = Header {
|
||||
@@ -282,6 +360,7 @@ mod tests {
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: None,
|
||||
key_commitment: Some([3u8; KEY_COMMITMENT_LEN]),
|
||||
}
|
||||
.encode();
|
||||
bytes[0] ^= 1;
|
||||
@@ -301,6 +380,7 @@ mod tests {
|
||||
kdf: KdfParams::Raw,
|
||||
nonce_prefix: [0u8; NONCE_PREFIX_LEN],
|
||||
plaintext_length: None,
|
||||
key_commitment: Some([4u8; KEY_COMMITMENT_LEN]),
|
||||
}
|
||||
.encode();
|
||||
// flags byte is at offset 6 (4 magic + version + alg)
|
||||
@@ -328,6 +408,7 @@ mod tests {
|
||||
assert_eq!(parsed.flags, 0);
|
||||
assert_eq!(parsed.chunk_size, 1024);
|
||||
assert_eq!(parsed.plaintext_length, None);
|
||||
assert_eq!(parsed.key_commitment, None);
|
||||
// Re-encoding must reproduce the original v1 bytes exactly so the
|
||||
// recomputed AAD matches what the file was authenticated with.
|
||||
assert_eq!(parsed.encode(), bytes);
|
||||
|
||||
+178
-38
@@ -4,6 +4,7 @@ mod crypto;
|
||||
mod error;
|
||||
mod header;
|
||||
mod pipeline;
|
||||
mod policy;
|
||||
mod reader;
|
||||
mod secrets;
|
||||
mod utils;
|
||||
@@ -12,9 +13,13 @@ use crypto::*;
|
||||
use error::FcryError;
|
||||
use header::{ARGON2_SALT_LEN, KdfParams};
|
||||
use secrets::{SecretBytes32, SecretVec, read_passphrase_tty};
|
||||
use utils::DEFAULT_CHUNK_SIZE;
|
||||
use utils::{DEFAULT_CHUNK_SIZE, OutSinkOptions};
|
||||
|
||||
use clap::Parser;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use
|
||||
@@ -34,10 +39,9 @@ struct Cli {
|
||||
#[clap(short, long)]
|
||||
output_file: Option<String>,
|
||||
|
||||
/// 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, conflicts_with_all = ["passphrase", "passphrase_env"])]
|
||||
raw_key: Option<Zeroizing<String>>,
|
||||
/// Read the raw 32-byte crypto key from a file.
|
||||
#[clap(short = 'k', long, conflicts_with_all = ["passphrase", "passphrase_env"])]
|
||||
key_file: Option<PathBuf>,
|
||||
|
||||
/// Read passphrase interactively (terminal). Implies argon2id KDF on encrypt.
|
||||
#[clap(short, long)]
|
||||
@@ -53,22 +57,43 @@ struct Cli {
|
||||
chunk_size: u32,
|
||||
|
||||
/// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB).
|
||||
#[clap(long, default_value_t = 1024)]
|
||||
#[clap(long, default_value_t = policy::DEFAULT_ARGON_MEMORY_MIB)]
|
||||
argon_memory: u32,
|
||||
|
||||
/// Argon2id passes / iterations (encryption only).
|
||||
#[clap(long, default_value_t = 2)]
|
||||
#[clap(long, default_value_t = policy::MIN_ARGON_PASSES)]
|
||||
argon_passes: u32,
|
||||
|
||||
/// Argon2id parallelism / lanes (encryption only).
|
||||
#[clap(long, default_value_t = 4)]
|
||||
#[clap(long, default_value_t = policy::DEFAULT_ARGON_PARALLELISM)]
|
||||
argon_parallelism: u32,
|
||||
|
||||
/// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop.
|
||||
#[clap(long)]
|
||||
allow_weak_kdf: bool,
|
||||
|
||||
/// Maximum Argon2id memory accepted while decrypting, in MiB.
|
||||
/// Overrides the dynamic default. Raising it can OOM constrained machines.
|
||||
#[clap(long)]
|
||||
max_argon_memory_mib: Option<u32>,
|
||||
|
||||
/// Number of worker threads for AEAD work. Defaults to the number of
|
||||
/// available CPUs. Set to 1 for fully serial encrypt/decrypt.
|
||||
#[clap(short = 'j', long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
threads: Option<u32>,
|
||||
|
||||
/// Replace an existing different output file after encryption/decryption succeeds.
|
||||
#[clap(long)]
|
||||
force: bool,
|
||||
|
||||
/// Directory for private temporary files.
|
||||
#[clap(long)]
|
||||
temp_dir: Option<PathBuf>,
|
||||
|
||||
/// For decrypt-to-stdout, verify the whole plaintext in a private temp file before emitting it.
|
||||
#[clap(long, requires = "decrypt")]
|
||||
buffer_verify: bool,
|
||||
|
||||
/// Random-access decrypt: byte offset of the slice to read.
|
||||
/// Requires `--decrypt`, an `--input-file` whose header has the
|
||||
/// length-committed flag set, and `--length`.
|
||||
@@ -91,25 +116,76 @@ struct Cli {
|
||||
length: Option<u64>,
|
||||
}
|
||||
|
||||
fn parse_raw_key(s: &str) -> Result<SecretBytes32, FcryError> {
|
||||
let raw = s.as_bytes();
|
||||
if raw.len() != 32 {
|
||||
fn read_key_file(path: &Path) -> Result<SecretBytes32, FcryError> {
|
||||
warn_if_key_file_world_readable(path);
|
||||
let mut file = File::open(path)?;
|
||||
let mut buf = Zeroizing::new([0u8; 33]);
|
||||
let mut n = 0usize;
|
||||
while n < buf.len() {
|
||||
match file.read(&mut buf[n..]) {
|
||||
Ok(0) => break,
|
||||
Ok(read) => n += read,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if n < 32 {
|
||||
return Err(FcryError::Format(format!(
|
||||
"raw_key must be exactly 32 bytes, got {}",
|
||||
raw.len()
|
||||
"key file {} is too short: expected exactly 32 bytes, got {n}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if n > 32 {
|
||||
return Err(FcryError::Format(format!(
|
||||
"key file {} is too long: expected exactly 32 bytes; possible trailing newline",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut extra = Zeroizing::new([0u8; 1]);
|
||||
if file.read(&mut *extra)? != 0 {
|
||||
return Err(FcryError::Format(format!(
|
||||
"key file {} is too long: expected exactly 32 bytes; possible trailing newline",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut key = SecretBytes32::zeroed();
|
||||
key.with_mut_array(|key| key.copy_from_slice(raw));
|
||||
key.with_mut_array(|key| key.copy_from_slice(&buf[..32]));
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn warn_if_key_file_world_readable(path: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
let mode = meta.permissions().mode();
|
||||
if (mode & 0o077) != 0 {
|
||||
eprintln!(
|
||||
"Warning: key file {} is group/world accessible; consider chmod 600",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn warn_if_key_file_world_readable(_path: &Path) {}
|
||||
|
||||
/// Source of a passphrase: either the terminal or a named env var.
|
||||
enum PassphraseSource {
|
||||
Tty,
|
||||
EnvVar(String),
|
||||
}
|
||||
|
||||
fn normalize_passphrase(pw: SecretVec) -> Result<SecretVec, FcryError> {
|
||||
let normalized = pw.with_slice(|bytes| {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| {
|
||||
FcryError::Passphrase("passphrase must be valid UTF-8 after normalization".into())
|
||||
})?;
|
||||
Ok::<Zeroizing<String>, FcryError>(Zeroizing::new(s.nfc().collect::<String>()))
|
||||
})?;
|
||||
Ok(SecretVec::from_vec(normalized.as_bytes().to_vec()))
|
||||
}
|
||||
|
||||
fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, FcryError> {
|
||||
match src {
|
||||
PassphraseSource::EnvVar(var) => {
|
||||
@@ -117,17 +193,22 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, F
|
||||
// protected storage. The source Vec is zeroed after the copy.
|
||||
// Note: a copy still exists in the process `environ` table; that is
|
||||
// a known and accepted leak for the env-var path.
|
||||
let v = std::env::var(var).map_err(|_| {
|
||||
let v = Zeroizing::new(std::env::var(var).map_err(|_| {
|
||||
FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
|
||||
})?;
|
||||
Ok(SecretVec::from_vec(v.into_bytes()))
|
||||
})?);
|
||||
let normalized = Zeroizing::new(v.as_str().nfc().collect::<String>());
|
||||
Ok(SecretVec::from_vec(normalized.as_bytes().to_vec()))
|
||||
}
|
||||
PassphraseSource::Tty => {
|
||||
let pw = read_passphrase_tty("Passphrase: ")
|
||||
.map_err(|e| FcryError::Passphrase(e.to_string()))?;
|
||||
let pw = normalize_passphrase(
|
||||
read_passphrase_tty("Passphrase: ")
|
||||
.map_err(|e| FcryError::Passphrase(e.to_string()))?,
|
||||
)?;
|
||||
if confirm {
|
||||
let pw2 = read_passphrase_tty("Confirm passphrase: ")
|
||||
.map_err(|e| FcryError::Passphrase(e.to_string()))?;
|
||||
let pw2 = normalize_passphrase(
|
||||
read_passphrase_tty("Confirm passphrase: ")
|
||||
.map_err(|e| FcryError::Passphrase(e.to_string()))?,
|
||||
)?;
|
||||
if pw != pw2 {
|
||||
return Err(FcryError::Passphrase("passphrases do not match".into()));
|
||||
}
|
||||
@@ -155,7 +236,7 @@ fn disable_core_dumps() {
|
||||
fn run(mut cli: Cli) -> Result<(), FcryError> {
|
||||
// Move the secret-bearing fields out of `Cli` immediately so they don't
|
||||
// sit in the parsed struct for the rest of the function.
|
||||
let raw_key_str: Option<Zeroizing<String>> = cli.raw_key.take();
|
||||
let key_file: Option<PathBuf> = cli.key_file.take();
|
||||
let pw_src: Option<PassphraseSource> = if cli.passphrase {
|
||||
Some(PassphraseSource::Tty)
|
||||
} else {
|
||||
@@ -169,24 +250,54 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
||||
let argon_memory = cli.argon_memory;
|
||||
let argon_passes = cli.argon_passes;
|
||||
let argon_parallelism = cli.argon_parallelism;
|
||||
let threads = cli.threads.map(|n| n as usize).unwrap_or_else(|| {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
});
|
||||
let allow_weak_kdf = cli.allow_weak_kdf;
|
||||
let argon_cap = policy::resolve_argon_decrypt_cap(cli.max_argon_memory_mib)?;
|
||||
if argon_cap.overridden && argon_cap.effective_mib > argon_cap.default_mib {
|
||||
eprintln!(
|
||||
"Warning: --max-argon-memory-mib raises the Argon2 decrypt trust ceiling from {} MiB to {} MiB; this can OOM constrained machines",
|
||||
argon_cap.default_mib, argon_cap.effective_mib
|
||||
);
|
||||
}
|
||||
let (threads, thread_warning) = policy::normalize_worker_threads(cli.threads);
|
||||
if let Some(requested) = thread_warning {
|
||||
eprintln!(
|
||||
"Warning: requested {requested} worker threads; capped at {}",
|
||||
policy::MAX_WORKER_THREADS
|
||||
);
|
||||
}
|
||||
let force = cli.force;
|
||||
let temp_dir = cli.temp_dir.take();
|
||||
let buffer_verify = cli.buffer_verify;
|
||||
let offset = cli.offset;
|
||||
let length = cli.length;
|
||||
drop(cli);
|
||||
|
||||
if pw_src.is_none() && raw_key_str.is_none() {
|
||||
if pw_src.is_none() && key_file.is_none() {
|
||||
return Err(FcryError::Format(
|
||||
"must provide one of --raw-key, --passphrase, --passphrase-env".into(),
|
||||
"must provide one of --key-file, --passphrase, --passphrase-env".into(),
|
||||
));
|
||||
}
|
||||
if buffer_verify && !decrypt_mode {
|
||||
return Err(FcryError::Format(
|
||||
"--buffer-verify is only valid for decrypt".into(),
|
||||
));
|
||||
}
|
||||
if buffer_verify && output.is_some() {
|
||||
return Err(FcryError::Format(
|
||||
"--buffer-verify is only meaningful when decrypting to stdout".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let output_options = OutSinkOptions {
|
||||
force,
|
||||
input_file: input.as_ref().map(PathBuf::from),
|
||||
temp_dir,
|
||||
buffer_verify_stdout: buffer_verify,
|
||||
};
|
||||
|
||||
if decrypt_mode {
|
||||
let raw_key = match raw_key_str.as_deref() {
|
||||
Some(s) => Some(parse_raw_key(s)?),
|
||||
let raw_key = match key_file.as_deref() {
|
||||
Some(path) => Some(read_key_file(path)?),
|
||||
None => None,
|
||||
};
|
||||
let pw = match &pw_src {
|
||||
@@ -202,10 +313,27 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
||||
"--offset/--length require --input-file (random-access needs a seekable file)".into(),
|
||||
)
|
||||
})?;
|
||||
decrypt_range(path, output, raw_key.as_ref(), pw.as_ref(), o, l)?;
|
||||
decrypt_range_with_output_options(
|
||||
path,
|
||||
output,
|
||||
raw_key.as_ref(),
|
||||
pw.as_ref(),
|
||||
o,
|
||||
l,
|
||||
argon_cap.effective_mib,
|
||||
&output_options,
|
||||
)?;
|
||||
}
|
||||
(None, None) => {
|
||||
decrypt(input, output, raw_key.as_ref(), pw.as_ref(), threads)?;
|
||||
decrypt_with_output_options(
|
||||
input,
|
||||
output,
|
||||
raw_key.as_ref(),
|
||||
pw.as_ref(),
|
||||
threads,
|
||||
argon_cap.effective_mib,
|
||||
&output_options,
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(FcryError::Format(
|
||||
@@ -217,9 +345,12 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
||||
let (key, kdf) = if let Some(src) = &pw_src {
|
||||
let mut salt = [0u8; ARGON2_SALT_LEN];
|
||||
getrandom::fill(&mut salt)?;
|
||||
let m_cost_kib = argon_memory.checked_mul(1024).ok_or_else(|| {
|
||||
FcryError::Format("argon-memory too large (overflow when converting to KiB)".into())
|
||||
})?;
|
||||
let m_cost_kib = policy::validate_new_argon_params(
|
||||
argon_memory,
|
||||
argon_passes,
|
||||
argon_parallelism,
|
||||
allow_weak_kdf,
|
||||
)?;
|
||||
let kdf = KdfParams::Argon2id {
|
||||
salt,
|
||||
m_cost: m_cost_kib,
|
||||
@@ -227,13 +358,22 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
||||
p_cost: argon_parallelism,
|
||||
};
|
||||
let pw = read_passphrase(src, true)?;
|
||||
policy::validate_new_passphrase(&pw, allow_weak_kdf)?;
|
||||
let key = derive_key(&kdf, None, Some(&pw))?;
|
||||
(key, kdf)
|
||||
} else {
|
||||
let key = parse_raw_key(raw_key_str.as_deref().unwrap())?;
|
||||
let key = read_key_file(key_file.as_deref().unwrap())?;
|
||||
(key, KdfParams::Raw)
|
||||
};
|
||||
encrypt(input, output, &key, chunk_size, kdf, threads)?;
|
||||
encrypt_with_output_options(
|
||||
input,
|
||||
output,
|
||||
&key,
|
||||
chunk_size,
|
||||
kdf,
|
||||
threads,
|
||||
&output_options,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+17
-15
@@ -42,31 +42,33 @@ use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded};
|
||||
use crate::crypto::{bump_counter, make_nonce};
|
||||
use crate::error::FcryError;
|
||||
use crate::header::NONCE_PREFIX_LEN;
|
||||
use crate::policy;
|
||||
use crate::reader::{AheadReader, ReadInfoChunk};
|
||||
use crate::utils::OutSink;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
struct Job {
|
||||
counter: u32,
|
||||
last: bool,
|
||||
buf: Vec<u8>,
|
||||
buf: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
struct Done {
|
||||
counter: u32,
|
||||
buf: Vec<u8>,
|
||||
buf: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Job-channel capacity: small multiples of worker count, enough to keep
|
||||
/// workers fed without unbounded memory.
|
||||
fn channel_capacity(threads: usize) -> usize {
|
||||
(threads * 2).max(2)
|
||||
fn channel_capacity(threads: usize, in_flight: usize) -> usize {
|
||||
policy::pipeline_channel_capacity(threads, in_flight)
|
||||
}
|
||||
|
||||
/// Total in-flight chunk cap (jobs queued + at workers + in writer's reorder
|
||||
/// buffer). Permit count; bounded above the job-channel capacity to absorb
|
||||
/// reordering without blocking workers unnecessarily.
|
||||
fn in_flight_capacity(threads: usize) -> usize {
|
||||
(threads * 4).max(4)
|
||||
fn in_flight_capacity(threads: usize, chunk_len: usize) -> usize {
|
||||
policy::pipeline_in_flight_capacity(threads, chunk_len)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -152,8 +154,8 @@ fn run_pipeline(
|
||||
threads: usize,
|
||||
is_encrypt: bool,
|
||||
) -> Result<(OutSink, u64), FcryError> {
|
||||
let cap = channel_capacity(threads);
|
||||
let in_flight = in_flight_capacity(threads);
|
||||
let in_flight = in_flight_capacity(threads, chunk_sz);
|
||||
let cap = channel_capacity(threads, in_flight);
|
||||
let (jobs_tx, jobs_rx) = bounded::<Job>(cap);
|
||||
let (done_tx, done_rx) = bounded::<Done>(cap);
|
||||
|
||||
@@ -193,7 +195,7 @@ fn run_pipeline(
|
||||
Err(RecvTimeoutError::Disconnected) => return Ok(bytes_seen),
|
||||
}
|
||||
}
|
||||
let mut buf = vec![0u8; chunk_sz];
|
||||
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
|
||||
match input.read_ahead(&mut buf)? {
|
||||
ReadInfoChunk::Normal(_) => {
|
||||
if jobs_tx
|
||||
@@ -206,7 +208,7 @@ fn run_pipeline(
|
||||
{
|
||||
return Ok(bytes_seen);
|
||||
}
|
||||
bytes_seen = bytes_seen.saturating_add(chunk_sz as u64);
|
||||
bytes_seen = policy::checked_count_add(bytes_seen, chunk_sz, "bytes read")?;
|
||||
counter = bump_counter(counter)?;
|
||||
}
|
||||
ReadInfoChunk::Last(n) => {
|
||||
@@ -216,7 +218,7 @@ fn run_pipeline(
|
||||
last: true,
|
||||
buf,
|
||||
});
|
||||
bytes_seen = bytes_seen.saturating_add(n as u64);
|
||||
bytes_seen = policy::checked_count_add(bytes_seen, n, "bytes read")?;
|
||||
return Ok(bytes_seen);
|
||||
}
|
||||
ReadInfoChunk::Empty => {
|
||||
@@ -261,9 +263,9 @@ fn run_pipeline(
|
||||
}
|
||||
let nonce = make_nonce(&nonce_prefix, job.counter, job.last);
|
||||
let res = if is_encrypt {
|
||||
aead.encrypt_in_place(&nonce, aad.as_slice(), &mut job.buf)
|
||||
aead.encrypt_in_place(&nonce, aad.as_slice(), &mut *job.buf)
|
||||
} else {
|
||||
aead.decrypt_in_place(&nonce, aad.as_slice(), &mut job.buf)
|
||||
aead.decrypt_in_place(&nonce, aad.as_slice(), &mut *job.buf)
|
||||
};
|
||||
if let Err(e) = res {
|
||||
cancel.store(true, Ordering::Release);
|
||||
@@ -343,13 +345,13 @@ fn ordered_writer(
|
||||
permit_tx: Sender<()>,
|
||||
) -> Result<(OutSink, u64), FcryError> {
|
||||
let mut next: u32 = 0;
|
||||
let mut pending: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
|
||||
let mut pending: BTreeMap<u32, Zeroizing<Vec<u8>>> = BTreeMap::new();
|
||||
let mut total: u64 = 0;
|
||||
for done in done_rx.iter() {
|
||||
pending.insert(done.counter, done.buf);
|
||||
while let Some(buf) = pending.remove(&next) {
|
||||
output.write_all(&buf)?;
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
total = policy::checked_count_add(total, buf.len(), "bytes written")?;
|
||||
// `bump_counter` rejects overflow upstream; a wrap here would be
|
||||
// a real bug, so use plain addition and let it panic in debug.
|
||||
next += 1;
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
//! Central resource and format policy.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use crate::error::FcryError;
|
||||
use crate::header::{KdfParams, TAG_LEN};
|
||||
use crate::secrets::SecretVec;
|
||||
|
||||
pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
|
||||
pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024;
|
||||
|
||||
pub const DEFAULT_ARGON_MEMORY_MIB: u32 = 1024;
|
||||
pub const MIN_ARGON_MEMORY_MIB: u32 = 64;
|
||||
pub const DEFAULT_ARGON_DECRYPT_CAP_MIB: u32 = 4096;
|
||||
pub const MIN_ARGON_PASSES: u32 = 2;
|
||||
pub const MAX_ARGON_PASSES: u32 = 64;
|
||||
pub const DEFAULT_ARGON_PARALLELISM: u32 = 4;
|
||||
pub const MAX_ARGON_PARALLELISM: u32 = 64;
|
||||
pub const MIN_PASSPHRASE_BYTES: usize = 12;
|
||||
|
||||
pub const MAX_WORKER_THREADS: usize = 256;
|
||||
pub const PIPELINE_IN_FLIGHT_BYTES: usize = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArgonDecryptCap {
|
||||
pub default_mib: u32,
|
||||
pub effective_mib: u32,
|
||||
pub overridden: bool,
|
||||
}
|
||||
|
||||
pub fn architecture_argon_cap_mib() -> u32 {
|
||||
let usize_cap_mib = usize::MAX / 1024 / 1024;
|
||||
let argon_m_cost_cap_mib = (u32::MAX / 1024) as usize;
|
||||
usize_cap_mib
|
||||
.min(argon_m_cost_cap_mib)
|
||||
.min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn available_memory_mib() -> Option<u32> {
|
||||
let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
|
||||
for line in meminfo.lines() {
|
||||
let Some(rest) = line.strip_prefix("MemAvailable:") else {
|
||||
continue;
|
||||
};
|
||||
let kib = rest.split_whitespace().next()?.parse::<u64>().ok()?;
|
||||
return u32::try_from(kib / 1024).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn available_memory_mib() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn default_argon_decrypt_cap_mib() -> u32 {
|
||||
let mut cap = DEFAULT_ARGON_DECRYPT_CAP_MIB.min(architecture_argon_cap_mib());
|
||||
if let Some(available) = available_memory_mib() {
|
||||
cap = cap.min(available);
|
||||
}
|
||||
cap.max(1)
|
||||
}
|
||||
|
||||
pub fn resolve_argon_decrypt_cap(override_mib: Option<u32>) -> Result<ArgonDecryptCap, FcryError> {
|
||||
let default_mib = default_argon_decrypt_cap_mib();
|
||||
let Some(effective_mib) = override_mib else {
|
||||
return Ok(ArgonDecryptCap {
|
||||
default_mib,
|
||||
effective_mib: default_mib,
|
||||
overridden: false,
|
||||
});
|
||||
};
|
||||
if effective_mib == 0 {
|
||||
return Err(FcryError::Format(
|
||||
"--max-argon-memory-mib must be at least 1".into(),
|
||||
));
|
||||
}
|
||||
let arch = architecture_argon_cap_mib();
|
||||
if effective_mib > arch {
|
||||
return Err(FcryError::Format(format!(
|
||||
"--max-argon-memory-mib {effective_mib} exceeds this build's supported cap {arch}"
|
||||
)));
|
||||
}
|
||||
Ok(ArgonDecryptCap {
|
||||
default_mib,
|
||||
effective_mib,
|
||||
overridden: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mib_to_kib(mib: u32, name: &str) -> Result<u32, FcryError> {
|
||||
mib.checked_mul(1024).ok_or_else(|| {
|
||||
FcryError::Format(format!("{name} too large (overflow converting MiB to KiB)"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_chunk_size(chunk_size: u32) -> Result<usize, FcryError> {
|
||||
if chunk_size == 0 {
|
||||
return Err(FcryError::Format("chunk_size must be > 0".into()));
|
||||
}
|
||||
if chunk_size > MAX_CHUNK_SIZE {
|
||||
return Err(FcryError::Format(format!(
|
||||
"chunk_size {chunk_size} exceeds maximum {MAX_CHUNK_SIZE}"
|
||||
)));
|
||||
}
|
||||
usize::try_from(chunk_size)
|
||||
.map_err(|_| FcryError::Format("chunk_size does not fit in usize".into()))
|
||||
}
|
||||
|
||||
pub fn cipher_chunk_len(plain_chunk_len: usize) -> Result<usize, FcryError> {
|
||||
plain_chunk_len
|
||||
.checked_add(TAG_LEN)
|
||||
.ok_or_else(|| FcryError::Format("cipher chunk length overflow".into()))
|
||||
}
|
||||
|
||||
pub fn validate_new_argon_params(
|
||||
memory_mib: u32,
|
||||
passes: u32,
|
||||
parallelism: u32,
|
||||
allow_weak_kdf: bool,
|
||||
) -> Result<u32, FcryError> {
|
||||
if !allow_weak_kdf && memory_mib < MIN_ARGON_MEMORY_MIB {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-memory must be at least {MIN_ARGON_MEMORY_MIB} MiB for new encryption (use --allow-weak-kdf only for tests/legacy interop)"
|
||||
)));
|
||||
}
|
||||
if !allow_weak_kdf && passes < MIN_ARGON_PASSES {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-passes must be at least {MIN_ARGON_PASSES} for new encryption (use --allow-weak-kdf only for tests/legacy interop)"
|
||||
)));
|
||||
}
|
||||
validate_argon_common(memory_mib, passes, parallelism, "new encryption")?;
|
||||
mib_to_kib(memory_mib, "argon-memory")
|
||||
}
|
||||
|
||||
pub fn validate_new_passphrase(pw: &SecretVec, allow_weak_kdf: bool) -> Result<(), FcryError> {
|
||||
let len = pw.len();
|
||||
if len == 0 {
|
||||
return Err(FcryError::Passphrase("passphrase must not be empty".into()));
|
||||
}
|
||||
if !allow_weak_kdf && len < MIN_PASSPHRASE_BYTES {
|
||||
return Err(FcryError::Passphrase(format!(
|
||||
"passphrase must be at least {MIN_PASSPHRASE_BYTES} UTF-8 bytes for new encryption (use --allow-weak-kdf only for tests/legacy interop)"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_header_kdf(kdf: &KdfParams, max_argon_memory_mib: u32) -> Result<(), FcryError> {
|
||||
match kdf {
|
||||
KdfParams::Raw => Ok(()),
|
||||
KdfParams::Argon2id {
|
||||
m_cost,
|
||||
t_cost,
|
||||
p_cost,
|
||||
..
|
||||
} => {
|
||||
let cap_kib = mib_to_kib(max_argon_memory_mib, "max argon memory")?;
|
||||
if *m_cost == 0 {
|
||||
return Err(FcryError::Format("argon2id memory cost must be > 0".into()));
|
||||
}
|
||||
if *t_cost == 0 {
|
||||
return Err(FcryError::Format("argon2id passes must be > 0".into()));
|
||||
}
|
||||
if *p_cost == 0 {
|
||||
return Err(FcryError::Format("argon2id parallelism must be > 0".into()));
|
||||
}
|
||||
if *m_cost > cap_kib {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon2id memory cost {} KiB exceeds configured decrypt cap {} MiB",
|
||||
*m_cost, max_argon_memory_mib
|
||||
)));
|
||||
}
|
||||
if *t_cost > MAX_ARGON_PASSES {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon2id passes {} exceeds maximum {}",
|
||||
*t_cost, MAX_ARGON_PASSES
|
||||
)));
|
||||
}
|
||||
if *p_cost > MAX_ARGON_PARALLELISM {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon2id parallelism {} exceeds maximum {}",
|
||||
*p_cost, MAX_ARGON_PARALLELISM
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_argon_common(
|
||||
memory_mib: u32,
|
||||
passes: u32,
|
||||
parallelism: u32,
|
||||
context: &str,
|
||||
) -> Result<(), FcryError> {
|
||||
if memory_mib == 0 {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-memory must be > 0 for {context}"
|
||||
)));
|
||||
}
|
||||
if passes == 0 {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-passes must be > 0 for {context}"
|
||||
)));
|
||||
}
|
||||
if passes > MAX_ARGON_PASSES {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-passes {passes} exceeds maximum {MAX_ARGON_PASSES}"
|
||||
)));
|
||||
}
|
||||
if parallelism == 0 {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-parallelism must be > 0 for {context}"
|
||||
)));
|
||||
}
|
||||
if parallelism > MAX_ARGON_PARALLELISM {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-parallelism {parallelism} exceeds maximum {MAX_ARGON_PARALLELISM}"
|
||||
)));
|
||||
}
|
||||
if memory_mib > architecture_argon_cap_mib() {
|
||||
return Err(FcryError::Kdf(format!(
|
||||
"argon-memory {memory_mib} exceeds this build's supported cap {}",
|
||||
architecture_argon_cap_mib()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize_worker_threads(requested: Option<u32>) -> (usize, Option<u32>) {
|
||||
let requested = requested.map(|n| n as usize).unwrap_or_else(|| {
|
||||
std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(1)
|
||||
});
|
||||
let capped = requested.clamp(1, MAX_WORKER_THREADS);
|
||||
let warning = (requested > MAX_WORKER_THREADS).then_some(requested as u32);
|
||||
(capped, warning)
|
||||
}
|
||||
|
||||
pub fn pipeline_in_flight_capacity(threads: usize, chunk_len: usize) -> usize {
|
||||
let chunk_len = chunk_len.max(1);
|
||||
let thread_cap = threads.saturating_mul(4).max(1);
|
||||
let byte_cap = (PIPELINE_IN_FLIGHT_BYTES / chunk_len).max(1);
|
||||
thread_cap.min(byte_cap)
|
||||
}
|
||||
|
||||
pub fn pipeline_channel_capacity(threads: usize, in_flight: usize) -> usize {
|
||||
threads.saturating_mul(2).max(1).min(in_flight.max(1))
|
||||
}
|
||||
|
||||
pub fn checked_add_u64(a: u64, b: u64, what: &str) -> Result<u64, FcryError> {
|
||||
a.checked_add(b)
|
||||
.ok_or_else(|| FcryError::Format(format!("{what} overflow")))
|
||||
}
|
||||
|
||||
pub fn checked_mul_u64(a: u64, b: u64, what: &str) -> Result<u64, FcryError> {
|
||||
a.checked_mul(b)
|
||||
.ok_or_else(|| FcryError::Format(format!("{what} overflow")))
|
||||
}
|
||||
|
||||
pub fn checked_count_add(total: u64, delta: usize, what: &str) -> Result<u64, FcryError> {
|
||||
checked_add_u64(total, delta as u64, what)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chunk_size_bounds() {
|
||||
assert!(validate_chunk_size(1).is_ok());
|
||||
assert!(validate_chunk_size(MAX_CHUNK_SIZE).is_ok());
|
||||
assert!(validate_chunk_size(0).is_err());
|
||||
assert!(validate_chunk_size(MAX_CHUNK_SIZE + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argon_cap_override_replaces_default() {
|
||||
let down = resolve_argon_decrypt_cap(Some(1)).unwrap();
|
||||
assert_eq!(down.effective_mib, 1);
|
||||
assert!(down.overridden);
|
||||
let default = resolve_argon_decrypt_cap(None).unwrap();
|
||||
assert_eq!(default.effective_mib, default.default_mib);
|
||||
assert!(!default.overridden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_capacity_has_one_chunk_minimum() {
|
||||
assert_eq!(
|
||||
pipeline_in_flight_capacity(4, PIPELINE_IN_FLIGHT_BYTES * 2),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::io;
|
||||
use std::io::{BufRead, Read};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub enum ReadInfoChunk {
|
||||
Normal(#[allow(dead_code)] usize),
|
||||
@@ -11,7 +12,7 @@ pub enum ReadInfoChunk {
|
||||
|
||||
pub struct AheadReader {
|
||||
inner: Box<dyn BufRead + Send>,
|
||||
buf: Vec<u8>,
|
||||
buf: Zeroizing<Vec<u8>>,
|
||||
bufsz: usize,
|
||||
capacity: usize,
|
||||
}
|
||||
@@ -20,7 +21,7 @@ impl AheadReader {
|
||||
pub fn from(reader: Box<dyn BufRead + Send>, capacity: usize) -> Self {
|
||||
Self {
|
||||
inner: reader,
|
||||
buf: vec![0; capacity],
|
||||
buf: Zeroizing::new(vec![0; capacity]),
|
||||
bufsz: 0,
|
||||
capacity,
|
||||
}
|
||||
@@ -61,7 +62,7 @@ impl AheadReader {
|
||||
}
|
||||
|
||||
// 2nd read directly into our internal buf
|
||||
let mut tmp = vec![0u8; self.capacity];
|
||||
let mut tmp = Zeroizing::new(vec![0u8; self.capacity]);
|
||||
let n2 = self.read_until_full(&mut tmp)?;
|
||||
self.buf = tmp;
|
||||
self.bufsz = n2;
|
||||
@@ -78,7 +79,7 @@ impl AheadReader {
|
||||
let userbuf_sz = self.bufsz;
|
||||
|
||||
// 2nd read directly into our internal buf
|
||||
let mut tmp = vec![0u8; self.capacity];
|
||||
let mut tmp = Zeroizing::new(vec![0u8; self.capacity]);
|
||||
let n2 = self.read_until_full(&mut tmp)?;
|
||||
self.buf = tmp;
|
||||
self.bufsz = n2;
|
||||
|
||||
+56
-14
@@ -95,6 +95,10 @@ impl SecretVec {
|
||||
let inner = self.inner.borrow();
|
||||
f(&inner[..self.len])
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SecretVec {
|
||||
@@ -192,13 +196,15 @@ mod imp {
|
||||
mod imp {
|
||||
use super::{MAX_PASSPHRASE_LEN, SecretVec};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, Write};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::ptr;
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode,
|
||||
ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, ReadConsoleW,
|
||||
SetConsoleMode,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
struct ConsoleModeGuard {
|
||||
handle: HANDLE,
|
||||
@@ -214,7 +220,7 @@ mod imp {
|
||||
}
|
||||
|
||||
pub fn read_passphrase_tty(prompt: &str) -> io::Result<SecretVec> {
|
||||
let mut tty_in = OpenOptions::new().read(true).write(true).open("CONIN$")?;
|
||||
let tty_in = OpenOptions::new().read(true).write(true).open("CONIN$")?;
|
||||
let mut tty_out = OpenOptions::new().write(true).open("CONOUT$")?;
|
||||
|
||||
let h_in = tty_in.as_raw_handle() as HANDLE;
|
||||
@@ -236,18 +242,38 @@ mod imp {
|
||||
write!(tty_out, "{prompt}")?;
|
||||
tty_out.flush()?;
|
||||
|
||||
let mut buf = SecretVec::with_capacity(MAX_PASSPHRASE_LEN);
|
||||
let mut byte = [0u8; 1];
|
||||
let mut wide = Zeroizing::new(Vec::<u16>::with_capacity(MAX_PASSPHRASE_LEN));
|
||||
loop {
|
||||
match tty_in.read(&mut byte) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => match byte[0] {
|
||||
b'\n' => break,
|
||||
b'\r' => continue,
|
||||
b => buf.push(b)?,
|
||||
},
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
let mut unit = 0u16;
|
||||
let mut read = 0u32;
|
||||
let ok = unsafe {
|
||||
ReadConsoleW(
|
||||
h_in,
|
||||
(&mut unit as *mut u16).cast(),
|
||||
1,
|
||||
&mut read,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
const LF: u16 = b'\n' as u16;
|
||||
const CR: u16 = b'\r' as u16;
|
||||
match unit {
|
||||
LF | CR => break,
|
||||
u => {
|
||||
if wide.len() >= MAX_PASSPHRASE_LEN {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"secret buffer full",
|
||||
));
|
||||
}
|
||||
wide.push(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +281,22 @@ mod imp {
|
||||
let _ = writeln!(tty_out);
|
||||
let _ = tty_out.flush();
|
||||
|
||||
let utf8 = Zeroizing::new(String::from_utf16(&wide).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"console passphrase is not valid UTF-16",
|
||||
)
|
||||
})?);
|
||||
if utf8.len() > MAX_PASSPHRASE_LEN {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"secret buffer full",
|
||||
));
|
||||
}
|
||||
let mut buf = SecretVec::with_capacity(MAX_PASSPHRASE_LEN);
|
||||
for b in utf8.as_bytes() {
|
||||
buf.push(*b)?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
+206
-59
@@ -1,14 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::policy;
|
||||
|
||||
/// 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 DEFAULT_CHUNK_SIZE: u32 = policy::DEFAULT_CHUNK_SIZE;
|
||||
|
||||
/// Opened input.
|
||||
///
|
||||
@@ -45,63 +47,225 @@ pub(crate) fn open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Inp
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OutSinkOptions {
|
||||
pub force: bool,
|
||||
pub input_file: Option<PathBuf>,
|
||||
pub temp_dir: Option<PathBuf>,
|
||||
pub buffer_verify_stdout: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct SecureTempFile {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
remove_on_drop: bool,
|
||||
}
|
||||
|
||||
impl SecureTempFile {
|
||||
fn create(dir: &Path, prefix: &str) -> io::Result<Self> {
|
||||
fs::create_dir_all(dir)?;
|
||||
for _ in 0..128 {
|
||||
let mut rand = [0u8; 16];
|
||||
getrandom::fill(&mut rand).map_err(io::Error::other)?;
|
||||
let name = format!("{prefix}.{}.tmp", hex(&rand));
|
||||
let path = dir.join(name);
|
||||
let mut opts = OpenOptions::new();
|
||||
opts.read(true).write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
match opts.open(&path) {
|
||||
Ok(file) => {
|
||||
return Ok(Self {
|
||||
path,
|
||||
file: Some(file),
|
||||
remove_on_drop: true,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"could not create a unique temporary file after 128 attempts",
|
||||
))
|
||||
}
|
||||
|
||||
fn file_mut(&mut self) -> &mut File {
|
||||
self.file
|
||||
.as_mut()
|
||||
.expect("temporary file handle taken before commit")
|
||||
}
|
||||
|
||||
fn sync_file(&mut self) -> io::Result<()> {
|
||||
let file = self.file_mut();
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
fn persist(mut self, final_path: &Path) -> io::Result<()> {
|
||||
self.sync_file()?;
|
||||
self.file.take();
|
||||
#[cfg(windows)]
|
||||
if final_path.exists() {
|
||||
fs::remove_file(final_path)?;
|
||||
}
|
||||
fs::rename(&self.path, final_path)?;
|
||||
self.remove_on_drop = false;
|
||||
best_effort_fsync_parent(final_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_stdout(mut self) -> io::Result<()> {
|
||||
self.sync_file()?;
|
||||
let mut file = self
|
||||
.file
|
||||
.take()
|
||||
.expect("temporary file handle taken before stdout commit");
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut stdout = io::stdout();
|
||||
io::copy(&mut file, &mut stdout)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SecureTempFile {
|
||||
fn drop(&mut self) {
|
||||
self.file.take();
|
||||
if self.remove_on_drop {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0x0f) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn best_effort_fsync_parent(path: &Path) {
|
||||
let Some(parent) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(dir) = File::open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir_for_target(final_path: &Path, explicit: Option<&Path>) -> PathBuf {
|
||||
if let Some(dir) = explicit {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
final_path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn temp_dir_for_stdout(explicit: Option<&Path>) -> PathBuf {
|
||||
explicit
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
}
|
||||
|
||||
fn file_name_prefix(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("fcry")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result<bool> {
|
||||
let Some(input) = input else {
|
||||
return Ok(false);
|
||||
};
|
||||
match same_file::is_same_file(input, output) {
|
||||
Ok(same) => Ok(same),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output sink that supports atomic file replacement.
|
||||
///
|
||||
/// For file outputs: bytes are written to `<path>.tmp`. On `commit()`, the
|
||||
/// temp file is renamed into place. If dropped without commit (panic, error,
|
||||
/// process exit), the temp file is deleted so a partial/garbage file does
|
||||
/// not replace any existing target.
|
||||
/// For file outputs: bytes are written to a private, randomly named temp file.
|
||||
/// On `commit()`, the temp file is fsynced and renamed into place. If dropped
|
||||
/// without commit (panic, error, process exit), the temp file is deleted so a
|
||||
/// partial/garbage file does not replace any existing target.
|
||||
///
|
||||
/// For stdout: behaves as a passthrough; `commit()` is a no-op.
|
||||
pub enum OutSink {
|
||||
Stdout(io::Stdout),
|
||||
BufferVerify {
|
||||
temp: SecureTempFile,
|
||||
},
|
||||
File {
|
||||
tmp_path: PathBuf,
|
||||
final_path: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
temp: SecureTempFile,
|
||||
},
|
||||
}
|
||||
|
||||
impl OutSink {
|
||||
#[allow(dead_code)]
|
||||
pub fn open<S: AsRef<str>>(output_file: Option<S>) -> io::Result<Self> {
|
||||
Self::open_with_options(output_file, &OutSinkOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_with_options<S: AsRef<str>>(
|
||||
output_file: Option<S>,
|
||||
options: &OutSinkOptions,
|
||||
) -> io::Result<Self> {
|
||||
match output_file {
|
||||
None if options.buffer_verify_stdout => {
|
||||
let dir = temp_dir_for_stdout(options.temp_dir.as_deref());
|
||||
Ok(Self::BufferVerify {
|
||||
temp: SecureTempFile::create(&dir, "fcry-buffer")?,
|
||||
})
|
||||
}
|
||||
None => Ok(Self::Stdout(io::stdout())),
|
||||
Some(f) => {
|
||||
let final_path = PathBuf::from(f.as_ref());
|
||||
let mut tmp_path = final_path.clone();
|
||||
let name = tmp_path
|
||||
.file_name()
|
||||
.map(|n| n.to_os_string())
|
||||
.unwrap_or_default();
|
||||
let mut tmp_name = name;
|
||||
tmp_name.push(".tmp");
|
||||
tmp_path.set_file_name(tmp_name);
|
||||
let file = File::create(&tmp_path)?;
|
||||
Ok(Self::File {
|
||||
tmp_path,
|
||||
final_path,
|
||||
file: Some(file),
|
||||
committed: false,
|
||||
})
|
||||
if final_path.exists()
|
||||
&& !options.force
|
||||
&& !output_aliases_input(&final_path, options.input_file.as_deref())?
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!(
|
||||
"output file {} already exists (use --force to replace it)",
|
||||
final_path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let dir = temp_dir_for_target(&final_path, options.temp_dir.as_deref());
|
||||
let prefix = file_name_prefix(&final_path);
|
||||
let temp = SecureTempFile::create(&dir, &prefix)?;
|
||||
Ok(Self::File { final_path, temp })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit(mut self) -> io::Result<()> {
|
||||
if let Self::File {
|
||||
tmp_path,
|
||||
final_path,
|
||||
file,
|
||||
committed,
|
||||
} = &mut self
|
||||
{
|
||||
if let Some(mut f) = file.take() {
|
||||
f.flush()?;
|
||||
f.sync_all()?;
|
||||
match &mut self {
|
||||
Self::Stdout(s) => s.flush()?,
|
||||
Self::BufferVerify { .. } => {}
|
||||
Self::File { .. } => {}
|
||||
}
|
||||
fs::rename(&*tmp_path, &*final_path)?;
|
||||
*committed = true;
|
||||
match self {
|
||||
Self::Stdout(_) => {}
|
||||
Self::BufferVerify { temp } => temp.copy_to_stdout()?,
|
||||
Self::File { final_path, temp } => temp.persist(&final_path)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -111,33 +275,16 @@ impl Write for OutSink {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self {
|
||||
Self::Stdout(s) => s.write(buf),
|
||||
Self::File { file, .. } => file.as_mut().expect("file taken before commit").write(buf),
|
||||
Self::BufferVerify { temp } => temp.file_mut().write(buf),
|
||||
Self::File { temp, .. } => temp.file_mut().write(buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match self {
|
||||
Self::Stdout(s) => s.flush(),
|
||||
Self::File { file, .. } => match file.as_mut() {
|
||||
Some(f) => f.flush(),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OutSink {
|
||||
fn drop(&mut self) {
|
||||
if let Self::File {
|
||||
tmp_path,
|
||||
committed,
|
||||
file,
|
||||
..
|
||||
} = self
|
||||
&& !*committed
|
||||
{
|
||||
file.take(); // close the file before unlink
|
||||
let _ = fs::remove_file(tmp_path);
|
||||
Self::BufferVerify { temp } => temp.file_mut().flush(),
|
||||
Self::File { temp, .. } => temp.file_mut().flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+538
-48
@@ -7,19 +7,28 @@
|
||||
// wrong key, truncation, bad magic).
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{ErrorKind, 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()
|
||||
}
|
||||
|
||||
fn write_key_file(dir: &std::path::Path) -> std::path::PathBuf {
|
||||
let key = dir.join("key.bin");
|
||||
fs::write(&key, KEY).unwrap();
|
||||
key
|
||||
}
|
||||
|
||||
fn key_file_near(path: &std::path::Path) -> std::path::PathBuf {
|
||||
write_key_file(path.parent().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> {
|
||||
@@ -37,12 +46,13 @@ fn pseudo_random(seed: u64, n: usize) -> Vec<u8> {
|
||||
|
||||
fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Option<u32>) {
|
||||
let mut cmd = fcry();
|
||||
let key = key_file_near(ct);
|
||||
cmd.arg("-i")
|
||||
.arg(plain)
|
||||
.arg("-o")
|
||||
.arg(ct)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR);
|
||||
.arg("--key-file")
|
||||
.arg(key);
|
||||
if let Some(cs) = chunk_size {
|
||||
cmd.arg("--chunk-size").arg(cs.to_string());
|
||||
}
|
||||
@@ -55,14 +65,15 @@ fn encrypt_file(plain: &std::path::Path, ct: &std::path::Path, chunk_size: Optio
|
||||
}
|
||||
|
||||
fn decrypt_file(ct: &std::path::Path, rt: &std::path::Path) {
|
||||
let key = key_file_near(ct);
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(ct)
|
||||
.arg("-o")
|
||||
.arg(rt)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -133,10 +144,12 @@ fn roundtrip_chunk_size_one_byte() {
|
||||
#[test]
|
||||
fn roundtrip_pipe_stdin_stdout() {
|
||||
let data = pseudo_random(42, 200_000);
|
||||
let dir = TempDir::new().unwrap();
|
||||
let key = write_key_file(dir.path());
|
||||
|
||||
let mut enc = fcry()
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -152,8 +165,8 @@ fn roundtrip_pipe_stdin_stdout() {
|
||||
|
||||
let mut dec = fcry()
|
||||
.arg("-d")
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -182,19 +195,24 @@ fn rejects_wrong_key() {
|
||||
fs::write(&plain, pseudo_random(1, 1000)).unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
let wrong = "ffffffffffffffffffffffffffffffff";
|
||||
assert_ne!(wrong.as_bytes(), KEY);
|
||||
let wrong = dir.path().join("wrong.key");
|
||||
fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg("--key-file")
|
||||
.arg(wrong)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success(), "decrypt with wrong key should fail");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("WrongKey"),
|
||||
"expected distinct WrongKey error, got {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -216,8 +234,8 @@ fn rejects_tampered_header() {
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key_file_near(&ct))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -246,8 +264,8 @@ fn rejects_tampered_ciphertext() {
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key_file_near(&ct))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -275,8 +293,8 @@ fn rejects_truncated_ciphertext() {
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key_file_near(&ct))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -296,8 +314,8 @@ fn rejects_bad_magic() {
|
||||
.arg(&bogus)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("rt.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -312,25 +330,145 @@ fn rejects_bad_magic() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_raw_key() {
|
||||
fn rejects_short_key_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let key = dir.path().join("short.key");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
fs::write(&key, b"tooshort").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("c.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg("tooshort")
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"encrypt with short raw_key should fail"
|
||||
"encrypt with short key file should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_long_key_file_and_trailing_newline() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let key = dir.path().join("long.key");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
fs::write(&key, b"0123456789abcdef0123456789abcdef\n").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("c.bin"))
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success(), "long key file should fail");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("too long"),
|
||||
"expected too-long error, got {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_key_file_roundtrips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let key = dir.path().join("key.bin");
|
||||
let plain = dir.path().join("plain.bin");
|
||||
let ct = dir.path().join("ct.bin");
|
||||
let rt = dir.path().join("rt.bin");
|
||||
let key_bytes: Vec<u8> = (0..32u8).map(|b| b ^ 0x80).collect();
|
||||
let data = pseudo_random(31, 8192);
|
||||
fs::write(&key, key_bytes).unwrap();
|
||||
fs::write(&plain, &data).unwrap();
|
||||
|
||||
let enc = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
enc.status.success(),
|
||||
"non-UTF-8 key encrypt failed: {}",
|
||||
String::from_utf8_lossy(&enc.stderr)
|
||||
);
|
||||
let dec = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(&rt)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
dec.status.success(),
|
||||
"non-UTF-8 key decrypt failed: {}",
|
||||
String::from_utf8_lossy(&dec.stderr)
|
||||
);
|
||||
assert_eq!(fs::read(&rt).unwrap(), data);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn split_fifo_key_file_read_roundtrips() {
|
||||
use std::ffi::CString;
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fifo = dir.path().join("key.fifo");
|
||||
let fifo_c = CString::new(fifo.as_os_str().as_bytes()).unwrap();
|
||||
let rc = unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) };
|
||||
assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
|
||||
|
||||
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(33, 8192);
|
||||
fs::write(&plain, &data).unwrap();
|
||||
|
||||
let fifo_writer = fifo.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
let mut file = OpenOptions::new().write(true).open(&fifo_writer).unwrap();
|
||||
file.write_all(&KEY[..8]).unwrap();
|
||||
file.flush().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
file.write_all(&KEY[8..]).unwrap();
|
||||
});
|
||||
|
||||
let enc = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.arg("--key-file")
|
||||
.arg(&fifo)
|
||||
.output()
|
||||
.unwrap();
|
||||
writer.join().unwrap();
|
||||
assert!(
|
||||
enc.status.success(),
|
||||
"split FIFO key encrypt failed: {}",
|
||||
String::from_utf8_lossy(&enc.stderr)
|
||||
);
|
||||
|
||||
decrypt_file(&ct, &rt);
|
||||
assert_eq!(fs::read(&rt).unwrap(), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_passphrase_argon2id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -352,6 +490,7 @@ fn roundtrip_passphrase_argon2id() {
|
||||
.arg("8")
|
||||
.arg("--argon-passes")
|
||||
.arg("1")
|
||||
.arg("--allow-weak-kdf")
|
||||
.env("FCRY_TEST_PW", "correct horse battery staple")
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -394,6 +533,70 @@ fn roundtrip_passphrase_argon2id() {
|
||||
assert!(!bad.status.success(), "wrong passphrase should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_passphrase_kdf_rejected_without_override() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
let enc = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("c.bin"))
|
||||
.arg("--passphrase-env")
|
||||
.arg("FCRY_TEST_PW")
|
||||
.arg("--argon-memory")
|
||||
.arg("8")
|
||||
.arg("--argon-passes")
|
||||
.arg("1")
|
||||
.env("FCRY_TEST_PW", "short")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!enc.status.success(), "weak KDF/passphrase should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_argon_memory_cap_rejects_hostile_header() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
let enc = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.arg("--passphrase-env")
|
||||
.arg("FCRY_TEST_PW")
|
||||
.arg("--argon-memory")
|
||||
.arg("8")
|
||||
.arg("--argon-passes")
|
||||
.arg("1")
|
||||
.arg("--allow-weak-kdf")
|
||||
.env("FCRY_TEST_PW", "correct horse battery staple")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(enc.status.success());
|
||||
|
||||
let dec = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("--passphrase-env")
|
||||
.arg("FCRY_TEST_PW")
|
||||
.arg("--max-argon-memory-mib")
|
||||
.arg("1")
|
||||
.env("FCRY_TEST_PW", "correct horse battery staple")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!dec.status.success(), "low decrypt cap should reject file");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&dec.stderr).contains("decrypt cap"),
|
||||
"expected cap error, got {}",
|
||||
String::from_utf8_lossy(&dec.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_output_no_stale_tmp_on_failure() {
|
||||
// A failed decrypt (wrong key) should not leave the output file behind.
|
||||
@@ -404,15 +607,16 @@ fn atomic_output_no_stale_tmp_on_failure() {
|
||||
fs::write(&plain, b"hello world").unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
let wrong = "ffffffffffffffffffffffffffffffff";
|
||||
let wrong = dir.path().join("wrong.key");
|
||||
fs::write(&wrong, b"ffffffffffffffffffffffffffffffff").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(&rt)
|
||||
.arg("--raw-key")
|
||||
.arg(wrong)
|
||||
.arg("--key-file")
|
||||
.arg(&wrong)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
@@ -422,6 +626,131 @@ fn atomic_output_no_stale_tmp_on_failure() {
|
||||
assert!(!tmp.exists(), "temp file must be cleaned up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_output_refuses_without_force() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
fs::write(&ct, b"existing").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success(), "existing output should refuse");
|
||||
assert_eq!(fs::read(&ct).unwrap(), b"existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_replaces_only_after_success() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
fs::write(&ct, b"existing").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.arg("--force")
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"force encrypt failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_ne!(fs::read(&ct).unwrap(), b"existing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_place_replacement_roundtrips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("data.bin");
|
||||
let original = pseudo_random(41, 50_000);
|
||||
fs::write(&path, &original).unwrap();
|
||||
|
||||
let enc = fcry()
|
||||
.arg("-i")
|
||||
.arg(&path)
|
||||
.arg("-o")
|
||||
.arg(&path)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
enc.status.success(),
|
||||
"in-place encrypt failed: {}",
|
||||
String::from_utf8_lossy(&enc.stderr)
|
||||
);
|
||||
assert_ne!(fs::read(&path).unwrap(), original);
|
||||
|
||||
let dec = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&path)
|
||||
.arg("-o")
|
||||
.arg(&path)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
dec.status.success(),
|
||||
"in-place decrypt failed: {}",
|
||||
String::from_utf8_lossy(&dec.stderr)
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_predictable_temp_name_input_is_not_truncated() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let input = dir.path().join("out.bin.tmp");
|
||||
let output = dir.path().join("out.bin");
|
||||
let original = pseudo_random(42, 1024);
|
||||
fs::write(&input, &original).unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&output)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"encrypt failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(fs::read(&input).unwrap(), original);
|
||||
assert!(output.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn output_file_mode_is_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
let mode = fs::metadata(&ct).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-threaded pipeline + length-committed + random-access tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -433,12 +762,13 @@ fn encrypt_file_threads(
|
||||
threads: usize,
|
||||
) {
|
||||
let mut cmd = fcry();
|
||||
let key = key_file_near(ct);
|
||||
cmd.arg("-i")
|
||||
.arg(plain)
|
||||
.arg("-o")
|
||||
.arg(ct)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key)
|
||||
.arg("-j")
|
||||
.arg(threads.to_string());
|
||||
if let Some(cs) = chunk_size {
|
||||
@@ -453,14 +783,15 @@ fn encrypt_file_threads(
|
||||
}
|
||||
|
||||
fn decrypt_file_threads(ct: &std::path::Path, rt: &std::path::Path, threads: usize) {
|
||||
let key = key_file_near(ct);
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(ct)
|
||||
.arg("-o")
|
||||
.arg(rt)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key)
|
||||
.arg("-j")
|
||||
.arg(threads.to_string())
|
||||
.output()
|
||||
@@ -517,10 +848,12 @@ fn roundtrip_pipe_multi_threaded() {
|
||||
// length when we don't know the input size), but encrypt/decrypt must still
|
||||
// round-trip cleanly across the pipeline.
|
||||
let data = pseudo_random(14, 200_000);
|
||||
let dir = TempDir::new().unwrap();
|
||||
let key = write_key_file(dir.path());
|
||||
|
||||
let mut enc = fcry()
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.arg("-j")
|
||||
.arg("4")
|
||||
.stdin(Stdio::piped())
|
||||
@@ -536,16 +869,17 @@ fn roundtrip_pipe_multi_threaded() {
|
||||
String::from_utf8_lossy(&enc_out.stderr)
|
||||
);
|
||||
|
||||
// flags byte at offset 6 must be 0 (no length committed for stdin input).
|
||||
// flags byte at offset 6 must not set length commitment for stdin input.
|
||||
assert_eq!(
|
||||
enc_out.stdout[6], 0,
|
||||
enc_out.stdout[6] & 0x01,
|
||||
0,
|
||||
"stdin-encrypted file unexpectedly committed length"
|
||||
);
|
||||
|
||||
let mut dec = fcry()
|
||||
.arg("-d")
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.arg("-j")
|
||||
.arg("4")
|
||||
.stdin(Stdio::piped())
|
||||
@@ -567,6 +901,104 @@ fn roundtrip_pipe_multi_threaded() {
|
||||
assert_eq!(dec_out.stdout, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_chunk_size_zero_fails_but_empty_valid_chunk_succeeds() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let key = write_key_file(dir.path());
|
||||
let mut bad = fcry()
|
||||
.arg("--chunk-size")
|
||||
.arg("0")
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// Invalid options can make the child exit before it drains stdin.
|
||||
if let Err(err) = bad.stdin.as_mut().unwrap().write_all(b"x") {
|
||||
assert_eq!(
|
||||
err.kind(),
|
||||
ErrorKind::BrokenPipe,
|
||||
"unexpected stdin write error for failing chunk-size 0 process: {err}"
|
||||
);
|
||||
}
|
||||
let bad_out = bad.wait_with_output().unwrap();
|
||||
assert!(!bad_out.status.success(), "chunk-size 0 should fail");
|
||||
|
||||
let mut good = fcry()
|
||||
.arg("--chunk-size")
|
||||
.arg("1")
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
drop(good.stdin.take());
|
||||
let good_out = good.wait_with_output().unwrap();
|
||||
assert!(
|
||||
good_out.status.success(),
|
||||
"empty stdin with valid chunk should succeed: {}",
|
||||
String::from_utf8_lossy(&good_out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_thread_count_is_bounded() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let plain = dir.path().join("p.bin");
|
||||
let ct = dir.path().join("c.bin");
|
||||
fs::write(&plain, b"hello").unwrap();
|
||||
let out = fcry()
|
||||
.arg("-i")
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.arg("-j")
|
||||
.arg("1000000")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"huge -j should be capped, got {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("capped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_huge_chunk_header_fails_before_allocation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let forged = dir.path().join("forged.bin");
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(b"fcry");
|
||||
bytes.push(3); // version
|
||||
bytes.push(1); // alg
|
||||
bytes.push(0x02); // key commitment flag
|
||||
bytes.push(0); // reserved
|
||||
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
fs::write(&forged, bytes).unwrap();
|
||||
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&forged)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success(), "huge chunk header should fail");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("chunk_size"),
|
||||
"expected chunk_size error, got {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_input_commits_length() {
|
||||
// Encrypting from a regular file must auto-set FLAG_LENGTH_COMMITTED (bit 0
|
||||
@@ -580,8 +1012,39 @@ fn file_input_commits_length() {
|
||||
|
||||
let bytes = fs::read(&ct).unwrap();
|
||||
// Magic(4) + version(1) + alg(1) + flags(1) = byte 6
|
||||
assert_eq!(bytes[4], 2, "version should be 2");
|
||||
assert_eq!(bytes[4], 3, "version should be 3");
|
||||
assert_eq!(bytes[6] & 0x01, 0x01, "length-committed flag should be set");
|
||||
assert_eq!(bytes[6] & 0x02, 0x02, "key-committed flag should be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_downgrade_or_commitment_stripping_fails_authentication() {
|
||||
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");
|
||||
fs::write(&plain, pseudo_random(51, 1000)).unwrap();
|
||||
encrypt_file(&plain, &ct, None);
|
||||
|
||||
let mut bytes = fs::read(&ct).unwrap();
|
||||
bytes[4] = 2;
|
||||
bytes[6] &= !0x02;
|
||||
fs::write(&ct, bytes).unwrap();
|
||||
|
||||
let out = fcry()
|
||||
.arg("-d")
|
||||
.arg("-i")
|
||||
.arg(&ct)
|
||||
.arg("-o")
|
||||
.arg(&rt)
|
||||
.arg("--key-file")
|
||||
.arg(key_file_near(&ct))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"downgraded/stripped v3 header must fail authentication"
|
||||
);
|
||||
}
|
||||
|
||||
fn encrypt_random_access_fixture(
|
||||
@@ -608,8 +1071,8 @@ fn random_access_decrypt(
|
||||
.arg(ct)
|
||||
.arg("-o")
|
||||
.arg(out)
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(key_file_near(ct))
|
||||
.arg("--offset")
|
||||
.arg(offset.to_string())
|
||||
.arg("--length")
|
||||
@@ -671,10 +1134,11 @@ fn random_access_rejects_stdin_encrypted() {
|
||||
let data = pseudo_random(18, 2000);
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ct = dir.path().join("c.bin");
|
||||
let key = write_key_file(dir.path());
|
||||
|
||||
let mut enc = fcry()
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(&key)
|
||||
.arg("-o")
|
||||
.arg(&ct)
|
||||
.stdin(Stdio::piped())
|
||||
@@ -693,14 +1157,13 @@ fn random_access_rejects_stdin_encrypted() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_access_zero_length() {
|
||||
fn random_access_rejects_zero_length() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let data = pseudo_random(19, 1000);
|
||||
let ct = encrypt_random_access_fixture(dir.path(), &data, 256);
|
||||
let out = dir.path().join("empty.bin");
|
||||
let r = random_access_decrypt(&ct, &out, 500, 0);
|
||||
assert!(r.status.success(), "zero-length slice should succeed");
|
||||
assert_eq!(fs::read(&out).unwrap(), Vec::<u8>::new());
|
||||
assert!(!r.status.success(), "zero-length slice should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -723,6 +1186,33 @@ fn random_access_tampered_length_fails() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_verify_stdout_emits_nothing_on_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(61, 3 * 1024 * 1024)).unwrap();
|
||||
encrypt_file(&plain, &ct, Some(64 * 1024));
|
||||
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("--buffer-verify")
|
||||
.arg("--key-file")
|
||||
.arg(key_file_near(&ct))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success(), "truncated decrypt should fail");
|
||||
assert!(
|
||||
out.stdout.is_empty(),
|
||||
"buffer-verify must suppress partial stdout"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_threads() {
|
||||
// -j 0 is almost certainly a user mistake. Clap should reject it before
|
||||
@@ -735,8 +1225,8 @@ fn rejects_zero_threads() {
|
||||
.arg(&plain)
|
||||
.arg("-o")
|
||||
.arg(dir.path().join("c.bin"))
|
||||
.arg("--raw-key")
|
||||
.arg(KEY_STR)
|
||||
.arg("--key-file")
|
||||
.arg(write_key_file(dir.path()))
|
||||
.arg("-j")
|
||||
.arg("0")
|
||||
.output()
|
||||
|
||||
Reference in New Issue
Block a user