# v0.6.0 — KDF: HKDF + Argon2id
**Release date:** 2026-05-22
**Status:** pre-1.0 milestone. The public API is allowed to evolve in
breaking ways through the `0.x` series; 1.0 freezes it.
---
## What this release is
Key derivation, both shapes. A new `crypt_io::kdf` module ships
HKDF (RFC 5869) for deriving subkeys from high-entropy inputs and
Argon2id (RFC 9106) for hashing passwords. They address different
threat models, share zero API surface, and the module makes the
distinction explicit so callers don't reach for the wrong one.
If you have a master key and you need a per-session, per-context, or
per-tenant subkey — use HKDF. If you have a password from a human
and you need to store something the database can verify against —
use Argon2id. The module overview spells out the distinction so the
"I used HKDF on my user passwords" bug doesn't make it past code
review.
---
## Highlights
- **`kdf::hkdf_sha256` / `kdf::hkdf_sha512`** — RFC 5869
extract-then-expand HKDF in a single call. Accepts optional
salt, application-specific `info` context, and an output length
up to `255 * digest_size` (8160 bytes for SHA-256, 16320 for
SHA-512). Length-overflow is rejected as `Error::Kdf`, not
silently truncated.
- **`kdf::argon2_hash` / `kdf::argon2_verify`** — password hashing
with the OWASP-recommended Argon2id parameter set (~100 ms per
hash on a modern CPU). Salt is generated fresh per-call via
`mod_rand::tier3::fill_bytes` and embedded in the returned PHC
string. Callers do not need to manage salt storage.
- **`Argon2Params` + `argon2_hash_with_params`** for callers with
different cost tolerances (e.g. machine-to-machine credentials
with a higher memory cost, or low-end embedded targets that
need to dial it down).
- **Spec-pinned KATs** for HKDF-SHA256 (RFC 5869 Test Case 1 and
Test Case 3). HKDF-SHA512 cross-checked against the upstream
`hkdf` crate (RFC 5869 has no SHA-512 vectors).
- **Argon2id functional coverage** with reduced parameters so the
test suite stays fast: round-trip, wrong-password rejection,
salt-randomness verification, unparseable-PHC rejection,
tampered-PHC rejection, empty-password edge case,
invalid-params rejection, and redaction-clean error rendering
(passwords never appear in `Error` Display / Debug).
---
## API at a glance
Deriving a subkey from a master:
```rust
use crypt_io::kdf;
let master = [0x42u8; 32];
let session_key = kdf::hkdf_sha256(
&master,
Some(b"randomly-generated-salt"),
b"app:session:v1",
32,
)?;
assert_eq!(session_key.len(), 32);
# Ok::<(), crypt_io::Error>(())
```
Deriving *multiple* uncorrelated subkeys from the same master:
```rust
use crypt_io::kdf;
let master = [0x42u8; 32];
let encrypt_key = kdf::hkdf_sha256(&master, None, b"app:encrypt:v1", 32)?;
let mac_key = kdf::hkdf_sha256(&master, None, b"app:mac:v1", 32)?;
let session_key = kdf::hkdf_sha256(&master, None, b"app:session:v1", 32)?;
// All three are independent thanks to the `info` domain-separator.
assert_ne!(encrypt_key, mac_key);
# Ok::<(), crypt_io::Error>(())
```
Hashing a password:
```rust,no_run
use crypt_io::kdf;
let phc = kdf::argon2_hash(b"correct horse battery staple")?;
// `phc` looks like `$argon2id$v=19$m=19456,t=2,p=1$...$...`
// Store it as a string. Salt and parameters are embedded.
assert!(kdf::argon2_verify(&phc, b"correct horse battery staple")?);
assert!(!kdf::argon2_verify(&phc, b"wrong guess")?);
# Ok::<(), crypt_io::Error>(())
```
Custom Argon2id parameters:
```rust,no_run
use crypt_io::kdf::{argon2_hash_with_params, Argon2Params};
// Higher memory cost for machine-to-machine credentials.
let params = Argon2Params {
m_cost: 64 * 1024, // 64 MiB
t_cost: 3,
p_cost: 1,
output_len: 32,
};
let phc = argon2_hash_with_params(b"service-token", params)?;
# Ok::<(), crypt_io::Error>(())
```
---
## Which one do I want?
| Master key (32 B+) | `kdf::hkdf_sha256` |
| Diffie-Hellman shared secret | `kdf::hkdf_sha256` |
| Token from a secrets manager | `kdf::hkdf_sha256` |
| Output of another KDF | `kdf::hkdf_sha256` |
| Password from a human | `kdf::argon2_hash` |
| PIN from a human | `kdf::argon2_hash_with_params` (higher cost) |
| Anything where speed of an attacker brute-forcing matters | Argon2id |
| Anything where the input is *already high entropy* | HKDF |
The two are not interchangeable. HKDF is fast (microseconds) and
assumes the input is uniformly random; Argon2id is deliberately
slow (~100 ms) and assumes the input is low-entropy and needs
brute-force resistance.
---
## What's NOT in 0.6.0
- **PBKDF2** — superseded by Argon2id for new code. Users
needing PBKDF2 for legacy compatibility should use the
`pbkdf2` crate directly.
- **scrypt** — same. The `scrypt` crate exists for users who
specifically need it.
- **bcrypt** — same. Use the `bcrypt` crate.
- **KDF benchmarks.** Deferred to Phase 0.8.0 alongside the rest
of the benchmark suite. The Argon2id default-params timing
(~100 ms) is documented but not yet measured under criterion.
- **Caller-supplied salt for Argon2id.** `argon2_hash` generates
the salt internally because that's the PHC-format convention
and eliminates a class of "you reused the salt" bugs. Callers
wanting a deterministic salt for testing can use the upstream
`argon2` crate directly.
---
## Security notes
- **HKDF is not for passwords.** The module overview makes this
explicit. Feeding HKDF a low-entropy input doesn't make it
brute-force-resistant — it just makes the brute-force step
faster. Use `argon2_hash` instead.
- **Argon2id defaults track OWASP.** 19 MiB memory, 2 iterations,
1 lane, 32-byte output. Reducing any parameter reduces
resistance to brute force; the defaults are tuned for
interactive web-facing login flows.
- **Salt is generated per-call.** `argon2_hash` calls
`mod_rand::tier3::fill_bytes` for every invocation, so each PHC
string carries a fresh 16-byte random salt. Salt reuse cannot
happen through the public API.
- **No password bytes in errors.** Tested explicitly: a known
password is run through the unparseable-PHC failure path and
the resulting `Error`'s `Display` and `Debug` rendering is
asserted to not contain any password byte.
- **PHC-parse failures and authentication failures are
distinguishable.** A malformed PHC string returns
`Err(Error::Kdf(...))`; a correctly-formatted but wrong-password
hash returns `Ok(false)`. Applications should log these
differently — the first indicates corruption or a coding
mistake, the second indicates an attacker (or a user
mistyping).
---
## Compatibility & build
- **Default features extended.** `default` now includes
`kdf-argon2` in addition to `kdf-hkdf`. A fresh `cargo add
crypt-io` ships with both KDFs available.
- **MSRV** unchanged: Rust 1.85 (edition 2024).
- **New `Error::Kdf` variant.** `Error` is `#[non_exhaustive]`, so
match sites with a wildcard arm compile unchanged.
---
## Installation
```toml
[dependencies]
crypt-io = "0.6"
```
Or:
```bash
cargo add crypt-io
```
---
## Verification
| `cargo fmt --all -- --check` | clean |
| `cargo clippy --all-targets --all-features -- -D warnings` | clean |
| `cargo test --all-features` | 117 unit + 1 smoke + 27 doctest — all passing |
| `cargo doc --no-deps --all-features` (with `RUSTDOCFLAGS="-D warnings"`) | clean |
| HKDF-SHA256 RFC 5869 Test Case 1 | byte-exact match |
| HKDF-SHA256 RFC 5869 Test Case 3 (no salt, no info) | byte-exact match |
| HKDF-SHA512 upstream cross-check | wrapper matches direct upstream call |
| HKDF output > `255 * digest_size` | rejected as `Error::Kdf` |
| Argon2id round-trip (hash + verify) | passes with reduced + default params |
| Argon2id two-hashes-of-same-password-differ | distinct PHC strings (salt randomness) |
| Argon2id tampered PHC | returns `false`, not error |
| Argon2id unparseable PHC | returns `Error::Kdf`, not panic |
| Argon2id error rendering | no password bytes in `Display` or `Debug` |
| MSRV (1.85) build | clean |
---
## Acknowledgements
`crypt-io` does not implement cryptographic primitives. The math
new in 0.6.0 comes from:
- [`hkdf`](https://crates.io/crates/hkdf) — RustCrypto's HKDF
extract-then-expand implementation.
- [`argon2`](https://crates.io/crates/argon2) — RustCrypto's
Argon2 implementation, including the PHC password-hash framework.
- [`password-hash`](https://crates.io/crates/password-hash) —
PHC-string parsing and constant-time verification, pulled in
transitively by `argon2`.
Carried forward: `chacha20poly1305`, `aes-gcm`, `sha2`, `blake3`,
`hmac`, `mod-rand`.
---
## What's next
Phase 0.7.0: Stream / file encryption. Chunked AEAD with proper
framing for inputs that don't fit in memory, with resumable reads
and a documented frame format. Same `Crypt` API at the top, with
`StreamEncryptor` / `StreamDecryptor` for chunk-by-chunk
processing.