ddidderr fe65e1f899 feat!: argon2id passphrases, secret hardening, atomic output, manual STREAM
This commit lands four follow-up items that were explicitly deferred in
TODO.md after the prior file-format change, plus a CLI/units cleanup
that fell out of reviewing them:

  1. Manual STREAM nonce construction (drops `stream` cargo feature).
  2. Atomic file output (`.tmp` + rename, with cleanup on failure).
  3. Argon2id KDF + passphrase prompt + matching CLI flags.
  4. Hardened secret handling: zeroize-on-drop, mlock'd buffers,
     custom cross-platform tty reader (replaces `rpassword`).

Why
---
The prior version had three concrete weaknesses that were fine for
"early development" but unacceptable past that point:

  * `--raw-key` was the only way to supply a key, exposing it in
    `/proc/$pid/cmdline`. There was no passphrase mode at all.
  * Crashes/aborts during encrypt could leave a half-written output
    file in place of (or replacing) the user's target.
  * Key material wasn't zeroed and could end up in swap or coredumps.
    rpassword's reallocating String buffers also leaked stale heap
    copies of typed passphrases that no `Zeroizing` wrapper could
    reach after the fact.

(1) Manual STREAM nonces
------------------------
Replaces `aead::stream::EncryptorBE32` / `DecryptorBE32` with
explicit `make_nonce(prefix, counter, last)` and direct
`XChaCha20Poly1305::{encrypt,decrypt}_in_place` calls. The wire format
is unchanged (XChaCha20Poly1305 STREAM-BE32 = 19-byte prefix || 4-byte
big-endian counter || 1-byte last-block flag), so files written by the
previous version still decrypt. Counter overflow is now an explicit
`Format` error rather than a panic in the upstream stream wrapper.

This removes the `stream` cargo feature from `chacha20poly1305` and
prepares the encrypt path for parallelism: with explicit nonces we can
hand chunks to a worker pool keyed by counter without the stream
wrapper's stateful API getting in the way.

(2) Atomic file output
----------------------
New `utils::OutSink` writes to `<path>.tmp`, calls `sync_all()` on
`commit()`, and renames into place. If dropped without commit (panic,
crypto/IO error, ctrl-C), the temp file is unlinked so the existing
target is untouched. Stdout output is unaffected (no temp dance).

A new integration test (`atomic_output_no_stale_tmp_on_failure`)
verifies that a failed decrypt leaves neither the final output nor
the temp file behind.

(3) Argon2id + passphrase
-------------------------
New `KdfParams::Argon2id { salt, m_cost, t_cost, p_cost }` variant
encoded into the header (and authenticated as AAD), so tampering with
KDF params fails authentication on every chunk.

CLI surface (BREAKING):
  * `--raw-key` is now optional; one of `--raw-key`, `--passphrase`,
    `--passphrase-env <VAR>` is required.
  * `--passphrase` prompts on the controlling terminal with echo off,
    and asks for confirmation when encrypting.
  * `--passphrase-env <VAR>` reads from a named env var; intended for
    non-interactive use (scripts, tests). The env-table copy is a
    known leak for that path.
  * `--argon-memory <MiB>` (default 1024 = 1 GiB), `--argon-passes`
    (default 2), `--argon-parallelism` (default 4). Names follow
    argon2 RFC 9106 terminology; memory is MiB rather than KiB to
    match how humans actually think about RAM. Defaults follow the
    "Balanced" preset for 2026-era hardware (~1.5–4 s on a laptop).
    The argon2 crate wants KiB internally, so the CLI value is
    multiplied by 1024 with overflow-check.

(4) Secret hardening
--------------------
New `secrets` module provides:

  * `SecretBytes32`: heap-allocated 32-byte buffer wrapped in
    `Zeroizing<[u8; 32]>` and mlock'd via the `region` crate.
    Field order ensures the lock guard drops *before* the buffer is
    freed (otherwise munlock would target freed memory).
  * `SecretVec`: fixed-capacity, mlock'd, zeroize-on-drop byte
    buffer. `push()` rejects writes past the reserved capacity so
    the underlying allocation never reallocates and moves — which
    would invalidate the lock and leave a stale unzeroed copy on
    the heap.
  * `read_passphrase_tty()`: direct tty reader. On Unix, opens
    `/dev/tty`, clears `ECHO` via `tcgetattr`/`tcsetattr` with an
    RAII guard that restores termios on drop. On Windows, opens
    `CONIN$`/`CONOUT$` and clears `ENABLE_ECHO_INPUT` via
    `Get/SetConsoleMode`. Reads byte-by-byte into a pre-reserved
    `SecretVec` (1024 bytes), so neither the Rust side nor the libc
    side reallocates during read. This replaces `rpassword`, which
    returned a `String` that grew by reallocation and left
    unzeroed copies of typed passphrases on the heap.

`PartialEq` on `SecretVec` is constant-time-ish (length check +
xor-or accumulate) so the confirmation comparison doesn't early-out
on the first differing byte.

`disable_core_dumps()` calls `setrlimit(CORE, 0)` on Unix; on
Windows it's a no-op (WER/minidump suppression is a per-machine
policy and intentionally not done here).

`Cli`'s secret-bearing fields are moved out into local bindings at
the top of `run()` and the `Cli` is explicitly dropped, so they
don't sit in the parsed struct for the rest of the function.
`Cli.raw_key` is `Option<Zeroizing<String>>` so the field we own
zeroes itself on drop. Clap's own intermediate copies during
parsing are an accepted leak.

Threat model — what is and isn't covered
-----------------------------------------
Covered (best-effort):
  * Secrets in coredumps                  → rlimit on Unix.
  * Secrets paged to swap or hibernation  → mlock on the AEAD key
                                            and passphrase buffer.
  * Half-written ciphertext on crash      → atomic rename.
  * Stale heap copies of typed passphrase → custom tty reader,
                                            pre-reserved buffer.
  * Stale stack/heap copies of the AEAD
    key or passphrase post-process-exit   → zeroize on drop.

Not covered (and not pretending to be):
  * Live-process attackers with ptrace or `/proc/$pid/mem` access.
  * The kernel's tty/line buffer.
  * Clap's transient String allocations during arg parsing.
  * The `environ` table copy of an env-var passphrase.
  * Swap on systems without functioning mlock or with
    `RLIMIT_MEMLOCK = 0`.

mlock is small (32 bytes + 1024 bytes — two pages at most on any
of the three target OSes), so it fits well under the typical
unprivileged `RLIMIT_MEMLOCK` of 64 KiB.

Portability
-----------
The whole binary targets Linux, macOS, and Windows 11 with the
same security properties where the OS supports them:

  * `region` crate provides cross-platform mlock/munlock.
  * `libc::tcgetattr`/`tcsetattr` covers Linux + macOS.
  * `windows-sys` covers Console API.
  * `rlimit` is gated to `cfg(unix)`.

The Windows tty path compiles in my head but is unverified on this
machine — there is no `x86_64-pc-windows-*` target installed and
no Windows runner. Treat that path as "best-effort, needs CI on
Windows" until exercised.

Files written by the previous v0.10 (Raw KDF, BE32 STREAM) are
still readable: the wire format is unchanged for that path.

Test plan
---------
Existing 17 integration tests pass unchanged. Two new tests:

  * `roundtrip_passphrase_argon2id` — encrypts and decrypts via
    `--passphrase-env` with cheap argon2 params (8 MiB / 1 pass) so
    the test stays fast; also verifies that a wrong passphrase
    fails.
  * `atomic_output_no_stale_tmp_on_failure` — wrong-key decrypt
    leaves neither the final file nor the `.tmp` in place.

Manual sanity (not automated): run with `--passphrase` on a
terminal and confirm echo is off and confirmation works.

Follow-ups (still in TODO.md)
-----------------------------
  * Multi-threaded encrypt pipeline (now feasible — manual nonces).
  * Length-committed mode + random-access decrypt fast path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:26:44 +02:00

fcry - [f]ile[cry]pt

A file en-/decryption tool for easy use.

Currently fcry uses ChaCha20Poly1305 (RFC 8439) as AEAD cipher provided by the chacha20poly1305 crate.

Status

Currently fcry is not thoroughly tested and in early stages of development.
There is a chance, that something is broken as of now.
Encryption seems to work, but due to a possible lack of understanding of some underlying methods (or misinterpretation) it could theoretically be not effective at all.

See TODO.md for further information.

S
Description
fcry - [f]ile[cry]pt: A file en-/decryption tool for easy use.
Readme 251 KiB
Languages
Rust 100%