Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
71b3900518
|
|||
|
7c499c1de0
|
|||
|
298a42f24b
|
|||
|
304bdb8eb8
|
|||
|
792f2f174b
|
|||
|
7f49d034ae
|
|||
|
f370beb19f
|
|||
|
77d3037e98
|
|||
|
655013f86e
|
|||
|
2f16e735c3
|
|||
|
f44cfc6190
|
|||
|
126a86ec07
|
Generated
+11
-11
@@ -164,9 +164,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.63"
|
version = "1.2.64"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -350,7 +350,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fcry"
|
name = "fcry"
|
||||||
version = "0.12.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
@@ -501,9 +501,9 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.1"
|
version = "2.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
@@ -863,9 +863,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasip2"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wit-bindgen 0.57.1",
|
"wit-bindgen 0.57.1",
|
||||||
]
|
]
|
||||||
@@ -1055,18 +1055,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.8.2"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zeroize_derive",
|
"zeroize_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize_derive"
|
name = "zeroize_derive"
|
||||||
version = "1.4.3"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "fcry"
|
name = "fcry"
|
||||||
version = "0.12.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT-0"
|
license = "MIT-0"
|
||||||
|
|
||||||
@@ -36,8 +36,20 @@ windows-sys = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
debug = true
|
||||||
|
strip = false
|
||||||
|
debug-assertions = true
|
||||||
|
overflow-checks = true
|
||||||
|
lto = false
|
||||||
|
panic = "unwind"
|
||||||
|
incremental = true
|
||||||
|
|
||||||
|
[profile.production]
|
||||||
|
inherits = "release"
|
||||||
debug = false
|
debug = false
|
||||||
strip = true
|
strip = true
|
||||||
|
debug-assertions = false
|
||||||
|
overflow-checks = false
|
||||||
lto = true
|
lto = true
|
||||||
panic = "unwind"
|
incremental = false
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
@@ -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 (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.
|
|
||||||
@@ -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>).
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
imports_layout = "HorizontalVertical"
|
||||||
+153
-172
@@ -1,22 +1,35 @@
|
|||||||
// SPDX-License-Identifier: MIT-0
|
// SPDX-License-Identifier: MIT-0
|
||||||
|
|
||||||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace};
|
use std::{
|
||||||
use std::fs::File;
|
fs::File,
|
||||||
use std::io::{BufReader, Read, Seek, SeekFrom, Write};
|
io::{BufReader, Read, Seek, SeekFrom, Write},
|
||||||
use std::sync::Arc;
|
path::PathBuf,
|
||||||
|
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 crate::pipeline;
|
|
||||||
use crate::policy;
|
use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce, aead::AeadInPlace};
|
||||||
use crate::reader::{AheadReader, ReadInfoChunk};
|
|
||||||
use crate::secrets::{SecretBytes32, SecretVec};
|
|
||||||
use crate::utils::*;
|
|
||||||
use zeroize::Zeroizing;
|
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
|
/// 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.
|
/// into a 4-byte big-endian counter and a 1-byte "last block" flag.
|
||||||
pub(crate) const NONCE_LEN: usize = 24;
|
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()))
|
.ok_or_else(|| FcryError::Format("STREAM counter overflow (input too large)".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[derive(Clone, Debug)]
|
||||||
pub fn encrypt<S: AsRef<str>>(
|
pub struct EncryptOptions {
|
||||||
input_file: Option<S>,
|
pub input_file: Option<PathBuf>,
|
||||||
output_file: Option<S>,
|
pub output_file: Option<PathBuf>,
|
||||||
key: &SecretBytes32,
|
pub chunk_size: u32,
|
||||||
chunk_size: u32,
|
pub threads: usize,
|
||||||
kdf: KdfParams,
|
pub output: OutputOptions,
|
||||||
threads: usize,
|
|
||||||
) -> Result<(), FcryError> {
|
|
||||||
encrypt_with_output_options(
|
|
||||||
input_file,
|
|
||||||
output_file,
|
|
||||||
key,
|
|
||||||
chunk_size,
|
|
||||||
kdf,
|
|
||||||
threads,
|
|
||||||
&OutSinkOptions::default(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encrypt_with_output_options<S: AsRef<str>>(
|
impl Default for EncryptOptions {
|
||||||
input_file: Option<S>,
|
fn default() -> Self {
|
||||||
output_file: Option<S>,
|
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,
|
key: &SecretBytes32,
|
||||||
chunk_size: u32,
|
|
||||||
kdf: KdfParams,
|
kdf: KdfParams,
|
||||||
threads: usize,
|
|
||||||
output_options: &OutSinkOptions,
|
|
||||||
) -> Result<(), FcryError> {
|
) -> Result<(), FcryError> {
|
||||||
let chunk_sz = policy::validate_chunk_size(chunk_size)?;
|
let chunk_sz = policy::validate_chunk_size(options.chunk_size)?;
|
||||||
let input = open_input(input_file)?;
|
let input = open_input(options.input_file.as_deref())?;
|
||||||
let plaintext_length = input.length;
|
let plaintext_length = input.length;
|
||||||
let mut f_plain = AheadReader::from(input.reader, chunk_sz);
|
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];
|
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
||||||
getrandom::fill(&mut nonce_prefix)?;
|
getrandom::fill(&mut nonce_prefix)?;
|
||||||
@@ -151,7 +198,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
|
|||||||
version: VERSION_CURRENT,
|
version: VERSION_CURRENT,
|
||||||
alg: AlgId::XChaCha20Poly1305,
|
alg: AlgId::XChaCha20Poly1305,
|
||||||
flags,
|
flags,
|
||||||
chunk_size,
|
chunk_size: options.chunk_size,
|
||||||
kdf,
|
kdf,
|
||||||
nonce_prefix,
|
nonce_prefix,
|
||||||
plaintext_length,
|
plaintext_length,
|
||||||
@@ -163,7 +210,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
|
|||||||
|
|
||||||
let aead = build_aead(key);
|
let aead = build_aead(key);
|
||||||
|
|
||||||
if threads > 1 {
|
if options.threads > 1 {
|
||||||
return pipeline::encrypt_parallel(
|
return pipeline::encrypt_parallel(
|
||||||
f_plain,
|
f_plain,
|
||||||
f_encrypted,
|
f_encrypted,
|
||||||
@@ -171,7 +218,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
|
|||||||
aad,
|
aad,
|
||||||
nonce_prefix,
|
nonce_prefix,
|
||||||
chunk_sz,
|
chunk_sz,
|
||||||
threads,
|
options.threads,
|
||||||
plaintext_length,
|
plaintext_length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,7 +229,7 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
match f_plain.read_ahead(&mut buf)? {
|
match f_plain.read_ahead(&mut buf)? {
|
||||||
ReadInfoChunk::Normal(_) => {
|
ReadInfoChunk::Normal => {
|
||||||
let nonce = make_nonce(&nonce_prefix, counter, false);
|
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)?;
|
f_encrypted.write_all(&buf)?;
|
||||||
@@ -225,55 +272,18 @@ pub fn encrypt_with_output_options<S: AsRef<str>>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
pub fn decrypt(
|
||||||
pub fn decrypt<S: AsRef<str>>(
|
options: &DecryptOptions,
|
||||||
input_file: Option<S>,
|
|
||||||
output_file: Option<S>,
|
|
||||||
raw_key: Option<&SecretBytes32>,
|
raw_key: Option<&SecretBytes32>,
|
||||||
passphrase: Option<&SecretVec>,
|
passphrase: Option<&SecretVec>,
|
||||||
threads: usize,
|
|
||||||
) -> Result<(), FcryError> {
|
) -> Result<(), FcryError> {
|
||||||
decrypt_with_argon_cap(
|
let mut reader = open_input(options.input_file.as_deref())?.reader;
|
||||||
input_file,
|
let header = Header::read_with_options(
|
||||||
output_file,
|
&mut reader,
|
||||||
raw_key,
|
HeaderReadOptions {
|
||||||
passphrase,
|
max_argon_memory_mib: options.max_argon_memory_mib,
|
||||||
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 aad = Arc::new(header.encode());
|
let aad = Arc::new(header.encode());
|
||||||
|
|
||||||
let key = derive_key(&header.kdf, raw_key, passphrase)?;
|
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 cipher_chunk = policy::cipher_chunk_len(chunk_sz)?;
|
||||||
|
|
||||||
let mut f_encrypted = AheadReader::from(reader, cipher_chunk);
|
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);
|
let aead = build_aead(&key);
|
||||||
|
|
||||||
if threads > 1 {
|
if options.threads > 1 {
|
||||||
return pipeline::decrypt_parallel(
|
return pipeline::decrypt_parallel(
|
||||||
f_encrypted,
|
f_encrypted,
|
||||||
f_plain,
|
f_plain,
|
||||||
@@ -295,7 +311,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
|
|||||||
aad,
|
aad,
|
||||||
header.nonce_prefix,
|
header.nonce_prefix,
|
||||||
cipher_chunk,
|
cipher_chunk,
|
||||||
threads,
|
options.threads,
|
||||||
header.plaintext_length,
|
header.plaintext_length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -306,7 +322,7 @@ pub fn decrypt_with_output_options<S: AsRef<str>>(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
match f_encrypted.read_ahead(&mut buf)? {
|
match f_encrypted.read_ahead(&mut buf)? {
|
||||||
ReadInfoChunk::Normal(_) => {
|
ReadInfoChunk::Normal => {
|
||||||
let nonce = make_nonce(&header.nonce_prefix, counter, false);
|
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)?;
|
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
|
/// 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
|
/// each ciphertext chunk lives and which chunk is the last (its nonce uses
|
||||||
/// the STREAM last-block flag).
|
/// the STREAM last-block flag).
|
||||||
#[allow(dead_code)]
|
pub fn decrypt_range(
|
||||||
pub fn decrypt_range<S: AsRef<str>>(
|
options: &DecryptRangeOptions,
|
||||||
input_file: &str,
|
|
||||||
output_file: Option<S>,
|
|
||||||
raw_key: Option<&SecretBytes32>,
|
raw_key: Option<&SecretBytes32>,
|
||||||
passphrase: Option<&SecretVec>,
|
passphrase: Option<&SecretVec>,
|
||||||
offset: u64,
|
|
||||||
length: u64,
|
|
||||||
) -> Result<(), FcryError> {
|
) -> Result<(), FcryError> {
|
||||||
decrypt_range_with_argon_cap(
|
if options.length == 0 {
|
||||||
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()));
|
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 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 aad = header.encode();
|
||||||
let header_len = aad.len() as u64;
|
let header_len = aad.len() as u64;
|
||||||
|
|
||||||
@@ -416,12 +389,14 @@ pub fn decrypt_range_with_output_options<S: AsRef<str>>(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let end = offset
|
let end = options
|
||||||
.checked_add(length)
|
.offset
|
||||||
|
.checked_add(options.length)
|
||||||
.ok_or_else(|| FcryError::Format("offset + length overflows u64".into()))?;
|
.ok_or_else(|| FcryError::Format("offset + length overflows u64".into()))?;
|
||||||
if end > total {
|
if end > total {
|
||||||
return Err(FcryError::Format(format!(
|
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 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;
|
let end_chunk = (end - 1) / chunk_sz;
|
||||||
|
|
||||||
// Reusable buffer sized to a full chunk + tag.
|
// 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.
|
// 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_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 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;
|
let hi = end.min(chunk_end) - chunk_start;
|
||||||
out.write_all(&buf[lo as usize..hi as usize])?;
|
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
|
//! must match the bytes that were authenticated when the file was
|
||||||
//! written. The v1 test below catches the regression where `encode()`
|
//! written. The v1 test below catches the regression where `encode()`
|
||||||
//! used to hard-code the current version on output.
|
//! used to hard-code the current version on output.
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::header::{Header, KdfParams, NONCE_PREFIX_LEN};
|
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]) {
|
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,
|
// 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();
|
let plain: Vec<u8> = (0..200u8).collect();
|
||||||
write_v1_ciphertext(&ct, &key, &plain);
|
write_v1_ciphertext(&ct, &key, &plain);
|
||||||
|
|
||||||
decrypt(
|
let options = DecryptOptions {
|
||||||
Some(ct.to_str().unwrap()),
|
input_file: Some(ct.clone()),
|
||||||
Some(rt.to_str().unwrap()),
|
output_file: Some(rt.clone()),
|
||||||
Some(&key),
|
threads: 1,
|
||||||
None,
|
..DecryptOptions::default()
|
||||||
1,
|
};
|
||||||
)
|
decrypt(&options, Some(&key), None).expect("v1 decrypt should succeed");
|
||||||
.expect("v1 decrypt should succeed");
|
|
||||||
let got = fs::read(&rt).unwrap();
|
let got = fs::read(&rt).unwrap();
|
||||||
assert_eq!(got, plain);
|
assert_eq!(got, plain);
|
||||||
}
|
}
|
||||||
@@ -608,14 +590,13 @@ mod tests {
|
|||||||
let plain: Vec<u8> = (0..200u8).collect();
|
let plain: Vec<u8> = (0..200u8).collect();
|
||||||
write_v1_ciphertext(&ct, &key, &plain);
|
write_v1_ciphertext(&ct, &key, &plain);
|
||||||
|
|
||||||
decrypt(
|
let options = DecryptOptions {
|
||||||
Some(ct.to_str().unwrap()),
|
input_file: Some(ct.clone()),
|
||||||
Some(rt.to_str().unwrap()),
|
output_file: Some(rt.clone()),
|
||||||
Some(&key),
|
threads: 4,
|
||||||
None,
|
..DecryptOptions::default()
|
||||||
4,
|
};
|
||||||
)
|
decrypt(&options, Some(&key), None).expect("v1 parallel decrypt should succeed");
|
||||||
.expect("v1 parallel decrypt should succeed");
|
|
||||||
assert_eq!(fs::read(&rt).unwrap(), plain);
|
assert_eq!(fs::read(&rt).unwrap(), plain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-2
@@ -1,9 +1,9 @@
|
|||||||
// SPDX-License-Identifier: MIT-0
|
// SPDX-License-Identifier: MIT-0
|
||||||
|
|
||||||
|
use std::{fmt, io};
|
||||||
|
|
||||||
use chacha20poly1305::aead;
|
use chacha20poly1305::aead;
|
||||||
use std::io;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum FcryError {
|
pub enum FcryError {
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
@@ -15,6 +15,34 @@ pub enum FcryError {
|
|||||||
WrongKey,
|
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 {
|
impl From<io::Error> for FcryError {
|
||||||
fn from(e: io::Error) -> Self {
|
fn from(e: io::Error) -> Self {
|
||||||
FcryError::Io(e)
|
FcryError::Io(e)
|
||||||
|
|||||||
+20
-8
@@ -34,8 +34,7 @@
|
|||||||
|
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
|
||||||
use crate::error::FcryError;
|
use crate::{error::FcryError, policy};
|
||||||
use crate::policy;
|
|
||||||
|
|
||||||
const MAGIC: [u8; 4] = *b"fcry";
|
const MAGIC: [u8; 4] = *b"fcry";
|
||||||
pub const VERSION_CURRENT: u8 = 3;
|
pub const VERSION_CURRENT: u8 = 3;
|
||||||
@@ -152,6 +151,19 @@ pub struct Header {
|
|||||||
pub key_commitment: Option<[u8; KEY_COMMITMENT_LEN]>,
|
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 {
|
impl Header {
|
||||||
fn encode_without_commitment(&self) -> Vec<u8> {
|
fn encode_without_commitment(&self) -> Vec<u8> {
|
||||||
let mut out = Vec::with_capacity(104);
|
let mut out = Vec::with_capacity(104);
|
||||||
@@ -188,14 +200,13 @@ impl Header {
|
|||||||
self.encode_without_commitment()
|
self.encode_without_commitment()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn read(r: &mut impl Read) -> Result<Self, FcryError> {
|
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,
|
r: &mut impl Read,
|
||||||
max_argon_memory_mib: u32,
|
options: HeaderReadOptions,
|
||||||
) -> Result<Self, FcryError> {
|
) -> Result<Self, FcryError> {
|
||||||
let mut magic = [0u8; 4];
|
let mut magic = [0u8; 4];
|
||||||
r.read_exact(&mut magic)?;
|
r.read_exact(&mut magic)?;
|
||||||
@@ -238,7 +249,7 @@ impl Header {
|
|||||||
let mut kdf_id = [0u8; 1];
|
let mut kdf_id = [0u8; 1];
|
||||||
r.read_exact(&mut kdf_id)?;
|
r.read_exact(&mut kdf_id)?;
|
||||||
let kdf = KdfParams::read_from(kdf_id[0], r)?;
|
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];
|
let mut nonce_prefix = [0u8; NONCE_PREFIX_LEN];
|
||||||
r.read_exact(&mut nonce_prefix)?;
|
r.read_exact(&mut nonce_prefix)?;
|
||||||
@@ -274,9 +285,10 @@ impl Header {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip() {
|
fn roundtrip() {
|
||||||
let h = Header {
|
let h = Header {
|
||||||
|
|||||||
+50
@@ -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
@@ -1,25 +1,9 @@
|
|||||||
// SPDX-License-Identifier: MIT-0
|
// SPDX-License-Identifier: MIT-0
|
||||||
|
|
||||||
mod crypto;
|
use std::path::{Path, PathBuf};
|
||||||
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 clap::Parser;
|
use clap::Parser;
|
||||||
use std::fs::File;
|
use fcry::*;
|
||||||
use std::io::Read;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use unicode_normalization::UnicodeNormalization;
|
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
/// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use
|
/// fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use
|
||||||
@@ -57,15 +41,15 @@ struct Cli {
|
|||||||
chunk_size: u32,
|
chunk_size: u32,
|
||||||
|
|
||||||
/// Argon2id memory in MiB (encryption only). Default: 1024 (= 1 GiB).
|
/// 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,
|
argon_memory: u32,
|
||||||
|
|
||||||
/// Argon2id passes / iterations (encryption only).
|
/// 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,
|
argon_passes: u32,
|
||||||
|
|
||||||
/// Argon2id parallelism / lanes (encryption only).
|
/// 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,
|
argon_parallelism: u32,
|
||||||
|
|
||||||
/// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop.
|
/// Permit intentionally weak passphrase/KDF parameters for tests or legacy interop.
|
||||||
@@ -116,43 +100,6 @@ struct Cli {
|
|||||||
length: Option<u64>,
|
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)]
|
#[cfg(unix)]
|
||||||
fn warn_if_key_file_world_readable(path: &Path) {
|
fn warn_if_key_file_world_readable(path: &Path) {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -170,22 +117,17 @@ fn warn_if_key_file_world_readable(path: &Path) {
|
|||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fn warn_if_key_file_world_readable(_path: &Path) {}
|
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.
|
/// Source of a passphrase: either the terminal or a named env var.
|
||||||
enum PassphraseSource {
|
enum PassphraseSource {
|
||||||
Tty,
|
Tty,
|
||||||
EnvVar(String),
|
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> {
|
fn read_passphrase(src: &PassphraseSource, confirm: bool) -> Result<SecretVec, FcryError> {
|
||||||
match src {
|
match src {
|
||||||
PassphraseSource::EnvVar(var) => {
|
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(|_| {
|
let v = Zeroizing::new(std::env::var(var).map_err(|_| {
|
||||||
FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
|
FcryError::Passphrase(format!("environment variable {var} not set or not unicode"))
|
||||||
})?);
|
})?);
|
||||||
let normalized = Zeroizing::new(v.as_str().nfc().collect::<String>());
|
normalize_passphrase(SecretVec::from_vec(v.as_bytes().to_vec()))
|
||||||
Ok(SecretVec::from_vec(normalized.as_bytes().to_vec()))
|
|
||||||
}
|
}
|
||||||
PassphraseSource::Tty => {
|
PassphraseSource::Tty => {
|
||||||
let pw = normalize_passphrase(
|
let pw = normalize_passphrase(
|
||||||
@@ -251,18 +192,18 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
|||||||
let argon_passes = cli.argon_passes;
|
let argon_passes = cli.argon_passes;
|
||||||
let argon_parallelism = cli.argon_parallelism;
|
let argon_parallelism = cli.argon_parallelism;
|
||||||
let allow_weak_kdf = cli.allow_weak_kdf;
|
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 {
|
if argon_cap.overridden && argon_cap.effective_mib > argon_cap.default_mib {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Warning: --max-argon-memory-mib raises the Argon2 decrypt trust ceiling from {} MiB to {} MiB; this can OOM constrained machines",
|
"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
|
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 {
|
if let Some(requested) = thread_warning {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Warning: requested {requested} worker threads; capped at {}",
|
"Warning: requested {requested} worker threads; capped at {}",
|
||||||
policy::MAX_WORKER_THREADS
|
MAX_WORKER_THREADS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let force = cli.force;
|
let force = cli.force;
|
||||||
@@ -288,16 +229,15 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let output_options = OutSinkOptions {
|
let output_options = OutputOptions {
|
||||||
force,
|
force,
|
||||||
input_file: input.as_ref().map(PathBuf::from),
|
|
||||||
temp_dir,
|
temp_dir,
|
||||||
buffer_verify_stdout: buffer_verify,
|
buffer_verify_stdout: buffer_verify,
|
||||||
};
|
};
|
||||||
|
|
||||||
if decrypt_mode {
|
if decrypt_mode {
|
||||||
let raw_key = match key_file.as_deref() {
|
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,
|
None => None,
|
||||||
};
|
};
|
||||||
let pw = match &pw_src {
|
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(),
|
"--offset/--length require --input-file (random-access needs a seekable file)".into(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
decrypt_range_with_output_options(
|
let options = DecryptRangeOptions {
|
||||||
path,
|
input_file: PathBuf::from(path),
|
||||||
output,
|
output_file: output.as_deref().map(PathBuf::from),
|
||||||
raw_key.as_ref(),
|
offset: o,
|
||||||
pw.as_ref(),
|
length: l,
|
||||||
o,
|
max_argon_memory_mib: argon_cap.effective_mib,
|
||||||
l,
|
output: output_options,
|
||||||
argon_cap.effective_mib,
|
};
|
||||||
&output_options,
|
decrypt_range(&options, raw_key.as_ref(), pw.as_ref())?;
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
decrypt_with_output_options(
|
let options = DecryptOptions {
|
||||||
input,
|
input_file: input.as_deref().map(PathBuf::from),
|
||||||
output,
|
output_file: output.as_deref().map(PathBuf::from),
|
||||||
raw_key.as_ref(),
|
|
||||||
pw.as_ref(),
|
|
||||||
threads,
|
threads,
|
||||||
argon_cap.effective_mib,
|
max_argon_memory_mib: argon_cap.effective_mib,
|
||||||
&output_options,
|
output: output_options,
|
||||||
)?;
|
};
|
||||||
|
decrypt(&options, raw_key.as_ref(), pw.as_ref())?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(FcryError::Format(
|
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 (key, kdf) = if let Some(src) = &pw_src {
|
||||||
let mut salt = [0u8; ARGON2_SALT_LEN];
|
let mut salt = [0u8; ARGON2_SALT_LEN];
|
||||||
getrandom::fill(&mut salt)?;
|
getrandom::fill(&mut salt)?;
|
||||||
let m_cost_kib = policy::validate_new_argon_params(
|
let m_cost_kib = validate_new_argon_params(
|
||||||
argon_memory,
|
argon_memory,
|
||||||
argon_passes,
|
argon_passes,
|
||||||
argon_parallelism,
|
argon_parallelism,
|
||||||
@@ -358,22 +296,21 @@ fn run(mut cli: Cli) -> Result<(), FcryError> {
|
|||||||
p_cost: argon_parallelism,
|
p_cost: argon_parallelism,
|
||||||
};
|
};
|
||||||
let pw = read_passphrase(src, true)?;
|
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))?;
|
let key = derive_key(&kdf, None, Some(&pw))?;
|
||||||
(key, kdf)
|
(key, kdf)
|
||||||
} else {
|
} 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)
|
(key, KdfParams::Raw)
|
||||||
};
|
};
|
||||||
encrypt_with_output_options(
|
let options = EncryptOptions {
|
||||||
input,
|
input_file: input.as_deref().map(PathBuf::from),
|
||||||
output,
|
output_file: output.as_deref().map(PathBuf::from),
|
||||||
&key,
|
|
||||||
chunk_size,
|
chunk_size,
|
||||||
kdf,
|
|
||||||
threads,
|
threads,
|
||||||
&output_options,
|
output: output_options,
|
||||||
)?;
|
};
|
||||||
|
encrypt(&options, &key, kdf)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -383,7 +320,7 @@ fn main() {
|
|||||||
disable_core_dumps();
|
disable_core_dumps();
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
if let Err(e) = run(cli) {
|
if let Err(e) = run(cli) {
|
||||||
eprintln!("Error: {:?}", e);
|
eprintln!("Error: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-15
@@ -29,24 +29,30 @@
|
|||||||
//! cores (cap = 32) that's ~34 MiB. Adjust `in_flight_capacity` if you need
|
//! cores (cap = 32) that's ~34 MiB. Adjust `in_flight_capacity` if you need
|
||||||
//! a different memory/throughput tradeoff.
|
//! a different memory/throughput tradeoff.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::{
|
||||||
use std::io::Write;
|
collections::BTreeMap,
|
||||||
use std::sync::Arc;
|
io::Write,
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
sync::{
|
||||||
use std::thread::{self, JoinHandle};
|
Arc,
|
||||||
use std::time::Duration;
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
thread::{self, JoinHandle},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use chacha20poly1305::{XChaCha20Poly1305, aead::AeadInPlace};
|
use chacha20poly1305::{XChaCha20Poly1305, aead::AeadInPlace};
|
||||||
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender, bounded};
|
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 zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
crypto::{bump_counter, make_nonce},
|
||||||
|
error::FcryError,
|
||||||
|
header::NONCE_PREFIX_LEN,
|
||||||
|
policy,
|
||||||
|
reader::{AheadReader, ReadInfoChunk},
|
||||||
|
utils::OutSink,
|
||||||
|
};
|
||||||
|
|
||||||
struct Job {
|
struct Job {
|
||||||
counter: u32,
|
counter: u32,
|
||||||
last: bool,
|
last: bool,
|
||||||
@@ -197,7 +203,7 @@ fn run_pipeline(
|
|||||||
}
|
}
|
||||||
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
|
let mut buf = Zeroizing::new(vec![0u8; chunk_sz]);
|
||||||
match input.read_ahead(&mut buf)? {
|
match input.read_ahead(&mut buf)? {
|
||||||
ReadInfoChunk::Normal(_) => {
|
ReadInfoChunk::Normal => {
|
||||||
if jobs_tx
|
if jobs_tx
|
||||||
.send(Job {
|
.send(Job {
|
||||||
counter,
|
counter,
|
||||||
@@ -370,6 +376,5 @@ fn ordered_writer(
|
|||||||
|
|
||||||
// Compile-time check that the job type is Send+Sync (channel sends across
|
// Compile-time check that the job type is Send+Sync (channel sends across
|
||||||
// threads). Kept as a footgun for future struct edits.
|
// threads). Kept as a footgun for future struct edits.
|
||||||
#[allow(dead_code)]
|
|
||||||
fn _assert_send_sync<T: Send + Sync>() {}
|
fn _assert_send_sync<T: Send + Sync>() {}
|
||||||
const _: fn() = || _assert_send_sync::<Sender<Job>>();
|
const _: fn() = || _assert_send_sync::<Sender<Job>>();
|
||||||
|
|||||||
+5
-3
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use crate::error::FcryError;
|
use crate::{
|
||||||
use crate::header::{KdfParams, TAG_LEN};
|
error::FcryError,
|
||||||
use crate::secrets::SecretVec;
|
header::{KdfParams, TAG_LEN},
|
||||||
|
secrets::SecretVec,
|
||||||
|
};
|
||||||
|
|
||||||
pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
|
pub const MAX_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
|
||||||
pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024;
|
pub const DEFAULT_CHUNK_SIZE: u32 = 1024 * 1024;
|
||||||
|
|||||||
+12
-9
@@ -1,16 +1,19 @@
|
|||||||
// SPDX-License-Identifier: MIT-0
|
// SPDX-License-Identifier: MIT-0
|
||||||
|
|
||||||
use std::io;
|
use std::{
|
||||||
use std::io::{BufRead, Read};
|
io,
|
||||||
|
io::{BufRead, Read},
|
||||||
|
};
|
||||||
|
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
pub enum ReadInfoChunk {
|
pub(crate) enum ReadInfoChunk {
|
||||||
Normal(#[allow(dead_code)] usize),
|
Normal,
|
||||||
Last(usize),
|
Last(usize),
|
||||||
Empty,
|
Empty,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AheadReader {
|
pub(crate) struct AheadReader {
|
||||||
inner: Box<dyn BufRead + Send>,
|
inner: Box<dyn BufRead + Send>,
|
||||||
buf: Zeroizing<Vec<u8>>,
|
buf: Zeroizing<Vec<u8>>,
|
||||||
bufsz: usize,
|
bufsz: usize,
|
||||||
@@ -18,7 +21,7 @@ pub struct AheadReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
Self {
|
||||||
inner: reader,
|
inner: reader,
|
||||||
buf: Zeroizing::new(vec![0; capacity]),
|
buf: Zeroizing::new(vec![0; capacity]),
|
||||||
@@ -47,7 +50,7 @@ impl AheadReader {
|
|||||||
Ok(total)
|
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 {
|
if self.bufsz == 0 {
|
||||||
return self.first_read(userbuf);
|
return self.first_read(userbuf);
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,7 @@ impl AheadReader {
|
|||||||
return Ok(ReadInfoChunk::Last(n));
|
return Ok(ReadInfoChunk::Last(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ReadInfoChunk::Normal(n))
|
Ok(ReadInfoChunk::Normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normal_read(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
|
fn normal_read(&mut self, userbuf: &mut [u8]) -> io::Result<ReadInfoChunk> {
|
||||||
@@ -87,6 +90,6 @@ impl AheadReader {
|
|||||||
return Ok(ReadInfoChunk::Last(userbuf_sz));
|
return Ok(ReadInfoChunk::Last(userbuf_sz));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ReadInfoChunk::Normal(userbuf_sz))
|
Ok(ReadInfoChunk::Normal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-13
@@ -14,9 +14,17 @@
|
|||||||
//! Windows Console API). Reads into a pre-reserved `SecretVec` so no
|
//! Windows Console API). Reads into a pre-reserved `SecretVec` so no
|
||||||
//! reallocation can leave stale unzeroed copies on the heap.
|
//! 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 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.
|
/// Maximum passphrase length we accept on the tty.
|
||||||
/// Pre-reserved so the underlying Vec never reallocates while reading.
|
/// Pre-reserved so the underlying Vec never reallocates while reading.
|
||||||
@@ -99,6 +107,10 @@ impl SecretVec {
|
|||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.len
|
self.len
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len == 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for SecretVec {
|
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
|
// tty passphrase reader
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -132,10 +199,13 @@ pub fn read_passphrase_tty(prompt: &str) -> io::Result<SecretVec> {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod imp {
|
mod imp {
|
||||||
|
use std::{
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::{self, Read, Write},
|
||||||
|
os::unix::io::AsRawFd,
|
||||||
|
};
|
||||||
|
|
||||||
use super::{MAX_PASSPHRASE_LEN, SecretVec};
|
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.
|
/// RAII guard that restores the original termios on drop.
|
||||||
struct TermiosGuard {
|
struct TermiosGuard {
|
||||||
@@ -194,18 +264,28 @@ mod imp {
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::{MAX_PASSPHRASE_LEN, SecretVec};
|
use std::{
|
||||||
use std::fs::OpenOptions;
|
fs::OpenOptions,
|
||||||
use std::io::{self, Write};
|
io::{self, Write},
|
||||||
use std::os::windows::io::AsRawHandle;
|
os::windows::io::AsRawHandle,
|
||||||
use std::ptr;
|
ptr,
|
||||||
use windows_sys::Win32::Foundation::HANDLE;
|
};
|
||||||
use windows_sys::Win32::System::Console::{
|
|
||||||
ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode, ReadConsoleW,
|
use windows_sys::Win32::{
|
||||||
SetConsoleMode,
|
Foundation::HANDLE,
|
||||||
|
System::Console::{
|
||||||
|
ENABLE_ECHO_INPUT,
|
||||||
|
ENABLE_LINE_INPUT,
|
||||||
|
ENABLE_PROCESSED_INPUT,
|
||||||
|
GetConsoleMode,
|
||||||
|
ReadConsoleW,
|
||||||
|
SetConsoleMode,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{MAX_PASSPHRASE_LEN, SecretVec};
|
||||||
|
|
||||||
struct ConsoleModeGuard {
|
struct ConsoleModeGuard {
|
||||||
handle: HANDLE,
|
handle: HANDLE,
|
||||||
orig: u32,
|
orig: u32,
|
||||||
|
|||||||
+26
-20
@@ -1,8 +1,10 @@
|
|||||||
// SPDX-License-Identifier: MIT-0
|
// SPDX-License-Identifier: MIT-0
|
||||||
|
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::{
|
||||||
use std::io::{self, BufRead, BufReader, Seek, SeekFrom, Write};
|
fs::{self, File, OpenOptions},
|
||||||
use std::path::{Path, PathBuf};
|
io::{self, BufRead, BufReader, Seek, SeekFrom, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::policy;
|
use crate::policy;
|
||||||
|
|
||||||
@@ -22,10 +24,10 @@ pub(crate) struct Input {
|
|||||||
pub length: Option<u64>,
|
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 {
|
match input_file {
|
||||||
Some(f) => {
|
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 the open FD (not the path) so we can't be raced between
|
||||||
// stat and open.
|
// stat and open.
|
||||||
let length = file
|
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)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct OutSinkOptions {
|
pub struct OutputOptions {
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
pub input_file: Option<PathBuf>,
|
|
||||||
pub temp_dir: Option<PathBuf>,
|
pub temp_dir: Option<PathBuf>,
|
||||||
pub buffer_verify_stdout: bool,
|
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.
|
/// Output sink that supports atomic file replacement.
|
||||||
///
|
///
|
||||||
/// For file outputs: bytes are written to a private, randomly named temp file.
|
/// 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.
|
/// partial/garbage file does not replace any existing target.
|
||||||
///
|
///
|
||||||
/// For stdout: behaves as a passthrough; `commit()` is a no-op.
|
/// For stdout: behaves as a passthrough; `commit()` is a no-op.
|
||||||
pub enum OutSink {
|
pub(crate) enum OutSink {
|
||||||
Stdout(io::Stdout),
|
Stdout(io::Stdout),
|
||||||
BufferVerify {
|
BufferVerify {
|
||||||
temp: SecureTempFile,
|
temp: SecureTempFile,
|
||||||
@@ -217,16 +228,11 @@ pub enum OutSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OutSink {
|
impl OutSink {
|
||||||
#[allow(dead_code)]
|
pub(crate) fn open_with_options(
|
||||||
pub fn open<S: AsRef<str>>(output_file: Option<S>) -> io::Result<Self> {
|
paths: OutSinkPaths<'_>,
|
||||||
Self::open_with_options(output_file, &OutSinkOptions::default())
|
options: &OutputOptions,
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_with_options<S: AsRef<str>>(
|
|
||||||
output_file: Option<S>,
|
|
||||||
options: &OutSinkOptions,
|
|
||||||
) -> io::Result<Self> {
|
) -> io::Result<Self> {
|
||||||
match output_file {
|
match paths.output_file {
|
||||||
None if options.buffer_verify_stdout => {
|
None if options.buffer_verify_stdout => {
|
||||||
let dir = temp_dir_for_stdout(options.temp_dir.as_deref());
|
let dir = temp_dir_for_stdout(options.temp_dir.as_deref());
|
||||||
Ok(Self::BufferVerify {
|
Ok(Self::BufferVerify {
|
||||||
@@ -235,10 +241,10 @@ impl OutSink {
|
|||||||
}
|
}
|
||||||
None => Ok(Self::Stdout(io::stdout())),
|
None => Ok(Self::Stdout(io::stdout())),
|
||||||
Some(f) => {
|
Some(f) => {
|
||||||
let final_path = PathBuf::from(f.as_ref());
|
let final_path = f.to_path_buf();
|
||||||
if final_path.exists()
|
if final_path.exists()
|
||||||
&& !options.force
|
&& !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(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::AlreadyExists,
|
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 {
|
match &mut self {
|
||||||
Self::Stdout(s) => s.flush()?,
|
Self::Stdout(s) => s.flush()?,
|
||||||
Self::BufferVerify { .. } => {}
|
Self::BufferVerify { .. } => {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -6,14 +6,17 @@
|
|||||||
// plaintext bytes are preserved, plus a handful of failure cases (tampering,
|
// plaintext bytes are preserved, plus a handful of failure cases (tampering,
|
||||||
// wrong key, truncation, bad magic).
|
// wrong key, truncation, bad magic).
|
||||||
|
|
||||||
use std::fs;
|
use std::{
|
||||||
use std::io::{ErrorKind, Write};
|
fs,
|
||||||
use std::process::{Command, Stdio};
|
io::{ErrorKind, Write},
|
||||||
|
process::{Command, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
use assert_cmd::cargo::CommandCargoExt;
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
const KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
|
mod common;
|
||||||
|
use common::KEY;
|
||||||
|
|
||||||
fn fcry() -> Command {
|
fn fcry() -> Command {
|
||||||
Command::cargo_bin("fcry").unwrap()
|
Command::cargo_bin("fcry").unwrap()
|
||||||
@@ -209,8 +212,8 @@ fn rejects_wrong_key() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!out.status.success(), "decrypt with wrong key should fail");
|
assert!(!out.status.success(), "decrypt with wrong key should fail");
|
||||||
assert!(
|
assert!(
|
||||||
String::from_utf8_lossy(&out.stderr).contains("WrongKey"),
|
String::from_utf8_lossy(&out.stderr).contains("wrong key or passphrase"),
|
||||||
"expected distinct WrongKey error, got {}",
|
"expected distinct wrong-key error, got {}",
|
||||||
String::from_utf8_lossy(&out.stderr)
|
String::from_utf8_lossy(&out.stderr)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -422,11 +425,7 @@ fn non_utf8_key_file_roundtrips() {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn split_fifo_key_file_read_roundtrips() {
|
fn split_fifo_key_file_read_roundtrips() {
|
||||||
use std::ffi::CString;
|
use std::{ffi::CString, fs::OpenOptions, os::unix::ffi::OsStrExt, thread, time::Duration};
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let fifo = dir.path().join("key.fifo");
|
let fifo = dir.path().join("key.fifo");
|
||||||
|
|||||||
Reference in New Issue
Block a user