14 Commits
Author SHA1 Message Date
ddidderr 7cb55b67d7 [release] fcry v1.0.2 2026-06-28 11:05:10 +02:00
ddidderr 83ae3bf9c6 [deps] cargo update
Removing serde    v1.0.228
Updating arrayvec v0.7.6   -> v0.7.7
Updating bstr     v1.12.1  -> v1.12.3
Updating quote    v1.0.45  -> v1.0.46
2026-06-28 11:05:05 +02:00
ddidderr 6d6e7d3bcf [release] fcry v1.0.1 2026-06-19 22:01:29 +02:00
ddidderr e899411e65 [deps] cargo update
Removing anyhow                 v1.0.102
Removing equivalent             v1.0.2
Removing foldhash               v0.1.5
Removing hashbrown              v0.15.5
Removing hashbrown              v0.17.1
Removing id-arena               v2.3.0
Removing indexmap               v2.14.0
Removing itoa                   v1.0.18
Removing leb128fmt              v0.1.0
Removing log                    v0.4.32
Removing prettyplease           v0.2.37
Removing semver                 v1.0.28
Removing serde_json             v1.0.150
Removing unicode-xid            v0.2.6
Removing wasip2                 v1.0.4+wasi-0.2.12
Removing wasip3                 v0.4.0+wasi-0.3.0-rc-2026-01-06
Removing wasm-encoder           v0.244.0
Removing wasm-metadata          v0.244.0
Removing wasmparser             v0.244.0
Removing wit-bindgen-core       v0.51.0
Removing wit-bindgen-rust-macro v0.51.0
Removing wit-bindgen-rust       v0.51.0
Removing wit-bindgen            v0.51.0
Removing wit-bindgen            v0.57.1
Removing wit-component          v0.244.0
Removing wit-parser             v0.244.0
Removing zmij                   v1.0.21
Updating cc                     v1.2.64                         -> v1.2.65
Updating getrandom              v0.4.2                          -> v0.4.3
Updating syn                    v2.0.117                        -> v2.0.118
2026-06-19 22:01:09 +02:00
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
17 changed files with 587 additions and 649 deletions
Generated
+22 -289
View File
@@ -62,12 +62,6 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
@@ -88,9 +82,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "assert_cmd"
@@ -153,20 +147,20 @@ dependencies = [
[[package]]
name = "bstr"
version = "1.12.1"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
dependencies = [
"memchr",
"regex-automata",
"serde",
"serde_core",
]
[[package]]
name = "cc"
version = "1.2.63"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -326,12 +320,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -350,7 +338,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fcry"
version = "0.12.0"
version = "1.0.2"
dependencies = [
"argon2",
"assert_cmd",
@@ -358,7 +346,7 @@ dependencies = [
"chacha20poly1305",
"clap",
"crossbeam-channel",
"getrandom 0.4.2",
"getrandom 0.4.3",
"libc",
"rlimit",
"same-file",
@@ -375,12 +363,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -404,56 +386,21 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -469,18 +416,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@@ -493,17 +428,11 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
@@ -588,16 +517,6 @@ dependencies = [
"termtree",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -609,9 +528,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
@@ -680,21 +599,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -715,19 +619,6 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -748,9 +639,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
@@ -764,7 +655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
@@ -812,12 +703,6 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -861,58 +746,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -959,122 +792,22 @@ dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zeroize"
version = "1.8.2"
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",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fcry"
version = "0.12.0"
version = "1.0.2"
edition = "2024"
license = "MIT-0"
+1 -1
View File
@@ -27,7 +27,7 @@ clippy:
cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
cargo test --workspace
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)
}
}
+92 -12
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,
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");