# 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?

| Input          | Use            |
|----------------|----------------|
| 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

| Check | Result |
|---|---|
| `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.
