argon2-rust
A pure-Rust port of the reference Argon2 implementation (RFC 9106), with runtime-dispatched SIMD — faster than the C reference, OpenSSL, and the popular Rust crates, on both x86-64 and aarch64.
- Zero mandatory dependencies,
#![no_std]+alloc - Bit-exact with the C reference for Argon2 versions 16 and 19 — verified
against the official KAT traces (12,304 lines of internal state per file),
the official
test.cvectors, and a live differential harness comparing tags and C error codes over the supported parameter matrix; PHC strings are checked against the reference vectors - Runtime CPU dispatch: the Argon2 fill selects AVX-512 → AVX2 →
SSE2(+SSSE3) → NEON → scalar, while PHC Base64 separately selects AVX2 →
SSSE3 → NEON → scalar. Like
base64-simd, the Base64 cascade deliberately tops out at AVX2, including on AVX-512 CPUs. Each choice is cached in one relaxed atomic; safe code can never reach an instruction set the CPU lacks. On wasm32 both have a SIMD128 backend selected at compile time (-C target-feature=+simd128), the only sound choice for a wasm module - Persistent worker pool for
lanes > 1(3 thread spawns per hash instead of the C's 48), with a hand-rolled 0.6 µs barrier - OS-native memory:
mmap+MADV_HUGEPAGEarena on Linux, secure wipe with a compiler barrier that survives-O3, optional pooled arena reuse across hashes - Full API: raw hash, PHC encode/decode, verify, d/i/id × v0x10/v0x13,
password-flavored aliases, the C error-code range (
-1..-35), plus one crate-specific-100for OS-entropy failure - Salt generation without a dependency:
hash_password_with_random_saltreads the OS CSPRNG through the right entry point per platform (getrandom(2),getentropy,CCRandomGenerateBytes,ProcessPrng, WASI preview-1random_get,/dev/urandom), each declared by hand. Targets with no known source returnError::OsRandomrather than failing the build
Security and audit status
This crate implements security-sensitive cryptographic code and has not yet received an independent third-party security audit. Its differential tests, official test vectors, sanitizers, Miri checks, and fuzzing provide useful assurance, but are not a substitute for an audit. Evaluate it against your threat model before deploying it in security-critical systems.
Please report suspected vulnerabilities privately as described in the security policy. Do not disclose security-sensitive details in a public issue.
Performance
All numbers are wall-clock medians, interleaved rep-by-rep so machine drift hits both arms equally, with tag equality asserted on every repetition. Lower is better for the ms columns; bigger is better for speedups.
vs the C reference (phc-winner-argon2)
Measured on three hosts. Every C library is identified by disassembling its
fill_block symbol, never by the OPTTARGET that was typed — see
Reading the C build.
| host | CPU | cores used | memory | OS / kernel | C compiler |
|---|---|---|---|---|---|
| M5 Max | Apple M5 Max, 12P + 6E | 1 and 4 | 128 GiB | macOS 26.5.2 | Apple cc, -O3 |
| EPYC Genoa | AMD EPYC (family 25, model 17 = Zen 4), 2.55 GHz, 1 MiB L2/core | 4 vCPU | 12.5 GiB | Cloudflare Containers standard-4, Firecracker 6.18.36 |
gcc 11.4.0, -O3 |
| Sapphire Rapids | Intel, c3-standard-4, 105 MiB L3 |
4 vCPU | — | GCE | gcc, -O3 |
Rust is rustc 1.97.1 everywhere, bench profile (opt-level=3, lto="thin",
codegen-units=1). Argon2id, tags asserted equal on every repetition.
Apple M5 Max — aarch64, NEON backend
The reference has no NEON path: src/opt.c is x86-only, so the Makefile
compiles src/ref.c. Both sides were disassembled rather than assumed:
fill_block |
instructions | SIMD mnemonics | vector-register operands |
|---|---|---|---|
C ref.c |
473 | 0 | 0 |
Rust Backend::Scalar |
1141 | 198 | 588 |
Neither row below is scalar-vs-scalar. NEON is mandatory baseline on
aarch64, so LLVM auto-vectorises the Rust portable path — 192 of those SIMD
mnemonics are eor.16b, and the prologue saves d15/d14 — while gcc leaves
ref.c entirely on general-purpose registers. The scalar row is therefore
auto-vectorised portable Rust against non-vectorised portable C, and it comes
out a tie, so LLVM's automatic vectorisation is worth approximately nothing here.
Everything below a tie is the hand-written NEON backend.
A true scalar-vs-scalar row is not reachable on this platform, and a NEON-vs-NEON row does not exist at all, because the reference has no NEON implementation to compare against. The x86-64 host below is the only place in this README where Rust SIMD is measured against C SIMD.
| config | rust scalar | rust neon | C ref.c |
C / neon | C / scalar |
|---|---|---|---|---|---|
| 64 MiB, t=1, p=1 | 20.64 ms | 13.64 ms | 20.88 ms | 1.53x | 1.01x |
| 64 MiB, t=3, p=1 | 66.79 ms | 45.79 ms | 68.93 ms | 1.51x | 1.03x |
| 64 MiB, t=1, p=4 | 6.01 ms | 3.93 ms | 5.75 ms | 1.46x | 0.96x |
| 256 MiB, t=1, p=1 | 94.00 ms | 66.79 ms | 96.43 ms | 1.44x | 1.03x |
| 256 MiB, t=3, p=4 | 80.53 ms | 62.52 ms | 83.84 ms | 1.34x | 1.04x |
The port costs nothing against the C it is a port of (0.96x – 1.04x). NEON adds 1.34x – 1.53x on top.
These NEON numbers depend on FEAT_SHA3. fill_segment compiles to 992
xar.2d here — the rotate-and-XOR instruction, which folds a rotation and an
XOR into one op and is exactly what BLAKE2's round wants. rustc enables sha3
for aarch64-apple-darwin but not for aarch64-unknown-linux-gnu, so a
generic aarch64 Linux build lowers those rotations another way and should not be
assumed to reach these ratios. See src/fill_block/neon.rs — ROR32_DEFAULT
picks the spelling by cfg(target_feature = "sha3").
AMD EPYC Genoa (Zen 4) — x86-64, AVX-512 backend
The hardest comparison available: the reference ships hand-written AVX-512
intrinsics, and this CPU runs them. C built in-place with the reference's own
default OPTTARGET=native; the probe confirms src/opt.c, avx512, fill_block 3497 B: evex=498 pmuludq=32.
| config | rust scalar | rust avx512 | C AVX-512 | C / rust |
|---|---|---|---|---|
| 64 MiB, t=1, p=1 | 83.30 ms | 43.93 ms | 55.99 ms | 1.27x |
| 64 MiB, t=3, p=1 | 182.55 ms | 77.93 ms | 103.47 ms | 1.33x |
| 64 MiB, t=1, p=4 | 36.77 ms | 25.82 ms | 33.84 ms | 1.31x |
| 256 MiB, t=1, p=1 | 349.63 ms | 194.38 ms | 242.24 ms | 1.25x |
| 256 MiB, t=3, p=4 | 266.63 ms | 151.59 ms | 206.89 ms | 1.36x |
Five independent runs on this host — against an explicitly-flagged
-mavx512f -mavx512bw -mavx512dq -mavx512vl build and against -march=native —
put the ratio at 1.17x – 1.49x, with no run disagreeing about the direction.
Generic vs znver3 tuning made no difference beyond noise.
Note that gcc -march=native is worth checking rather than trusting here: gcc
11.4 predates Zen 4, so it reports -march=znver3, yet the probe still finds
evex=498. Native detection enables the CPUID feature bits regardless and only
falls back for tuning. The instruction counts settle it; the flag name does not.
Every Rust backend was also timed against the C built at the same ISA tier,
interleaved rep-by-rep, two independent runs over the five configurations
(ARGON2_BENCH_SUMMARY_BACKEND selects the Rust arm so the pair matches):
| Rust backend | C build (probe-verified) | whole-hash range |
|---|---|---|
| scalar | ref.c, auto-vectorised to SSE2 |
0.89x – 1.04x |
| sse2 | opt.c SSE2, vex256=14 |
1.09x – 1.31x |
| avx2 | opt.c AVX2, vex256=288 |
1.05x – 1.31x |
| avx512 | opt.c AVX-512, evex=496 |
1.18x – 1.44x |
The scalar row is the honest loss: ref.c at the x86-64 baseline is not
scalar, because the compiler auto-vectorises it to SSE2, so Backend::Scalar
is a portable fallback racing vectorised C and loses by up to 11%.
That table doubles as its own noise check. Timing scalar against scalar puts
the same code in both columns, and the ratio came back 0.98x – 1.02x, so this
method resolves about ±2% on this host. Ratios below that are not differences.
Sapphire Rapids — x86-64, AVX-512 backend
C built with its own -march=native — its fill_block contains 503 AVX-512
EVEX instructions, so this is the best the C can do on this machine.
| config | C (native AVX-512) | argon2-rust | speedup |
|---|---|---|---|
| 64 MiB, t=1, p=1 | 50.0 ms | 24.9 ms | 2.01x |
| 64 MiB, t=1, p=4 | 22.0 ms | 13.1 ms | 1.69x |
| 64 MiB, t=3, p=1 | 98.3 ms | 65.2 ms | 1.51x |
| 64 MiB, t=3, p=4 | 40.9 ms | 28.1 ms | 1.45x |
| 256 MiB, t=1, p=1 | 216.4 ms | 105.8 ms | 2.05x |
| 256 MiB, t=1, p=4 | 91.6 ms | 49.7 ms | 1.84x |
| 256 MiB, t=3, p=1 | 471.0 ms | 309.9 ms | 1.52x |
| 256 MiB, t=3, p=4 | 173.0 ms | 120.0 ms | 1.44x |
Every reachable path wins — each Rust backend was also compared against the C built for the same ISA tier (60+ cells, all three variants):
| Rust backend | C build | whole-hash range | fill kernel only |
|---|---|---|---|
| scalar | ref.c (gcc auto-vectorized to AVX2) |
1.11x – 1.52x | 1.00x – 1.10x |
| sse2 | opt.c SSE2 / SSSE3 |
1.14x – 1.54x | 1.03x – 1.19x |
| avx2 | opt.c AVX2 |
1.23x – 1.56x | 1.09x – 1.13x |
| avx512 | opt.c AVX-512 |
1.44x – 2.05x | 1.16x – 1.28x |
The fill-kernel win comes from a fully-unrolled round schedule (823 → 574
dynamic instructions per block) plus software prefetch of the reference block
on the data-independent path. The fixed-cost win (2.6x – 7.6x) comes from the
mmap+hugepage arena and the persistent worker pool — the C pays malloc,
page faults, and 48 pthread_create/join cycles per hash at t=3, p=4;
this crate pays one mmap and 3 spawns.
Reading the C build
A comparison against "the C reference" means nothing until you know which C got
compiled. OPTTARGET is not that answer: the Makefile silently falls back to
src/ref.c when the -march probe fails, and ref.c at the x86-64 baseline is
auto-vectorised to SSE2 anyway. So the label is never typed — it is read out of
the binary. benches/support/cref_isa.rs finds the fill_block symbol and
counts EVEX prefixes, VEX.L=1 prefixes and pshufb, and prints the counts
next to the label so the classification can be checked instead of trusted:
C reference : .../libargon2.so.1 (src/opt.c, avx512, fill_block 3497 B:
evex=498 vex256=1 pshufb=0 pmuludq=32)
pmuludq=32 is the tell for opt.c — two per BLAKE2 round, sixteen rounds. An
auto-vectorised ref.c emits many more.
Does loading the C through dlopen bias it?
No, and this was measured rather than argued. The bench loads the reference with
dlopen instead of linking it, and the Makefile compiles the shared library with
-fPIC -fvisibility=hidden while the static archive gets neither — so the .so
is genuinely different machine code, not just a different call path.
One self-timed C harness was built three ways from identical source and run round-robin at process granularity, so drift hits all three equally:
| link mode | M5 Max | EPYC Genoa |
|---|---|---|
libargon2.a, direct call |
1.000x | 1.000x |
-largon2 shared, PLT |
0.990x – 0.998x | 0.978x – 1.012x |
dlopen, as the bench does |
0.985x – 1.003x | 0.965x – 0.985x |
dlopen lands faster than static on most rows and the sign of the difference
flips between configurations, which is the signature of noise rather than a
systematic cost. There is nothing in a whole hash for linkage to charge except
one indirect call.
vs OpenSSL 3.5 (EVP_KDF Argon2, thread pool enabled)
Sapphire Rapids, in-process EVP_KDF_derive timing, tags verified identical:
| config | OpenSSL 3.5.5 | argon2-rust | speedup |
|---|---|---|---|
| 64 MiB, t=1, p=1 | 85.4 ms | 24.1 ms | 3.5x |
| 64 MiB, t=3, p=4 | 94.3 ms | 25.6 ms | 3.7x |
| 256 MiB, t=1, p=1 | 356.7 ms | 103.8 ms | 3.4x |
| 256 MiB, t=3, p=4 | 384.8 ms | 111.7 ms | 3.4x |
(OpenSSL's Argon2 has no SIMD fill at all.)
vs the popular Rust crates
The two most-downloaded Argon2 crates on crates.io —
argon2 (RustCrypto, 41.6M downloads) and
rust-argon2 (19.7M downloads) —
interleaved, tags asserted identical, Argon2id:
x86-64 (Sapphire Rapids, AVX-512 backend)
| config | argon2-rust | RustCrypto | rust-argon2 | vs RustCrypto | vs rust-argon2 |
|---|---|---|---|---|---|
| 64 MiB, t=1, p=1 | 23.6 ms | 57.0 | 85.8 | 2.4x | 3.6x |
| 64 MiB, t=3, p=4 | 23.7 ms | 133.7 | 199.1 | 5.6x | 8.4x |
| 256 MiB, t=1, p=1 | 99.3 ms | 253.0 | 367.3 | 2.5x | 3.7x |
| 256 MiB, t=3, p=4 | 115.7 ms | 600.9 | 874.3 | 5.2x | 7.6x |
aarch64 (Apple Silicon, NEON backend)
| config | argon2-rust | RustCrypto | rust-argon2 | vs RustCrypto | vs rust-argon2 |
|---|---|---|---|---|---|
| 64 MiB, t=1, p=1 | 14.5 ms | 22.4 | 26.7 | 1.6x | 1.9x |
| 64 MiB, t=3, p=4 | 13.7 ms | 65.0 | 83.5 | 4.8x | 6.1x |
| 256 MiB, t=1, p=1 | 66.9 ms | 93.2 | 112.2 | 1.4x | 1.7x |
| 256 MiB, t=3, p=4 | 62.0 ms | 292.0 | 351.2 | 4.7x | 5.7x |
Both crates compute lanes sequentially even at p > 1, so the margin grows
with parallelism; the single-thread rows are the honest kernel-vs-kernel
comparison.
Quick start
[]
= "1"
use ;
use ;
// Every setter is optional; unset ones keep `Params::DEFAULT`'s value.
let params = builder
.memory
.passes
.lanes
.tag_len
.build?;
let argon2 = new;
let mut tag = ;
argon2.hash_into?;
// PHC string format
let encoded = argon2.hash_encoded?;
assert!;
// Or let the crate draw a fresh 16-byte salt from the OS:
let encoded = argon2.hash_password_with_random_salt?;
Verifying a string you did not write? m_cost is ten digits of attacker-chosen
decimal, and — exactly as in the C — nothing stands between it and the
allocation, all 4 TiB of it. Bound it:
// No stored hash should exceed this.
let ceiling = builder
.memory
.passes
.lanes
.tag_len
.build?;
verify_encoded_bounded?;
That bounds the allocation, not just the cost numbers: the length of the
string is checked against what the ceiling could have produced before the
decoder runs, since the decoder sizes its salt and tag buffers from the input.
_with_ad and pooled Hasher spellings exist too.
Memory is not the only resource p spends. Decoding sets threads = lanes, so
the string also picks how many OS threads the verify spawns — which
ceiling.threads() bounds. Leaving .threads() unset makes it track .lanes(),
so the ceiling above caps both together at 4; set .threads(1) on the builder to
accept wide strings without spawning wide. Clamping never changes a verdict: only
lanes feeds the tag.
Hashing many passwords? Pool the arena — one allocation total instead of one per hash:
let mut hasher = argon2.hasher;
for in &credentials
How the dispatch works
first hash in the process
│
is_*_feature_detected! cascade
┌──────────┬────────┬─────────┬────────┬────────┐
▼ ▼ ▼ ▼ ▼ ▼
AVX-512 → AVX2 → SSE2 → NEON → scalar (cached in one AtomicU8)
│ (SSSE3 probed separately at runtime)
│ aarch64: NEON is compile-time on Apple/Windows (measured
│ fastest there); other aarch64 hosts run a one-time ~4 ms
│ shootout — Neoverse N1 gets scalar, Apple-class cores NEON
│ wasm32: SIMD128 selected at compile time (1.5-1.6x over
│ scalar under wasmtime); on wasm32-wasip1-threads the worker
│ pool runs for real — 3.9x from 4 lanes
▼
one fn-pointer resolve per hash, one indirect call per *segment*
(thousands of blocks) — dispatch cost is one relaxed atomic load
Safe API never names a backend, so safe code can never execute an instruction
the CPU lacks. Explicit-backend entry points exist for testing and are
unsafe fn (with compile_fail doctests proving the boundary).
Feature flags
| feature | default | what it does |
|---|---|---|
std |
✓ | runtime CPU detection (falls back to compile-time cfgs without it) |
parallel |
✓ | multi-lane fill on the persistent worker pool |
zeroize-memory |
✓ | wipe internal buffers (the C's FLAG_clear_internal_memory) |
bump-alloc |
internal test/bench control for Workspace; does not change stable hashing paths |
|
internal-api |
test/bench hooks (__internal); never enable in production |
--no-default-features builds for no_std (with alloc), including e.g.
thumbv7em-none-eabi.
SemVer policy
From 1.0.0 onward this crate follows Semantic
Versioning:
- Covered by SemVer: the public API under default features — crate-root
re-exports (
Argon2,Hasher,Params,Algorithm,Version,Error,Backend, …), theparamsanderrormodules, PHC encode/verify behaviour for supported versions, and the meaning of default features (std,parallel,zeroize-memory). - Not covered: the
internal-apifeature and everything under__internal(explicitly unstable; may change in a patch). - MSRV (
rust-versioninCargo.toml) may rise on a minor release when a newer compiler is required for a documented reason (today: stabilized AVX-512 stdarch at 1.89). It does not rise in a patch solely to use newer language sugar. - Optional features added later (for example a future
password-hashtrait façade) are non-breaking when they default off.
This is a pure-Rust port of the C reference with PHC string interop — not an
implementation of the RustCrypto password-hash traits. See the crate docs.
C parser compatibility limits
The hash core is bit-exact for the two standard versions this crate represents, and PHC strings emitted by this crate round-trip with the C reference. Its Rust decoder differs from C on several inputs outside that ordinary surface:
- C accepts any numeric
$v=value because its validator does not inspect the version. The raw value is hashed into H0, so C can produce and verify a self-consistent$v=99record. This crate's closedVersionenum accepts only 16 and 19 and returnsDecodingFailotherwise. - C verification uses
strlenand therefore ignores bytes after an embedded NUL. A Rust&strhas an explicit length, so this crate requires the whole string to be consumed. - The C decoder's input type is an arbitrary byte string, while Rust's
&strboundary cannot represent malformed UTF-8. On targets where Ccharis signed, its decoder also misclassifies every byte>= 0x80in a Base64 field as/; this crate rejects valid non-ASCII UTF-8 and cannot receive malformed UTF-8.
These are parser-acceptance differences, not differences in Argon2 tags for supported versions and canonical ASCII PHC strings.
Verification
The test suite proves equivalence with the C rather than assuming it:
- Golden traces: all six official KAT files replayed block-by-block (12,304 lines of internal memory state each), per runnable backend
- Official vectors: every
hashtest()call fromtest.c, plus error-state parity (same numeric codes, same messages) - Live differential: ~1,300 randomized parameter sets hashed by both this crate and the compiled C reference in one process; tags and error codes must match exactly
- Reuse/audit: pooled-arena byte-identity with one-shot hashing, an allocator spy proving wipe-before-free, and RSS isolation checks
Reproducing the benchmarks
# Full criterion suite (vs C via dlopen, per-backend grid)
cargo bench --features internal-api
# Seconds-scale iteration harness
cargo bench --bench micro --features internal-api -- \
--backend avx512 --m 262144 --t 3 --p 4 --vs-c
--vs-c loads phc-winner-argon2/libargon2.so.1 (built with its own
make) at runtime, prints the ISA genuinely inside it, and asserts tag
equality every repetition.
Build the reference first, and pin the ISA rather than trusting native:
make -C phc-winner-argon2 clean
make -C phc-winner-argon2 OPTTARGET=native libs # best on this CPU
make -C phc-winner-argon2 OPTTARGET='x86-64 -mavx2' libs # pinned AVX2
make -C phc-winner-argon2 OPTTARGET=none libs # ref.c
Then check the probe line in the output before quoting any ratio. On aarch64
every OPTTARGET produces src/ref.c, because src/opt.c is x86-only.
For a matched row, pin the Rust arm to the same ISA as the C. Detection always
returns the best backend, so on an AVX-512 host the ratio table would otherwise
only ever compare avx512:
# rust avx2 vs an AVX2 C, interleaved; skips the criterion groups
ARGON2_BENCH_SUMMARY_BACKEND=avx2 ARGON2_BENCH_REUSE_ONLY=__none__ \
cargo bench --bench argon2 -- __no_such_group__
Point it at a backend the CPU cannot execute and it says so and falls back, rather than crashing or silently running something else.
The x86-64 numbers above were taken by cross-building the bench binaries with
cargo zigbuild --target x86_64-unknown-linux-gnu.2.35 --release --benches and
running them in a container, with the C compiled by gcc on the target itself.
The bench bakes the reference's path at compile time from
env!("CARGO_MANIFEST_DIR"), so the tree must sit at the same absolute path
when it runs as when it was built.
Continuous benchmarking
Every push and pull request runs benches/codspeed.rs under
CodSpeed's CPU simulation
instrument, which counts instructions, cache accesses and branches inside a CPU
model rather than timing a shared runner. That is a different question from the
tables above — it is a regression net, not a speed claim — so the suite is
deliberately small, single-threaded and public-API focused: the three Argon2
algorithms plus the one-shot and reusable-hasher hashing, keyed, encoding, and
verification paths.
cargo codspeed build --bench codspeed
cargo codspeed run --bench codspeed
License
Licensed under the MIT License.
This crate is a port of the Argon2 reference implementation by Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves, which is offered under CC0-1.0 or Apache-2.0 at the user's option. The port is made under the CC0-1.0 option — a public-domain dedication that places no conditions on derivative works — which is what permits this crate to be MIT-licensed. See NOTICE.