12 Commits

Author SHA1 Message Date
ddidderr 71b3900518 [release] fcry v1.0.0 2026-06-12 23:24:07 +02:00
ddidderr 7c499c1de0 [deps] cargo update
Updating cc             v1.2.63           -> v1.2.64
Updating memchr         v2.8.1            -> v2.8.2
Updating wasip2         v1.0.3+wasi-0.2.9 -> v1.0.4+wasi-0.2.12
Updating zeroize_derive v1.4.3            -> v1.5.0
Updating zeroize        v1.8.2            -> v1.9.0
2026-06-12 23:23:59 +02:00
ddidderr 298a42f24b justfile: test more 2026-06-12 23:23:14 +02:00
ddidderr 304bdb8eb8 refactor: trim library exports to what callers actually use
The initial lib.rs re-exported the whole policy limit surface even
though nothing outside the library used most of it. Every unused
export is semver surface for free: tightening MAX_ARGON_PASSES or
removing architecture_argon_cap_mib later would be a breaking change
for constants nobody asked for.

Drop the re-exports with zero uses in main.rs and the tests:

- policy: DEFAULT_ARGON_DECRYPT_CAP_MIB, MIN_ARGON_MEMORY_MIB,
  MAX_ARGON_PASSES, MAX_ARGON_PARALLELISM, MAX_CHUNK_SIZE,
  MIN_PASSPHRASE_BYTES, architecture_argon_cap_mib
- secrets: MAX_PASSPHRASE_LEN

All of them stay pub inside their (private) modules because the
validation functions use them internally; they can be re-exported
deliberately if a downstream user ever needs to introspect the limits.
ArgonDecryptCap stays exported because it is the return type of the
exported resolve_argon_decrypt_cap. The header format exports
(Header, AlgId, flags, lengths) are kept as the blessed container
format API.

Breaking change for any out-of-tree user of the just-introduced lib
API, but the library has not shipped in a release yet.

Test plan: cargo clippy (default/--tests) clean; cargo test passes
all suites.
2026-06-12 22:57:08 +02:00
ddidderr 792f2f174b cleanup: share the integration-test key fixture in tests/common
The 32-byte test key "0123456789abcdef0123456789abcdef" was hardcoded
in three places: src/crypto.rs unit tests, tests/roundtrip.rs, and
tests/library_api.rs - three copies to keep in sync if the fixture
ever changes.

Add tests/common/mod.rs exposing the KEY bytes and a test_key()
SecretBytes32 constructor; roundtrip.rs and library_api.rs now pull
from it. The unit tests in src/crypto.rs cannot reach an
integration-test module and keep their own copy.

The module carries #![allow(dead_code)] because each test crate
compiles its own copy and none uses every fixture.

Test-only change.

Test plan: cargo test passes all suites (43 CLI roundtrip tests,
2 library_api tests, 13 unit tests).
2026-06-12 22:55:50 +02:00
ddidderr 7f49d034ae cleanup: derive argon cap directly in library_api range test
The range-decrypt test filled max_argon_memory_mib by building a whole
DecryptOptions::default() and reading one field out of it, which runs
thread and memory detection just to extract a u32. The library already
exports default_argon_decrypt_cap_mib(), which is the value that
default actually uses and says what is meant - call it directly.

Test-only change.

Test plan: cargo test --test library_api passes both tests.
2026-06-12 22:54:47 +02:00
ddidderr f370beb19f cleanup: move output_options into decrypt arms instead of cloning
The two decrypt match arms (range decrypt and full decrypt) are
mutually exclusive, so each can take ownership of output_options;
the clone() calls falsely suggested the value is used again later.
The encrypt path already moved it.

No behavior change beyond dropping one Option<PathBuf> clone per
decrypt invocation.
2026-06-12 22:54:29 +02:00
ddidderr 77d3037e98 docs: document read_key_file's missing permission check
read_key_file moved from main.rs into the library without a doc comment.
The world-readable key-file warning lives only in the CLI wrapper
(read_key_file_cli), so a library user calling read_key_file directly
silently loses that security check without anything telling them so.
Spell out the contract: exact-32-byte parsing, and no permission
checking - callers must do their own.

Also document normalize_passphrase (why NFC normalization happens)
since it became public API in the same move.

Comment-only change, no code touched.
2026-06-12 22:54:07 +02:00
ddidderr 655013f86e refactor: name the output/input paths passed to OutSink
OutSink::open_with_options took two adjacent positional Option<&Path>
parameters (output_file, input_file). The type system cannot tell them
apart, so a future call site that swaps them still compiles - and the
same-file-aliasing guard would then check the wrong path, silently
re-enabling the accidental-overwrite protection bypass it exists to
prevent. All three current call sites were correct; this hardens the
signature before a wrong one appears.

Introduce OutSinkPaths, a struct with named output_file/input_file
fields, so every call site must label which path plays which role.

No behavior change.

Test plan: cargo clippy (default/--tests) clean; cargo test passes
all 56 tests.
2026-06-12 22:53:29 +02:00
ddidderr 2f16e735c3 feat: split crate into library and thin CLI binary
The crypto engine was only reachable through the fcry binary; embedding
it in another Rust project meant shelling out to the CLI. Restructure
the crate so the binary sits on top of a proper library API.

- Add src/lib.rs exposing encrypt/decrypt/decrypt_range/derive_key, the
  header and policy types, and the secret-handling primitives.
- Replace the positional-argument wrapper ladder
  (encrypt_with_output_options, decrypt_with_argon_cap, ...) with
  options structs: EncryptOptions, DecryptOptions, DecryptRangeOptions
  and HeaderReadOptions. OutSinkOptions becomes the public
  OutputOptions and no longer carries the input path; the input is now
  an explicit parameter to OutSink::open_with_options so the
  same-file-aliasing guard's inputs are visible at each call site.
- File parameters take Option<PathBuf>/&Path instead of AsRef<str>, so
  non-UTF-8 paths work.
- FcryError implements Display and std::error::Error so it composes
  with anyhow/thiserror-style error handling in downstream crates.
- Move read_key_file and normalize_passphrase from main.rs into
  secrets.rs so library users get the same strict 32-byte key-file
  parsing and NFC passphrase normalization. The world-readable
  key-file warning stays in the CLI wrapper (read_key_file_cli).
- Drop now-unneeded #[allow(dead_code)] markers; ReadInfoChunk::Normal
  loses its unused byte-count payload.
- Add rustfmt.toml (StdExternalCrate grouping, crate-granularity
  imports) and reformat imports accordingly.
- Add tests/library_api.rs covering a file round-trip and a range
  decrypt through the public API with a raw key.

User-visible change: CLI behavior is unchanged except error output,
which is now human-readable Display text ("Error: wrong key or
passphrase") instead of the Rust Debug representation.

Test plan: cargo clippy (default, --tests, --benches) is clean;
cargo +nightly fmt produces no diff; cargo test passes 43 tests
including the new library_api integration tests.
2026-06-12 22:49:23 +02:00
ddidderr f44cfc6190 build: add local just workflow
Add a justfile for the common fcry developer commands. The run recipe is
variadic and uses just positional arguments so program flags are forwarded
through cargo run without needing a second separator.

Keep the default release profile debuggable and checked, then move the
stripped, LTO-enabled artifact settings into the production profile. This
keeps day-to-day release builds easier to inspect while preserving an explicit
production build recipe for optimized distributable binaries.

The clippy recipe uses workspace, all-target, all-feature coverage and denies
warnings so the task runner matches the stricter local verification path.

Test Plan:
- cargo clippy
- cargo clippy --benches
- cargo clippy --tests
- cargo +nightly fmt
- just --fmt --check
- just run --help
- just clippy
- git diff --cached --check

Refs: none
2026-06-10 21:18:10 +02:00
ddidderr 126a86ec07 cleanup: remove old review files 2026-06-10 20:51:29 +02:00
19 changed files with 621 additions and 450 deletions
Generated
+11 -11
View File
@@ -164,9 +164,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.63"
version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -350,7 +350,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fcry"
version = "0.12.0"
version = "1.0.0"
dependencies = [
"argon2",
"assert_cmd",
@@ -501,9 +501,9 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "memchr"
version = "2.8.1"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "once_cell"
@@ -863,9 +863,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen 0.57.1",
]
@@ -1055,18 +1055,18 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
+14 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "fcry"
version = "0.12.0"
version = "1.0.0"
edition = "2024"
license = "MIT-0"
@@ -36,8 +36,20 @@ windows-sys = {
}
[profile.release]
debug = true
strip = false
debug-assertions = true
overflow-checks = true
lto = false
panic = "unwind"
incremental = true
[profile.production]
inherits = "release"
debug = false
strip = true
debug-assertions = false
overflow-checks = false
lto = true
panic = "unwind"
incremental = false
codegen-units = 1
-45
View File
@@ -1,45 +0,0 @@
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 (HoangReyhanitabarRogawayVizá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.
-34
View File
@@ -1,34 +0,0 @@
# Findings
- High: attacker-controlled headers can cause large pre-auth resource use. chunk_size and Argon2 params are read before any AEAD tag is verified, then used for allocation/KDF work. A malicious file can request huge buffers or Argon2 memory/time. See
src/header.rs:107, src/crypto.rs:54, src/crypto.rs:189. Add strict max caps before allocation/KDF.
- High: output temp file handling is unsafe in hostile directories. Output uses predictable <name>.tmp plus File::create, which follows symlinks and truncates existing files. Decrypted plaintext may also be created with permissive umask-derived
permissions. See src/utils.rs:71. Use randomized create_new temp files, 0600 permissions for plaintext, and safe persist semantics.
- Medium: stdout decrypt streams verified chunks before whole-file success. File output is protected by temp-file commit, but stdout cannot roll back. A truncated valid-prefix ciphertext can emit authentic but incomplete plaintext before the final
error. See src/crypto.rs:217 and src/pipeline.rs:348. Document this sharply or add a “verify before stdout” mode.
- Medium: random-access decrypt authenticates only requested chunks. That is expected for range reads, but users may mistake success for whole-file integrity. --length 0 succeeds without authenticating any chunk, so it does not prove the key or file is
valid. See src/crypto.rs:307.
- Medium: weak passphrase choices are accepted. Empty/very short passphrases and very low Argon2 settings are possible. Defaults are strong, but user-supplied weak parameters are stored and honored. See src/main.rs:55, src/secrets.rs:172. Enforce
floors or require an explicit insecure flag.
- Medium: raw-key UX invites misuse. --raw-key is a UTF-8 command-line string, visible in process listings, and not truly arbitrary 32 raw bytes. It is documented as dangerous, but still a first-class CLI path. See src/main.rs:37. Prefer hex/base64
from file/stdin/fd, or make raw key testing-only.
- Medium: plaintext buffers are not zeroized. Keys/passphrases get protected handling, but plaintext/ciphertext chunk buffers are ordinary Vec<u8> and may leave sensitive plaintext in heap memory after decrypt/encrypt. See src/crypto.rs:127, src/
crypto.rs:210, src/pipeline.rs:48.
- Medium/Low: unchecked arithmetic and unbounded CLI knobs remain. chunk_size + TAG_LEN, random-access offsets, huge -j values, and release overflow behavior deserve explicit checked math and caps. See src/crypto.rs:190, src/pipeline.rs:61,
Cargo.toml:32.
- Low: platform hardening is partial. Unix core dumps are disabled, which is good, but Windows crash dumps are not addressed, and non-secret plaintext buffers are not protected from swap/dumps. See src/main.rs:141.
- Low: custom crypto framing needs a written threat model and test vectors. The construction is reasonable, but custom file formats are easy to misuse over time. README still says the tool is early and not thoroughly tested, and links a missing TODO.
See README.md:6.
Good Signs
The core AEAD choice is solid: XChaCha20-Poly1305 with random nonce prefix, per-chunk counters, full header as AAD, and an authenticated final chunk. The code also commits plaintext length for regular files, rejects unknown header flags, uses Argon2id
for passphrases, disables Unix core dumps, and avoids committing partial file outputs on failure.
Post-Quantum
For the current symmetric-only design, there is no RSA/ECC public-key crypto to replace. A 256-bit symmetric key is generally the right shape for post-quantum resistance; Grover-style search still leaves roughly 128-bit security. The weak link is
password entropy, not the AEAD.
If you add recipient/public-key encryption or signatures, use standardized PQC: NIST finalized FIPS 203 ML-KEM, FIPS 204 ML-DSA, and FIPS 205 SLH-DSA in 2024, and recommends migration planning now. Sources: NIST FIPS announcement
(<https://www.nist.gov/news-events/news/2024/08/announcing-approval-three-federal-information-processing-standards-fips>), NIST PQC project (<https://www.nist.gov/programs-projects/post-quantum-cryptography>).
+33
View File
@@ -0,0 +1,33 @@
set positional-arguments
run *args:
cargo run -- "$@"
build:
cargo build
build-release:
cargo build --release
build-production:
cargo build --profile production
fmt:
cargo +nightly fmt
tombi format
just --fmt
_fix:
cargo fix
cargo clippy --fix
fix: _fix fmt
clippy:
cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
cargo test --workspace --all-targets --all-features
clean:
cargo clean
+3
View File
@@ -0,0 +1,3 @@
group_imports = "StdExternalCrate"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"
+153 -172
View File
@@ -1,22 +1,35 @@
// SPDX-License-Identifier: MIT-0
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace};
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
use crate::error::*;
use crate::header::{
AlgId, FLAG_KEY_COMMITTED, FLAG_LENGTH_COMMITTED, Header, KdfParams, NONCE_PREFIX_LEN, TAG_LEN,
VERSION_CURRENT,
use std::{
fs::File,
io::{BufReader, Read, Seek, SeekFrom, Write},
path::PathBuf,
sync::Arc,
};
use crate::pipeline;
use crate::policy;
use crate::reader::{AheadReader, ReadInfoChunk};
use crate::secrets::{SecretBytes32, SecretVec};
use crate::utils::*;
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace};
use zeroize::Zeroizing;
use crate::{
error::*,
header::{
AlgId,
FLAG_KEY_COMMITTED,
FLAG_LENGTH_COMMITTED,
Header,
HeaderReadOptions,
KdfParams,
NONCE_PREFIX_LEN,
TAG_LEN,
VERSION_CURRENT,
},
pipeline,
policy,
reader::{AheadReader, ReadInfoChunk},
secrets::{SecretBytes32, SecretVec},
utils::*,
};
/// 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.
pub(crate) const NONCE_LEN: usize = 24;
@@ -104,40 +117,74 @@ 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>,
key: &SecretBytes32,
chunk_size: u32,
kdf: KdfParams,
threads: usize,
) -> Result<(), FcryError> {
encrypt_with_output_options(
input_file,
output_file,
key,
chunk_size,
kdf,
threads,
&OutSinkOptions::default(),
)
#[derive(Clone, Debug)]
pub struct EncryptOptions {
pub input_file: Option<PathBuf>,
pub output_file: Option<PathBuf>,
pub chunk_size: u32,
pub threads: usize,
pub output: OutputOptions,
}
pub fn encrypt_with_output_options<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
impl Default for EncryptOptions {
fn default() -> Self {
Self {
input_file: None,
output_file: None,
chunk_size: DEFAULT_CHUNK_SIZE,
threads: policy::normalize_worker_threads(None).0,
output: OutputOptions::default(),
}
}
}
#[derive(Clone, Debug)]
pub struct DecryptOptions {
pub input_file: Option<PathBuf>,
pub output_file: Option<PathBuf>,
pub threads: usize,
pub max_argon_memory_mib: u32,
pub output: OutputOptions,
}
impl Default for DecryptOptions {
fn default() -> Self {
Self {
input_file: None,
output_file: None,
threads: policy::normalize_worker_threads(None).0,
max_argon_memory_mib: policy::default_argon_decrypt_cap_mib(),
output: OutputOptions::default(),
}
}
}
#[derive(Clone, Debug)]
pub struct DecryptRangeOptions {
pub input_file: PathBuf,
pub output_file: Option<PathBuf>,
pub offset: u64,
pub length: u64,
pub max_argon_memory_mib: u32,
pub output: OutputOptions,
}
pub fn encrypt(
options: &EncryptOptions,
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 chunk_sz = policy::validate_chunk_size(options.chunk_size)?;
let input = open_input(options.input_file.as_deref())?;
let plaintext_length = input.length;
let mut f_plain = AheadReader::from(input.reader, chunk_sz);
let mut f_encrypted = OutSink::open_with_options(output_file, output_options)?;
let mut f_encrypted = OutSink::open_with_options(
OutSinkPaths {
output_file: options.output_file.as_deref(),
input_file: options.input_file.as_deref(),
},
&options.output,
)?;
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
getrandom::fill(&mut nonce_prefix)?;
@@ -151,7 +198,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
version: VERSION_CURRENT,
alg: AlgId::XChaCha20Poly1305,
flags,
chunk_size,
chunk_size: options.chunk_size,
kdf,
nonce_prefix,
plaintext_length,
@@ -163,7 +210,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
let aead = build_aead(key);
if threads > 1 {
if options.threads > 1 {
return pipeline::encrypt_parallel(
f_plain,
f_encrypted,
@@ -171,7 +218,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
aad,
nonce_prefix,
chunk_sz,
threads,
options.threads,
plaintext_length,
);
}
@@ -182,7 +229,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
loop {
match f_plain.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => {
ReadInfoChunk::Normal => {
let nonce = make_nonce(&nonce_prefix, counter, false);
aead.encrypt_in_place(&nonce, &aad, &mut *buf)?;
f_encrypted.write_all(&buf)?;
@@ -225,55 +272,18 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
Ok(())
}
#[allow(dead_code)]
pub fn decrypt<S: AsRef<str>>(
input_file: Option<S>,
output_file: Option<S>,
pub fn decrypt(
options: &DecryptOptions,
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_with_argon_cap(&mut reader, max_argon_memory_mib)?;
let mut reader = open_input(options.input_file.as_deref())?.reader;
let header = Header::read_with_options(
&mut reader,
HeaderReadOptions {
max_argon_memory_mib: options.max_argon_memory_mib,
},
)?;
let aad = Arc::new(header.encode());
let key = derive_key(&header.kdf, raw_key, passphrase)?;
@@ -283,11 +293,17 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
let cipher_chunk = policy::cipher_chunk_len(chunk_sz)?;
let mut f_encrypted = AheadReader::from(reader, cipher_chunk);
let mut f_plain = OutSink::open_with_options(output_file, output_options)?;
let mut f_plain = OutSink::open_with_options(
OutSinkPaths {
output_file: options.output_file.as_deref(),
input_file: options.input_file.as_deref(),
},
&options.output,
)?;
let aead = build_aead(&key);
if threads > 1 {
if options.threads > 1 {
return pipeline::decrypt_parallel(
f_encrypted,
f_plain,
@@ -295,7 +311,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
aad,
header.nonce_prefix,
cipher_chunk,
threads,
options.threads,
header.plaintext_length,
);
}
@@ -306,7 +322,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
loop {
match f_encrypted.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => {
ReadInfoChunk::Normal => {
let nonce = make_nonce(&header.nonce_prefix, counter, false);
aead.decrypt_in_place(&nonce, &aad, &mut *buf)?;
f_plain.write_all(&buf)?;
@@ -348,65 +364,22 @@ pub fn decrypt_with_output_options<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>,
pub fn decrypt_range(
options: &DecryptRangeOptions,
raw_key: Option<&SecretBytes32>,
passphrase: Option<&SecretVec>,
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 {
if options.length == 0 {
return Err(FcryError::Format("--length 0 is not allowed".into()));
}
let file = File::open(input_file)?;
let file = File::open(&options.input_file)?;
let mut reader = BufReader::new(file);
let header = Header::read_with_argon_cap(&mut reader, max_argon_memory_mib)?;
let header = Header::read_with_options(
&mut reader,
HeaderReadOptions {
max_argon_memory_mib: options.max_argon_memory_mib,
},
)?;
let aad = header.encode();
let header_len = aad.len() as u64;
@@ -416,12 +389,14 @@ pub fn decrypt_range_with_output_options<S: AsRef<str>>(
)
})?;
let end = offset
.checked_add(length)
let end = options
.offset
.checked_add(options.length)
.ok_or_else(|| FcryError::Format("offset + length overflows u64".into()))?;
if end > total {
return Err(FcryError::Format(format!(
"range [{offset}, {end}) exceeds plaintext length {total}"
"range [{}, {end}) exceeds plaintext length {total}",
options.offset
)));
}
@@ -451,9 +426,15 @@ pub fn decrypt_range_with_output_options<S: AsRef<str>>(
};
let last_idx = n_chunks - 1;
let mut out = OutSink::open_with_options(output_file, output_options)?;
let mut out = OutSink::open_with_options(
OutSinkPaths {
output_file: options.output_file.as_deref(),
input_file: Some(&options.input_file),
},
&options.output,
)?;
let start_chunk = offset / chunk_sz;
let start_chunk = options.offset / chunk_sz;
let end_chunk = (end - 1) / chunk_sz;
// Reusable buffer sized to a full chunk + tag.
@@ -490,7 +471,7 @@ pub fn decrypt_range_with_output_options<S: AsRef<str>>(
// window in absolute bytes and intersect with the requested range.
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 lo = options.offset.max(chunk_start) - chunk_start;
let hi = end.min(chunk_end) - chunk_start;
out.write_all(&buf[lo as usize..hi as usize])?;
}
@@ -506,10 +487,12 @@ mod tests {
//! must match the bytes that were authenticated when the file was
//! written. The v1 test below catches the regression where `encode()`
//! used to hard-code the current version on output.
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::header::{Header, KdfParams, NONCE_PREFIX_LEN};
use std::fs;
use tempfile::TempDir;
fn write_v1_ciphertext(path: &std::path::Path, key: &SecretBytes32, plaintext: &[u8]) {
// Build a v1 header by hand: same wire format as v2 with flags=0,
@@ -583,14 +566,13 @@ mod tests {
let plain: Vec<u8> = (0..200u8).collect();
write_v1_ciphertext(&ct, &key, &plain);
decrypt(
Some(ct.to_str().unwrap()),
Some(rt.to_str().unwrap()),
Some(&key),
None,
1,
)
.expect("v1 decrypt should succeed");
let options = DecryptOptions {
input_file: Some(ct.clone()),
output_file: Some(rt.clone()),
threads: 1,
..DecryptOptions::default()
};
decrypt(&options, Some(&key), None).expect("v1 decrypt should succeed");
let got = fs::read(&rt).unwrap();
assert_eq!(got, plain);
}
@@ -608,14 +590,13 @@ mod tests {
let plain: Vec<u8> = (0..200u8).collect();
write_v1_ciphertext(&ct, &key, &plain);
decrypt(
Some(ct.to_str().unwrap()),
Some(rt.to_str().unwrap()),
Some(&key),
None,
4,
)
.expect("v1 parallel decrypt should succeed");
let options = DecryptOptions {
input_file: Some(ct.clone()),
output_file: Some(rt.clone()),
threads: 4,
..DecryptOptions::default()
};
decrypt(&options, Some(&key), None).expect("v1 parallel decrypt should succeed");
assert_eq!(fs::read(&rt).unwrap(), plain);
}
}
+30 -2
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: MIT-0
use std::{fmt, io};
use chacha20poly1305::aead;
use std::io;
#[allow(dead_code)]
#[derive(Debug)]
pub enum FcryError {
Io(io::Error),
@@ -15,6 +15,34 @@ pub enum FcryError {
WrongKey,
}
impl fmt::Display for FcryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Crypto(_) => write!(f, "cryptographic authentication failed"),
Self::Rng(e) => write!(f, "randomness error: {e}"),
Self::Format(msg) => write!(f, "format error: {msg}"),
Self::Kdf(msg) => write!(f, "KDF error: {msg}"),
Self::Passphrase(msg) => write!(f, "passphrase error: {msg}"),
Self::WrongKey => write!(f, "wrong key or passphrase"),
}
}
}
impl std::error::Error for FcryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Rng(e) => Some(e),
Self::Crypto(_)
| Self::Format(_)
| Self::Kdf(_)
| Self::Passphrase(_)
| Self::WrongKey => None,
}
}
}
impl From<io::Error> for FcryError {
fn from(e: io::Error) -> Self {
FcryError::Io(e)
+20 -8
View File
@@ -34,8 +34,7 @@
use std::io::Read;
use crate::error::FcryError;
use crate::policy;
use crate::{error::FcryError, policy};
const MAGIC: [u8; 4] = *b"fcry";
pub const VERSION_CURRENT: u8 = 3;
@@ -152,6 +151,19 @@ pub struct Header {
pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct HeaderReadOptions {
pub max_argon_memory_mib: u32,
}
impl Default for HeaderReadOptions {
fn default() -> Self {
Self {
max_argon_memory_mib: policy::default_argon_decrypt_cap_mib(),
}
}
}
impl Header {
fn encode_without_commitment(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(104);
@@ -188,14 +200,13 @@ impl Header {
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())
Self::read_with_options(r, HeaderReadOptions::default())
}
pub fn read_with_argon_cap(
pub fn read_with_options(
r: &mut impl Read,
max_argon_memory_mib: u32,
options: HeaderReadOptions,
) -> Result<Self, FcryError> {
let mut magic = [0u8; 4];
r.read_exact(&mut magic)?;
@@ -238,7 +249,7 @@ impl Header {
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)?;
policy::validate_header_kdf(&kdf, options.max_argon_memory_mib)?;
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
r.read_exact(&mut nonce_prefix)?;
@@ -274,9 +285,10 @@ impl Header {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use super::*;
#[test]
fn roundtrip() {
let h = Header {
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT-0
mod crypto;
mod error;
mod header;
mod pipeline;
mod policy;
mod reader;
mod secrets;
mod utils;
pub use crate::{
crypto::{
DecryptOptions,
DecryptRangeOptions,
EncryptOptions,
decrypt,
decrypt_range,
derive_key,
encrypt,
},
error::FcryError,
header::{
ARGON2_SALT_LEN,
AlgId,
FLAG_KEY_COMMITTED,
FLAG_LENGTH_COMMITTED,
Header,
HeaderReadOptions,
KEY_COMMITMENT_LEN,
KdfParams,
NONCE_PREFIX_LEN,
TAG_LEN,
VERSION_CURRENT,
},
policy::{
ArgonDecryptCap,
DEFAULT_ARGON_MEMORY_MIB,
DEFAULT_ARGON_PARALLELISM,
MAX_WORKER_THREADS,
MIN_ARGON_PASSES,
default_argon_decrypt_cap_mib,
normalize_worker_threads,
resolve_argon_decrypt_cap,
validate_new_argon_params,
validate_new_passphrase,
},
secrets::{SecretBytes32, SecretVec, normalize_passphrase, read_key_file, read_passphrase_tty},
utils::{DEFAULT_CHUNK_SIZE, OutputOptions},
};
+42 -105
View File
@@ -1,25 +1,9 @@
// SPDX-License-Identifier: MIT-0
mod crypto;
mod error;
mod header;
mod pipeline;
mod policy;
mod reader;
mod secrets;
mod utils;
use crypto::*;
use error::FcryError;
use header::{ARGON2_SALT_LEN, KdfParams};
use secrets::{SecretBytes32, SecretVec, read_passphrase_tty};
use utils::{DEFAULT_CHUNK_SIZE, OutSinkOptions};
use std::path::{Path, PathBuf};
use clap::Parser;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;
use fcry::*;
use zeroize::Zeroizing;
/// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use
@@ -57,15 +41,15 @@ struct Cli {
chunk_size: u32,
/// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB).
#[clap(long, default_value_t = policy::DEFAULT_ARGON_MEMORY_MIB)]
#[clap(long, default_value_t = DEFAULT_ARGON_MEMORY_MIB)]
argon_memory: u32,
/// Argon2id passes / iterations (encryption only).
#[clap(long, default_value_t = policy::MIN_ARGON_PASSES)]
#[clap(long, default_value_t = MIN_ARGON_PASSES)]
argon_passes: u32,
/// Argon2id parallelism / lanes (encryption only).
#[clap(long, default_value_t = policy::DEFAULT_ARGON_PARALLELISM)]
#[clap(long, default_value_t = DEFAULT_ARGON_PARALLELISM)]
argon_parallelism: u32,
/// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop.
@@ -116,43 +100,6 @@ struct Cli {
length: Option<u64>,
}
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!(
"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(&buf[..32]));
Ok(key)
}
#[cfg(unix)]
fn warn_if_key_file_world_readable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
@@ -170,22 +117,17 @@ fn warn_if_key_file_world_readable(path: &Path) {
#[cfg(not(unix))]
fn warn_if_key_file_world_readable(_path: &Path) {}
fn read_key_file_cli(path: &Path) -> Result<SecretBytes32, FcryError> {
warn_if_key_file_world_readable(path);
read_key_file(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) => {
@@ -196,8 +138,7 @@ fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, F
let v = Zeroizing::new(std::env::var(var).map_err(|_| {
FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
})?);
let normalized = Zeroizing::new(v.as_str().nfc().collect::<String>());
Ok(SecretVec::from_vec(normalized.as_bytes().to_vec()))
normalize_passphrase(SecretVec::from_vec(v.as_bytes().to_vec()))
}
PassphraseSource::Tty => {
let pw = normalize_passphrase(
@@ -251,18 +192,18 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
let argon_passes = cli.argon_passes;
let argon_parallelism = cli.argon_parallelism;
let allow_weak_kdf = cli.allow_weak_kdf;
let argon_cap = policy::resolve_argon_decrypt_cap(cli.max_argon_memory_mib)?;
let argon_cap = 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);
let (threads, thread_warning) = normalize_worker_threads(cli.threads);
if let Some(requested) = thread_warning {
eprintln!(
"Warning: requested {requested} worker threads; capped at {}",
policy::MAX_WORKER_THREADS
MAX_WORKER_THREADS
);
}
let force = cli.force;
@@ -288,16 +229,15 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
));
}
let output_options = OutSinkOptions {
let output_options = OutputOptions {
force,
input_file: input.as_ref().map(PathBuf::from),
temp_dir,
buffer_verify_stdout: buffer_verify,
};
if decrypt_mode {
let raw_key = match key_file.as_deref() {
Some(path) => Some(read_key_file(path)?),
Some(path) => Some(read_key_file_cli(path)?),
None => None,
};
let pw = match &pw_src {
@@ -313,27 +253,25 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
"--offset/--length require --input-file (random-access needs a seekable file)".into(),
)
})?;
decrypt_range_with_output_options(
path,
output,
raw_key.as_ref(),
pw.as_ref(),
o,
l,
argon_cap.effective_mib,
&output_options,
)?;
let options = DecryptRangeOptions {
input_file: PathBuf::from(path),
output_file: output.as_deref().map(PathBuf::from),
offset: o,
length: l,
max_argon_memory_mib: argon_cap.effective_mib,
output: output_options,
};
decrypt_range(&options, raw_key.as_ref(), pw.as_ref())?;
}
(None, None) => {
decrypt_with_output_options(
input,
output,
raw_key.as_ref(),
pw.as_ref(),
let options = DecryptOptions {
input_file: input.as_deref().map(PathBuf::from),
output_file: output.as_deref().map(PathBuf::from),
threads,
argon_cap.effective_mib,
&output_options,
)?;
max_argon_memory_mib: argon_cap.effective_mib,
output: output_options,
};
decrypt(&options, raw_key.as_ref(), pw.as_ref())?;
}
_ => {
return Err(FcryError::Format(
@@ -345,7 +283,7 @@ 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 = policy::validate_new_argon_params(
let m_cost_kib = validate_new_argon_params(
argon_memory,
argon_passes,
argon_parallelism,
@@ -358,22 +296,21 @@ 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)?;
validate_new_passphrase(&pw, allow_weak_kdf)?;
let key = derive_key(&kdf, None, Some(&pw))?;
(key, kdf)
} else {
let key = read_key_file(key_file.as_deref().unwrap())?;
let key = read_key_file_cli(key_file.as_deref().unwrap())?;
(key, KdfParams::Raw)
};
encrypt_with_output_options(
input,
output,
&key,
let options = EncryptOptions {
input_file: input.as_deref().map(PathBuf::from),
output_file: output.as_deref().map(PathBuf::from),
chunk_size,
kdf,
threads,
&output_options,
)?;
output: output_options,
};
encrypt(&options, &key, kdf)?;
}
Ok(())
@@ -383,7 +320,7 @@ fn main() {
disable_core_dumps();
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("Error: {:?}", e);
eprintln!("Error: {e}");
std::process::exit(1);
}
}
+20 -15
View File
@@ -29,24 +29,30 @@
//! cores (cap = 32) that's ~34 MiB. Adjust `in_flight_capacity` if you need
//! a different memory/throughput tradeoff.
use std::collections::BTreeMap;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use std::{
collections::BTreeMap,
io::Write,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use chacha20poly1305::{XChaCha20Poly1305, aead::AeadInPlace};
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;
use crate::{
crypto::{bump_counter, make_nonce},
error::FcryError,
header::NONCE_PREFIX_LEN,
policy,
reader::{AheadReader, ReadInfoChunk},
utils::OutSink,
};
struct Job {
counter: u32,
last: bool,
@@ -197,7 +203,7 @@ fn run_pipeline(
}
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
match input.read_ahead(&mut buf)? {
ReadInfoChunk::Normal(_) => {
ReadInfoChunk::Normal => {
if jobs_tx
.send(Job {
counter,
@@ -370,6 +376,5 @@ fn ordered_writer(
// Compile-time check that the job type is Send+Sync (channel sends across
// threads). Kept as a footgun for future struct edits.
#[allow(dead_code)]
fn _assert_send_sync<T: Send + Sync>() {}
const _: fn() = || _assert_send_sync::<Sender<Job>>();
+5 -3
View File
@@ -4,9 +4,11 @@
use std::fs;
use crate::error::FcryError;
use crate::header::{KdfParams, TAG_LEN};
use crate::secrets::SecretVec;
use crate::{
error::FcryError,
header::{KdfParams, TAG_LEN},
secrets::SecretVec,
};
pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024;
+12 -9
View File
@@ -1,16 +1,19 @@
// SPDX-License-Identifier: MIT-0
use std::io;
use std::io::{BufRead, Read};
use std::{
io,
io::{BufRead, Read},
};
use zeroize::Zeroizing;
pub enum ReadInfoChunk {
Normal(#[allow(dead_code)] usize),
pub(crate) enum ReadInfoChunk {
Normal,
Last(usize),
Empty,
}
pub struct AheadReader {
pub(crate) struct AheadReader {
inner: Box<dyn BufRead + Send>,
buf: Zeroizing<Vec<u8>>,
bufsz: usize,
@@ -18,7 +21,7 @@ pub struct AheadReader {
}
impl AheadReader {
pub fn from(reader: Box<dyn BufRead + Send>, capacity: usize) -> Self {
pub(crate) fn from(reader: Box<dyn BufRead + Send>, capacity: usize) -> Self {
Self {
inner: reader,
buf: Zeroizing::new(vec![0; capacity]),
@@ -47,7 +50,7 @@ impl AheadReader {
Ok(total)
}
pub fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
pub(crate) fn read_ahead(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
if self.bufsz == 0 {
return self.first_read(userbuf);
}
@@ -70,7 +73,7 @@ impl AheadReader {
return Ok(ReadInfoChunk::Last(n));
}
Ok(ReadInfoChunk::Normal(n))
Ok(ReadInfoChunk::Normal)
}
fn normal_read(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
@@ -87,6 +90,6 @@ impl AheadReader {
return Ok(ReadInfoChunk::Last(userbuf_sz));
}
Ok(ReadInfoChunk::Normal(userbuf_sz))
Ok(ReadInfoChunk::Normal)
}
}
+93 -13
View File
@@ -14,9 +14,17 @@
//! Windows Console API). Reads into a pre-reserved `SecretVec` so no
//! reallocation can leave stale unzeroed copies on the heap.
use std::io;
use std::{
fs::File,
io::{self, Read},
path::Path,
};
use protected_secrets::{SecretBox as ProtectedSecretBox, SecretVec as ProtectedSecretVec};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::error::FcryError;
/// Maximum passphrase length we accept on the tty.
/// Pre-reserved so the underlying Vec never reallocates while reading.
@@ -99,6 +107,10 @@ impl SecretVec {
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
}
impl PartialEq for SecretVec {
@@ -117,6 +129,61 @@ impl PartialEq for SecretVec {
}
}
/// Reads a raw 32-byte key from `path`, rejecting files that are not exactly
/// 32 bytes long (a likely trailing newline is called out in the error).
///
/// Performs **no permission checking** on the file. Library callers who care
/// whether the key file is readable by others must check themselves; the fcry
/// CLI does this and prints a warning (see `read_key_file_cli` in the binary).
pub fn read_key_file(path: &Path) -> Result<SecretBytes32, FcryError> {
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() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
}
}
if n < 32 {
return Err(FcryError::Format(format!(
"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(&buf[..32]));
Ok(key)
}
/// Normalizes a passphrase to Unicode NFC so the same visual passphrase
/// always derives the same key regardless of how the platform or input
/// method composed it. Fails if the bytes are not valid UTF-8.
pub 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()))
}
// ============================================================================
// tty passphrase reader
// ============================================================================
@@ -132,10 +199,13 @@ pub fn read_passphrase_tty(prompt: &str) -> io::Result<SecretVec> {
#[cfg(unix)]
mod imp {
use std::{
fs::OpenOptions,
io::{self, Read, Write},
os::unix::io::AsRawFd,
};
use super::{MAX_PASSPHRASE_LEN, SecretVec};
use std::fs::OpenOptions;
use std::io::{self, Read, Write};
use std::os::unix::io::AsRawFd;
/// RAII guard that restores the original termios on drop.
struct TermiosGuard {
@@ -194,18 +264,28 @@ mod imp {
#[cfg(windows)]
mod imp {
use super::{MAX_PASSPHRASE_LEN, SecretVec};
use std::fs::OpenOptions;
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, ReadConsoleW,
SetConsoleMode,
use std::{
fs::OpenOptions,
io::{self, Write},
os::windows::io::AsRawHandle,
ptr,
};
use windows_sys::Win32::{
Foundation::HANDLE,
System::Console::{
ENABLE_ECHO_INPUT,
ENABLE_LINE_INPUT,
ENABLE_PROCESSED_INPUT,
GetConsoleMode,
ReadConsoleW,
SetConsoleMode,
},
};
use zeroize::Zeroizing;
use super::{MAX_PASSPHRASE_LEN, SecretVec};
struct ConsoleModeGuard {
handle: HANDLE,
orig: u32,
+26 -20
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: MIT-0
use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::{
fs::{self, File, OpenOptions},
io::{self, BufRead, BufReader, Seek, SeekFrom, Write},
path::{Path, PathBuf},
};
use crate::policy;
@@ -22,10 +24,10 @@ pub(crate) struct Input {
pub length: Option<u64>,
}
pub(crate) fn open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Input> {
pub(crate) fn open_input(input_file: Option<&Path>) -> io::Result<Input> {
match input_file {
Some(f) => {
let file = File::open(f.as_ref())?;
let file = File::open(f)?;
// Stat the open FD (not the path) so we can't be raced between
// stat and open.
let length = file
@@ -48,9 +50,8 @@ pub(crate) fn open_input<S: AsRef<str>>(input_file: Option<S>) -> io::Result<Inp
}
#[derive(Clone, Debug, Default)]
pub struct OutSinkOptions {
pub struct OutputOptions {
pub force: bool,
pub input_file: Option<PathBuf>,
pub temp_dir: Option<PathBuf>,
pub buffer_verify_stdout: bool,
}
@@ -197,6 +198,16 @@ fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result<bool>
}
}
/// Paths for [`OutSink::open_with_options`], named so the output and input
/// roles cannot be swapped silently at a call site (both are `Option<&Path>`).
pub(crate) struct OutSinkPaths<'a> {
/// Where the finished output is renamed to; `None` means stdout.
pub output_file: Option<&'a Path>,
/// The input being read; only consulted by the aliasing guard that
/// permits in-place overwrite of the input without `--force`.
pub input_file: Option<&'a Path>,
}
/// Output sink that supports atomic file replacement.
///
/// For file outputs: bytes are written to a private, randomly named temp file.
@@ -205,7 +216,7 @@ fn output_aliases_input(output: &Path, input: Option<&Path>) -> io::Result<bool>
/// partial/garbage file does not replace any existing target.
///
/// For stdout: behaves as a passthrough; `commit()` is a no-op.
pub enum OutSink {
pub(crate) enum OutSink {
Stdout(io::Stdout),
BufferVerify {
temp: SecureTempFile,
@@ -217,16 +228,11 @@ pub enum OutSink {
}
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,
pub(crate) fn open_with_options(
paths: OutSinkPaths<'_>,
options: &OutputOptions,
) -> io::Result<Self> {
match output_file {
match paths.output_file {
None if options.buffer_verify_stdout => {
let dir = temp_dir_for_stdout(options.temp_dir.as_deref());
Ok(Self::BufferVerify {
@@ -235,10 +241,10 @@ impl OutSink {
}
None => Ok(Self::Stdout(io::stdout())),
Some(f) => {
let final_path = PathBuf::from(f.as_ref());
let final_path = f.to_path_buf();
if final_path.exists()
&& !options.force
&& !output_aliases_input(&final_path, options.input_file.as_deref())?
&& !output_aliases_input(&final_path, paths.input_file)?
{
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
@@ -256,7 +262,7 @@ impl OutSink {
}
}
pub fn commit(mut self) -> io::Result<()> {
pub(crate) fn commit(mut self) -> io::Result<()> {
match &mut self {
Self::Stdout(s) => s.flush()?,
Self::BufferVerify { .. } => {}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: MIT-0
//
// Fixtures shared by the integration-test crates (tests/*.rs). The unit
// tests inside src/ cannot reach this module and keep their own copies.
// Each test crate compiles its own copy of this module and not every crate
// uses every fixture, so the dead-code lint misfires here.
#![allow(dead_code)]
use fcry::SecretBytes32;
/// The raw 32-byte key used by all integration tests.
pub const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
/// [`KEY`] wrapped in the `SecretBytes32` the library API takes.
pub fn test_key() -> SecretBytes32 {
let mut key = SecretBytes32::zeroed();
key.with_mut_array(|key| key.copy_from_slice(KEY));
key
}
+79
View File
@@ -0,0 +1,79 @@
use std::fs;
use fcry::{
DecryptOptions,
DecryptRangeOptions,
EncryptOptions,
KdfParams,
OutputOptions,
decrypt,
decrypt_range,
default_argon_decrypt_cap_mib,
encrypt,
};
use tempfile::TempDir;
mod common;
use common::test_key;
#[test]
fn library_file_roundtrip_raw_key() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("plain.bin");
let ct = dir.path().join("cipher.fcry");
let out = dir.path().join("out.bin");
let data: Vec<u8> = (0..=255).cycle().take(100_000).collect();
fs::write(&plain, &data).unwrap();
let key = test_key();
let encrypt_options = EncryptOptions {
input_file: Some(plain),
output_file: Some(ct.clone()),
chunk_size: 4096,
threads: 1,
output: OutputOptions::default(),
};
encrypt(&encrypt_options, &key, KdfParams::Raw).unwrap();
let decrypt_options = DecryptOptions {
input_file: Some(ct),
output_file: Some(out.clone()),
threads: 1,
..DecryptOptions::default()
};
decrypt(&decrypt_options, Some(&key), None).unwrap();
assert_eq!(fs::read(out).unwrap(), data);
}
#[test]
fn library_range_decrypt_raw_key() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("plain.bin");
let ct = dir.path().join("cipher.fcry");
let out = dir.path().join("slice.bin");
let data: Vec<u8> = (0..=255).cycle().take(50_000).collect();
fs::write(&plain, &data).unwrap();
let key = test_key();
let encrypt_options = EncryptOptions {
input_file: Some(plain),
output_file: Some(ct.clone()),
chunk_size: 1024,
threads: 2,
output: OutputOptions::default(),
};
encrypt(&encrypt_options, &key, KdfParams::Raw).unwrap();
let range_options = DecryptRangeOptions {
input_file: ct,
output_file: Some(out.clone()),
offset: 1234,
length: 20_000,
max_argon_memory_mib: default_argon_decrypt_cap_mib(),
output: OutputOptions::default(),
};
decrypt_range(&range_options, Some(&key), None).unwrap();
assert_eq!(fs::read(out).unwrap(), data[1234..21_234]);
}
+10 -11
View File
@@ -6,14 +6,17 @@
// plaintext bytes are preserved, plus a handful of failure cases (tampering,
// wrong key, truncation, bad magic).
use std::fs;
use std::io::{ErrorKind, Write};
use std::process::{Command, Stdio};
use std::{
fs,
io::{ErrorKind, Write},
process::{Command, Stdio},
};
use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;
const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
mod common;
use common::KEY;
fn fcry() -> Command {
Command::cargo_bin("fcry").unwrap()
@@ -209,8 +212,8 @@ fn rejects_wrong_key() {
.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).contains("wrong key or passphrase"),
"expected distinct wrong-key error, got {}",
String::from_utf8_lossy(&out.stderr)
);
}
@@ -422,11 +425,7 @@ fn non_utf8_key_file_roundtrips() {
#[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;
use std::{ffi::CString, fs::OpenOptions, os::unix::ffi::OsStrExt, thread, time::Duration};
let dir = TempDir::new().unwrap();
let fifo = dir.path().join("key.fifo");