
A gigabyte-per-second, multi-threaded file encryption engine. Achieves extreme throughput using a lock-free, triple-buffered io_uring pipeline, Rayon parallel chunking, and hardware-accelerated AEADs (AES-256-GCM / ChaCha20).
A multi-threaded AEAD encryption engine built in Rust. Encrypts and decrypts files at gigabyte-per-second throughput using a triple-buffered io_uring pipeline, parallel chunk processing via Rayon, and assembly-optimized ciphers via ring.
⚠️ DISCLAIMER: EXPERIMENTAL SOFTWARE ⚠️
This project is extremely new and currently NOT recommended for production or mission-critical use. While the cryptographic primitives (AES-256-GCM, ChaCha20-Poly1305 via ring) and format design are sound, the codebase has not undergone formal security audits or extensive real-world testing. Use at your own risk. For protecting sensitive data, consider using battle-tested tools like GnuPG, age, or OpenSSL until this project matures.
ring (assembly-optimized)--memory)seal_in_place_separate_tag / open_in_place via ring minimizes allocation in the hot loopO_DIRECT I/O, bypassing the kernel page cache for DMA-speed reads/writes on NVMe. Buffer pools use std::alloc with 4096-byte alignmentBenchmarked with cargo bench (Criterion, 10 samples per measurement). Key derivation is excluded - numbers reflect pure crypto throughput only.
Hardware:
Note on I/O: Criterion writes temporary files to /tmp, which on this system is tmpfs (RAM-backed). With O_DIRECT, the kernel cannot use real asynchronous DMA on tmpfs, so these numbers reflect cipher throughput + io_uring overhead without the DMA bypass benefit. On a real Gen4 NVMe drive, O_DIRECT eliminates page-cache double-buffering and enables DMA straight into the aligned buffer pools, which should yield significantly higher throughput.
Chunk size sweep (AES-256-GCM, 64 MiB file):
The engine uses ring (assembly-optimized AES-NI / NEON / ARMv8-CE) for cipher operations and a triple-buffered io_uring pipeline for I/O. Three pre-allocated buffer pools rotate through the pipeline: while pool A's writes are completing in the kernel, pool B is being encrypted by Rayon on the CPU, and pool C's reads are being submitted to the kernel. This overlaps I/O latency with crypto computation.
Why AES-256-GCM is faster than ChaCha20-Poly1305 on small files:
ring's AES-GCM backend exploits AES-NI + CLMUL hardware instructions available on x86-64, giving it a hardware advantage over ChaCha20 (which is a software cipher). At larger sizes both ciphers converge to ~1.0 GiB/s, indicating the bottleneck shifts from cipher throughput to I/O submission overhead.
Why peak throughput is at 1-16 MiB, not 256 MiB: Small files (1-16 MiB) have few chunks, so Rayon parallelism is efficient and the working set fits in cache. At 64-256 MiB, the io_uring pipeline is fully active (three batches in flight), but the per-SQE submission and CQE completion overhead scales with chunk count. The triple-buffer design ensures I/O and crypto overlap, partially hiding this cost.
Why ~1.0 GiB/s and not 10+ GiB/s: Modern AES-NI can push 2-4 GiB/s per core. With 12 threads, raw cipher throughput could exceed 10 GiB/s. Three factors explain the gap:
PIPELINE_DEPTH=3, only three batches rotate through the pipeline at any time. True steady-state overlap requires at least three batches; files that fit in one or two batches don't benefit from pipelining.Buffer lifecycle and safety:
Buffer pools are allocated once via std::alloc::alloc_zeroed with Layout::from_size_align(size, 4096) before the io_uring ring is created, and are reused across all pipeline iterations without reallocation. Each encrypted chunk is zero-padded to sector alignment before the O_DIRECT write. The ring is explicitly dropped before the buffer pools, ensuring the kernel never references freed memory (no UAF).
git clone https://github.com/frogsnot/concryptor.git
cd concryptor
cargo build --release
The binary will be at target/release/concryptor.
# AES-256-GCM (default), output to myfile.dat.enc
concryptor encrypt myfile.dat
# ChaCha20-Poly1305, custom output path
concryptor encrypt myfile.dat --cipher chacha -o encrypted.enc
# Custom chunk size (in MiB)
concryptor encrypt largefile.iso --chunk-size 8
# Stronger KDF (512 MiB memory cost)
concryptor encrypt secrets.tar --memory 512
# Non-interactive (skips password prompt)
concryptor encrypt myfile.dat -p "password"
Security note:
--password/-ppasses the password as a CLI argument, which is visible inpsoutput and shell history. For interactive use, omit it to get the secure hidden prompt. For scripting, prefer clearing history afterward or using a wrapper that reads from a file descriptor.
# Encrypt a directory (auto-detects, produces mydir.tar.enc)
concryptor encrypt mydir/
# With custom cipher and output
concryptor encrypt mydir/ --cipher chacha -o secrets.enc
Directory encryption creates a temporary tar archive (.concryptor-*.tar, 0600 permissions, CSPRNG-named), encrypts it, then auto-deletes the temp file. File names, directory structure, permissions, and timestamps are all inside the encrypted payload.
# Auto-strips .enc extension
concryptor decrypt myfile.dat.enc
# Custom output path
concryptor decrypt encrypted.enc -o restored.dat
# Non-interactive
concryptor decrypt myfile.dat.enc -p "password"
# Decrypt and extract in one step (auto-strips .tar.enc -> directory name)
concryptor decrypt mydir.tar.enc --extract
# Short flag, custom output directory
concryptor decrypt mydir.tar.enc -x -o restored_dir/
Without --extract, decrypting a directory archive produces the intermediate .tar file, which you can inspect or extract manually.
concryptor --help
concryptor encrypt --help
concryptor decrypt --help
All values are little-endian. The header occupies a full 4 KiB sector; each encrypted chunk slot is padded to the next 4 KiB boundary. This ensures every offset and I/O size is sector-aligned for O_DIRECT.
Offset Size Field
------ ----- ---------------------
0 10 Magic bytes "CONCRYPTOR"
10 1 Format version (4)
11 1 Cipher type (0 = AES-256-GCM, 1 = ChaCha20-Poly1305)
12 4 Chunk size (bytes, LE)
16 8 Original file size (bytes, LE)
24 16 Argon2 salt (cryptographically random, unique per file)
40 12 Base nonce (cryptographically random, unique per file)
52 4 Argon2 m_cost in KiB (LE, 0 = legacy 64 MiB)
56 4 Argon2 t_cost / iterations (LE, 0 = legacy 3)
60 4 Argon2 p_cost / parallelism (LE, 0 = legacy 4)
64 4032 Reserved (zero-padded to 4096 bytes)
4096 ... [Chunk 0: ciphertext + 16-byte tag + zero padding to sector boundary]
[Chunk 1: ciphertext + 16-byte tag + zero padding to sector boundary]
...
For 4 MiB chunks: each disk slot is ceil((4194304 + 16) / 4096) * 4096 = 4198400 bytes (4080 bytes of padding per chunk). The 4032 reserved bytes in the header are available for future features (asymmetric key slots, metadata, etc.).
The salt and base nonce are generated fresh from rand::rng() (backed by the OS CSPRNG) on every encryption. Reusing a password across files is safe because different salts produce different Argon2id keys, and different base nonces produce different per-chunk nonces.
chunk_nonce = base_nonce XOR chunk_index (TLS 1.3 style). Swapping chunks causes decryption failure because the nonce at position N won't match the nonce used to encrypt the chunk originally at position M. Note: XOR-based nonce derivation has a theoretical weakness when the same key is used across multiple streams (distinct base nonces can produce overlapping nonce spaces). This does not apply to Concryptor because each encryption generates a fresh 128-bit random salt, producing a unique Argon2id key per file. Nonce uniqueness only matters under the same key, and key reuse probability is ~2^-128 per file pair.AAD = full_aligned_header (4096) || chunk_index (8 LE) || is_final (1) (4105 bytes total). The entire 4 KiB header sector (core fields, KDF parameters, and reserved padding) is bound into every chunk's authentication tag. Modifying any header byte (cipher type, chunk size, original size, salt, nonce, KDF parameters, or reserved padding) invalidates all chunks. This prevents truncation attacks where an adversary edits original_size and removes trailing chunks, and also prevents smuggling data into the reserved padding region. Legacy v3 files are decrypted with 52-byte AAD for backward compatibility; no downgrade from v4 to v3 is possible because the version byte itself is within the authenticated AAD.0x01 for the final chunk and 0x00 for all others. This prevents two attacks:
is_final = 0x00 but decryption expects .# Run the full test suite (67 tests)
cargo test
# Run benchmarks (HTML reports in target/criterion/)
cargo bench
# Filter benchmarks
cargo bench -- "encrypt/AES"
cargo bench -- "chunk_sweep"
The test suite covers:
original_size + removed chunks)chunk_size)# From crates.io (recommended)
cargo install concryptor
# From source
git clone https://github.com/FrogSnot/Concryptor
cd Concryptor
cargo build --release
# Binary is at target/release/concryptor
This project is licensed under the GNU Affero General Public License v3.0.
| File Size | AES-256-GCM Encrypt | ChaCha20 Encrypt | AES-256-GCM Decrypt | ChaCha20 Decrypt |
|---|
| 64 KiB | 244 MiB/s | 233 MiB/s | 233 MiB/s | 234 MiB/s |
| 1 MiB | 1.08 GiB/s | 882 MiB/s | 1010 MiB/s | 876 MiB/s |
| 16 MiB | 1.10 GiB/s | 923 MiB/s | 1.06 GiB/s | 988 MiB/s |
| 64 MiB | 984 MiB/s | 935 MiB/s | 988 MiB/s | 973 MiB/s |
| 256 MiB | 1.00 GiB/s | 1015 MiB/s | 1.01 GiB/s | 1.02 GiB/s |
| Chunk Size | Throughput |
|---|
| 64 KiB | 1.01 GiB/s |
| 256 KiB | 1.05 GiB/s |
| 1 MiB | 1.07 GiB/s |
| 4 MiB | 988 MiB/s |
| 8 MiB | 988 MiB/s |
| 16 MiB | 1.00 GiB/s |
0x01is_final = 0x01 without the key.rand::rng()) for every encryption. Two encryptions of the same file with the same password produce completely different ciphertext. Nonce reuse (which is catastrophic for AES-GCM) is avoided by construction.--memory), 3 time iterations, parallelism of 4. The 256 MiB default is 4× the OWASP minimum and expensive for GPU/FPGA/ASIC attackers. KDF parameters are stored in the file header (bytes 52-63), making files self-describing — decryption always uses the correct parameters regardless of current defaults. If bytes 52-63 are all zero (legacy pre-KDF-params files), the old 64 MiB / 3 / 4 defaults are applied.| Crate | Purpose |
|---|
ring | Assembly-optimized AES-256-GCM and ChaCha20-Poly1305 AEAD |
io-uring | Linux io_uring interface for async read/write I/O |
libc | O_DIRECT flag and aligned pread/pwrite for header I/O |
argon2 | Argon2id key derivation |
rayon | Data-parallel chunk processing |
clap | CLI argument parsing |
indicatif | Terminal progress bar |
rand | Cryptographic random number generation |
zeroize | Secure memory wiping |
anyhow | Error handling |
rpassword | Hidden password input |
tar | Directory archiving and extraction |