diff --git a/.changes/bounded-password-records.md b/.changes/bounded-password-records.md new file mode 100644 index 00000000..13025a31 --- /dev/null +++ b/.changes/bounded-password-records.md @@ -0,0 +1,10 @@ +--- +"rscrypto" = "minor" +--- + +Argon2 and scrypt password verification now reject noncanonical or over-budget +PHC records before decoding, allocating, or running a KDF. The pre-1.0 API is +split into verifier-owned `Argon2idPassword` and `ScryptPassword` records and +raw `derive` operations with valid-by-construction parameters; legacy public +PHC parsing, unbounded verification, builder, version-selection, and +caller-supplied-salt password helpers have been removed. diff --git a/Cargo.toml b/Cargo.toml index 07f93db1..af80c93d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -310,7 +310,7 @@ required-features = ["rsa"] [[bench]] name = "password_hashing" harness = false -required-features = ["argon2", "scrypt", "phc-strings"] +required-features = ["argon2", "scrypt", "phc-strings", "getrandom"] [[bench]] name = "xxh3" diff --git a/README.md b/README.md index 00ce9b25..f246ec8e 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Use [`docs/types.md`](docs/types.md) when you need the full type map, and |---|---|---| | Cryptographic Hashes | SHA-2, SHA-3, SHAKE, cSHAKE128/256, BLAKE2, BLAKE3, Ascon-Hash/XOF/CXOF | `hashes` or leaf features | | MACs & KDFs | HMAC-SHA-2/SHA-3, KMAC128/256, standalone Poly1305, HKDF-SHA-2, PBKDF2-HMAC-SHA-2 | `auth` or leaf features | -| Password Hashing | Argon2d/i/id, scrypt, PHC string encode/verify | `auth`, `argon2`, `scrypt`, `phc-strings` | +| Password Hashing | Raw Argon2d/i/id and scrypt KDFs; generated, bounded PHC password records | `auth`, `argon2`, `scrypt`, `phc-strings` | | Public-Key Primitives | ECDSA P-256/P-384 signing/verification, Ed25519 signatures, RSA signing/verification/OAEP/RSAES-PKCS1-v1_5/key generation, X25519 key exchange, ML-KEM-512/768/1024 KEMs | `auth`, `signatures`, `key-exchange`, `ecdsa`, `ecdsa-p256`, `ecdsa-p384`, `ed25519`, `rsa`, `x25519`, `ml-kem` | | AEAD Encryption | AES-128/256-GCM, AES-128/256-GCM-SIV, ChaCha20-Poly1305, XChaCha20-Poly1305, AEGIS-256, Ascon-AEAD128 | `aead` or leaf features | | Checksums | CRC-16, CRC-24, CRC-32, CRC-32C, CRC-64/XZ, CRC-64/NVMe | `checksums` or leaf features | diff --git a/benches/password_hashing.rs b/benches/password_hashing.rs index dd4f79f4..bacbd57a 100644 --- a/benches/password_hashing.rs +++ b/benches/password_hashing.rs @@ -10,24 +10,19 @@ use core::{hint::black_box, time::Duration}; use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use dryoc::classic::crypto_pwhash::{PasswordHashAlgorithm, crypto_pwhash}; -use rscrypto::{Argon2Error, Argon2Params, Argon2Version, Argon2d, Argon2i, Argon2id, Scrypt, ScryptParams}; +use rscrypto::{ + Argon2Error, Argon2Params, Argon2d, Argon2i, Argon2id, Argon2idPassword, Scrypt, ScryptParams, ScryptPassword, +}; -/// Function pointer shared by all three `Argon2{d,i,id}::hash` variants. +/// Function pointer shared by all three raw Argon2 variants. type ArgonHashFn = fn(&Argon2Params, &[u8], &[u8], &mut [u8]) -> Result<(), Argon2Error>; const PASSWORD: &[u8] = b"correct horse battery staple"; const SALT: &[u8] = b"rscrypto-bench-salt-16bytes!"; /// Build rscrypto params. -fn rs_params(m_kib: u32, t: u32, p: u32, out_len: u32) -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(p) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() - .unwrap() +fn rs_params(m_kib: u32, t: u32, p: u32, _out_len: u32) -> Argon2Params { + Argon2Params::new(m_kib, t, p).unwrap() } /// Build RustCrypto oracle context. @@ -110,14 +105,14 @@ fn bench_small_variant( fn argon2d_small(c: &mut Criterion) { // dryoc has no Argon2d (libsodium only ships Argon2i and Argon2id). - bench_small_variant(c, "argon2d-small", Argon2d::hash, argon2::Algorithm::Argon2d, None); + bench_small_variant(c, "argon2d-small", Argon2d::derive, argon2::Algorithm::Argon2d, None); } fn argon2i_small(c: &mut Criterion) { bench_small_variant( c, "argon2i-small", - Argon2i::hash, + Argon2i::derive, argon2::Algorithm::Argon2i, Some(PasswordHashAlgorithm::Argon2i13), ); @@ -127,7 +122,7 @@ fn argon2id_small(c: &mut Criterion) { bench_small_variant( c, "argon2id-small", - Argon2id::hash, + Argon2id::derive, argon2::Algorithm::Argon2id, Some(PasswordHashAlgorithm::Argon2id13), ); @@ -150,7 +145,7 @@ fn argon2id_owasp(c: &mut Criterion) { g.bench_function(BenchmarkId::new("rscrypto", "m=19MiB_t=2_p=1"), |b| { let mut out = [0u8; 32]; b.iter(|| { - Argon2id::hash( + Argon2id::derive( black_box(&rs_params), black_box(PASSWORD), black_box(SALT), @@ -190,14 +185,8 @@ fn argon2id_owasp(c: &mut Criterion) { } /// Build rscrypto scrypt params. -fn rs_scrypt_params(log_n: u8, r: u32, p: u32, out_len: u32) -> ScryptParams { - ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(out_len) - .build() - .unwrap() +fn rs_scrypt_params(log_n: u8, r: u32, p: u32, _out_len: u32) -> ScryptParams { + ScryptParams::new(log_n, r, p).unwrap() } /// Build RustCrypto scrypt oracle params. @@ -221,7 +210,7 @@ fn scrypt_small(c: &mut Criterion) { g.bench_with_input(BenchmarkId::new("rscrypto", &id), &rs, |b, params| { let mut out = [0u8; 32]; b.iter(|| { - Scrypt::hash( + Scrypt::derive( black_box(params), black_box(PASSWORD), black_box(SALT), @@ -255,7 +244,7 @@ fn scrypt_owasp(c: &mut Criterion) { g.bench_function(BenchmarkId::new("rscrypto", "log_n=17_r=8_p=1"), |b| { let mut out = [0u8; 32]; b.iter(|| { - Scrypt::hash( + Scrypt::derive( black_box(&rs), black_box(PASSWORD), black_box(SALT), @@ -281,13 +270,18 @@ fn scrypt_phc_roundtrip(c: &mut Criterion) { g.sample_size(20); let params = rs_scrypt_params(10, 8, 1, 32); - g.bench_function("hash_string_with_salt", |b| { - b.iter(|| Scrypt::hash_string_with_salt(black_box(¶ms), black_box(PASSWORD), black_box(SALT)).unwrap()); + let password = ScryptPassword::new(params).unwrap(); + g.bench_function("hash_password", |b| { + b.iter(|| password.hash_password(black_box(PASSWORD)).unwrap()); }); - let encoded = Scrypt::hash_string_with_salt(¶ms, PASSWORD, SALT).unwrap(); - g.bench_function("verify_string", |b| { - b.iter(|| Scrypt::verify_string(black_box(PASSWORD), black_box(&encoded)).unwrap()); + let encoded = password.hash_password(PASSWORD).unwrap(); + g.bench_function("verify_password", |b| { + b.iter(|| { + password + .verify_password(black_box(PASSWORD), black_box(&encoded)) + .unwrap() + }); }); g.finish(); @@ -304,13 +298,18 @@ fn argon2id_phc_roundtrip(c: &mut Criterion) { g.sample_size(30); let params = rs_params(32, 2, 1, 32); - g.bench_function("hash_string_with_salt", |b| { - b.iter(|| Argon2id::hash_string_with_salt(black_box(¶ms), black_box(PASSWORD), black_box(SALT)).unwrap()); + let password = Argon2idPassword::new(params).unwrap(); + g.bench_function("hash_password", |b| { + b.iter(|| password.hash_password(black_box(PASSWORD)).unwrap()); }); - let encoded = Argon2id::hash_string_with_salt(¶ms, PASSWORD, SALT).unwrap(); - g.bench_function("verify_string", |b| { - b.iter(|| Argon2id::verify_string(black_box(PASSWORD), black_box(&encoded)).unwrap()); + let encoded = password.hash_password(PASSWORD).unwrap(); + g.bench_function("verify_password", |b| { + b.iter(|| { + password + .verify_password(black_box(PASSWORD), black_box(&encoded)) + .unwrap() + }); }); g.finish(); @@ -349,7 +348,7 @@ fn argon2id_parallel_scaling(c: &mut Criterion) { g.bench_with_input(BenchmarkId::new("rscrypto", &id), ¶ms, |b, params| { let mut out = [0u8; 32]; b.iter(|| { - Argon2id::hash( + Argon2id::derive( black_box(params), black_box(PASSWORD), black_box(SALT), @@ -385,7 +384,7 @@ fn argon2id_parallel_owasp(c: &mut Criterion) { g.bench_with_input(BenchmarkId::new("rscrypto", &id), ¶ms, |b, params| { let mut out = [0u8; 32]; b.iter(|| { - Argon2id::hash( + Argon2id::derive( black_box(params), black_box(PASSWORD), black_box(SALT), diff --git a/ct.toml b/ct.toml index 637b4ce5..1bd84317 100644 --- a/ct.toml +++ b/ct.toml @@ -3009,10 +3009,8 @@ tier = "A" claim = "ct-intended" features = ["argon2"] entrypoints = [ - "Argon2i::hash", + "Argon2i::derive", "Argon2i::verify", - "Argon2i::verify_string", - "Argon2i::verify_string_with_policy", ] secrets = ["password", "derived_key", "expected_key"] public = ["salt", "params", "phc_string"] @@ -3029,7 +3027,12 @@ id = "password.argon2d_argon2id" tier = "A" claim = "best-effort" features = ["argon2"] -entrypoints = ["Argon2d::*", "Argon2id::*"] +entrypoints = [ + "Argon2d::*", + "Argon2id::*", + "Argon2idPassword::verify_password", + "Argon2idPassword::verify_password_with_context", +] secrets = ["password", "derived_key", "expected_key"] public = ["salt", "params", "phc_string"] may_leak = ["input_length", "public_parameter_error", "opaque_success_or_failure"] @@ -3055,8 +3058,7 @@ features = ["scrypt"] entrypoints = [ "Scrypt::derive", "Scrypt::verify", - "Scrypt::verify_string", - "Scrypt::verify_string_with_policy", + "ScryptPassword::verify_password", ] secrets = ["password", "derived_key", "expected_key"] public = ["salt", "params", "phc_string"] diff --git a/docs/features.md b/docs/features.md index b8416ea2..5038186a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -83,7 +83,7 @@ rscrypto = { version = "0.6.4", features = ["full", "portable-only"] } | `hkdf` | `hmac` | HKDF-SHA256, HKDF-SHA384, and HKDF-SHA512 | | `poly1305` | -- | Standalone Poly1305 one-time MAC | | `pbkdf2` | `hmac` | PBKDF2-HMAC-SHA256 and PBKDF2-HMAC-SHA512 | -| `phc-strings` | `alloc` | PHC string encode/decode support | +| `phc-strings` | `alloc` | Canonical password-record generation and bounded PHC verification | | `argon2` | `blake2b`, `alloc` | Argon2i, Argon2d, Argon2id | | `scrypt` | `pbkdf2`, `alloc` | scrypt | | `ecdsa-p256` | `hmac` | ECDSA P-256/SHA-256 signing and verification | @@ -104,7 +104,7 @@ rscrypto = { version = "0.6.4", features = ["full", "portable-only"] } | Feature | Effect | |---|---| -| `getrandom` | Enables OS-RNG constructors such as `random()` / `try_random()`, `try_generate()` for Ed25519/X25519/ECDSA, `Poly1305OneTimeKey::try_generate()`, ML-KEM `try_generate_keypair()` / `try_encapsulate()`, AEAD random sealing helpers, RSA key generation, signing salt/blinding, encryption randomness, and private-operation blinding. Caller-supplied byte-filling closures remain available for deterministic tests and constrained integrations; deterministic ECDSA signing does not use OS randomness. RSA key generation uses OS entropy to seed its key-generation HMAC_DRBG; no separate DRBG feature is required. | +| `getrandom` | Enables OS-RNG constructors such as `random()` / `try_random()`, canonical Argon2id/scrypt password-record generation, `try_generate()` for Ed25519/X25519/ECDSA, `Poly1305OneTimeKey::try_generate()`, ML-KEM `try_generate_keypair()` / `try_encapsulate()`, AEAD random sealing helpers, RSA key generation, signing salt/blinding, encryption randomness, and private-operation blinding. Password-record salts are intentionally OS-owned; other APIs retain caller-supplied byte-filling closures where deterministic tests or constrained integrations need them. Deterministic ECDSA signing does not use OS randomness. RSA key generation uses OS entropy to seed its key-generation HMAC_DRBG; no separate DRBG feature is required. | | `serde` | Serde for non-secret byte wrappers (nonces, tags, public keys, signatures). | | `serde-secrets` | Serde for secret-key and shared-secret bytes. Implies `serde`. Use only for controlled key-material storage, not logs or DTOs. | | `parallel` | Rayon-backed BLAKE3 and Argon2 lane parallelism. Requires `std`, `blake3`, `argon2`. | diff --git a/docs/migration/README.md b/docs/migration/README.md index 761b4122..18203317 100644 --- a/docs/migration/README.md +++ b/docs/migration/README.md @@ -66,8 +66,8 @@ API shape change, or not a good fit. | From | To | Status | |---|---|---| -| [`argon2`](RustCrypto/argon2.md) (RustCrypto) | `Argon2id`, `Argon2i`, `Argon2d`, `Argon2Params`, `Argon2VerifyPolicy` | Verified against `argon2 0.6.0-rc.8` | -| [`scrypt`](RustCrypto/scrypt.md) (RustCrypto) | `Scrypt`, `ScryptParams`, `ScryptVerifyPolicy` | Verified against `scrypt 0.12.0` | +| [`argon2`](RustCrypto/argon2.md) (RustCrypto) | Raw `Argon2{d,i,id}` KDFs; bounded `Argon2idPassword` records | Verified against `argon2 0.6.0-rc.8` | +| [`scrypt`](RustCrypto/scrypt.md) (RustCrypto) | Raw `Scrypt` KDF; bounded `ScryptPassword` records | Verified against `scrypt 0.12.0` | ## Stack Migrations diff --git a/docs/migration/RustCrypto/argon2.md b/docs/migration/RustCrypto/argon2.md index d5c88d6e..b777f96f 100644 --- a/docs/migration/RustCrypto/argon2.md +++ b/docs/migration/RustCrypto/argon2.md @@ -1,161 +1,143 @@ # Migration: `argon2` (RustCrypto) → `rscrypto` -> Replace the runtime-variant `Argon2::new(Algorithm::*, Version, Params)` with type-level `Argon2id` / `Argon2i` / `Argon2d`. Same RFC 9106 spec, byte-identical output, PHC string round-trip built in (no `password-hash` crate dependency). +rscrypto separates two jobs that should not share an API: -Verified against `argon2 = "0.6.0-rc.8"` and the `rscrypto` 0.5.0 line. -Evidence: `tests/argon2_vectors.rs`, `tests/argon2_differential.rs`, `tests/argon2_kernels.rs`, `tests/argon2_parallel.rs`, and `tests/phc_roundtrip.rs`. -The examples use the 0.5-style call shape because that is still common in -existing projects; the current oracle coverage in this repository is the 0.6 -RC dev-dependency. +- `Argon2d`, `Argon2i`, and `Argon2id` are deterministic raw KDFs. +- `Argon2idPassword` generates canonical password records and verifies hostile PHC input under finite resource limits. -## TL;DR +The raw implementations are checked against RFC 9106 vectors and the RustCrypto `argon2 0.6.0-rc.8` oracle in `tests/argon2_vectors.rs`, `tests/argon2_differential.rs`, `tests/argon2_kernels.rs`, and `tests/argon2_parallel.rs`. -| | Before (`argon2` 0.5.x) | After (`rscrypto` 0.5.0) | -|---|---|---| -| Cargo dep | `argon2 = "0.5"` (+ `password-hash` for PHC) | `rscrypto = { version = "0.5.0", features = ["argon2", "phc-strings"] }` | -| Import | `use argon2::{Argon2, Algorithm, Version, Params};` | `use rscrypto::{Argon2id, Argon2Params, Argon2Version};` | -| Raw KDF | `Argon2::new(Argon2id, V0x13, Params::new(m, t, p, Some(N))?).hash_password_into(pw, salt, &mut out)?` | `Argon2id::hash(&Argon2Params::new()...build()?, pw, salt, &mut out)?` | - -## Cargo.toml - -```toml -# Before -[dependencies] -argon2 = "0.5" -password-hash = "0.5" # only if you need PHC strings -``` +## Cargo features ```toml -# After -[dependencies] -rscrypto = { version = "0.5.0", features = ["argon2", "phc-strings"] } +# Raw Argon2 KDF +rscrypto = { version = "0.7", default-features = false, features = ["argon2"] } + +# Password-record generation and verification +rscrypto = { version = "0.7", default-features = false, features = [ + "argon2", + "phc-strings", + "getrandom", +] } ``` -Drop `phc-strings` if you only need the raw KDF and not PHC `$argon2id$...$...` storage strings. Drop `getrandom` from the feature list if you supply your own salt. - -## Variant map - -| `argon2::Algorithm` value | rscrypto type | Notes | -|---|---|---| -| `Algorithm::Argon2id` | `Argon2id` | hybrid; recommended default | -| `Algorithm::Argon2i` | `Argon2i` | data-independent; for side-channel-sensitive deployments | -| `Algorithm::Argon2d` | `Argon2d` | data-dependent; legacy / Filecoin / niche | +`getrandom` is needed only to generate password records. Verification needs `argon2` and `phc-strings`. -## API patterns +## Raw KDF -### Raw KDF +RustCrypto: ```rust -// Before use argon2::{Algorithm, Argon2, Params, Version}; -let params = Params::new(19_456, 2, 1, Some(32)).unwrap(); // (mem KiB, time, parallel, output) + +let params = Params::new(19_456, 2, 1, Some(32))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); -let mut out = [0u8; 32]; -argon2.hash_password_into(password, salt, &mut out).unwrap(); +let mut output = [0u8; 32]; +argon2.hash_password_into(password, salt, &mut output)?; ``` +rscrypto: + ```rust -// After -use rscrypto::{Argon2Params, Argon2Version, Argon2id}; -let params = Argon2Params::new() - .memory_cost_kib(19_456) - .time_cost(2) - .parallelism(1) - .output_len(32) - .version(Argon2Version::V0x13) - .build()?; // validates ranges at build time -let mut out = [0u8; 32]; -Argon2id::hash(¶ms, password, salt, &mut out)?; +use rscrypto::{Argon2Params, Argon2id}; + +let params = Argon2Params::new(19_456, 2, 1)?; +let mut output = [0u8; 32]; +Argon2id::derive(¶ms, password, salt, &mut output)?; ``` -For switching variants, change the type: the `params` value is the same: +`Argon2Params::new(memory_kib, time_cost, parallelism)` returns a valid-by-construction Argon2 v1.3 profile. Output length comes from the destination slice. Switch variants by changing the type: ```rust -Argon2id::hash(¶ms, password, salt, &mut out)?; -Argon2i ::hash(¶ms, password, salt, &mut out)?; -Argon2d ::hash(¶ms, password, salt, &mut out)?; +Argon2id::derive(¶ms, password, salt, &mut output)?; +Argon2i::derive(¶ms, password, salt, &mut output)?; +Argon2d::derive(¶ms, password, salt, &mut output)?; ``` -Fixed-size convenience: `let out: [u8; 32] = Argon2id::hash_array(¶ms, password, salt)?;`. - -### PHC encode (auto-generate salt) +Argon2's optional secret and associated data are borrowed for one operation: ```rust -// Before: requires the `password-hash` crate -use argon2::{Argon2, password_hash::{PasswordHasher, SaltString}}; -use rand_core::OsRng; -let salt = SaltString::generate(&mut OsRng); -let phc = Argon2::default() - .hash_password(password, &salt)? - .to_string(); -// phc: String like "$argon2id$v=19$m=19456,t=2,p=1$..." -``` +use rscrypto::Argon2Context; -```rust -// After (with `phc-strings` + `getrandom` features) -use rscrypto::{Argon2Params, Argon2id}; -let phc: String = Argon2id::hash_string(&Argon2Params::default(), password)?; +let context = Argon2Context::new(pepper, associated_data); +Argon2id::derive_with_context(¶ms, context, password, salt, &mut output)?; ``` -### PHC encode (explicit salt) +The raw API deliberately accepts caller-provided salts and variable output lengths because those are required KDF capabilities. It does not encode PHC strings. + +## Password records + +`Argon2idPassword` owns the generation profile and the finite verification limits: ```rust -// After (with `phc-strings`, no `getrandom` needed) -use rscrypto::{Argon2Params, Argon2id}; -let phc = Argon2id::hash_string_with_salt(&Argon2Params::default(), password, salt)?; +use rscrypto::{Argon2idPassword, PasswordStatus}; + +let passwords = Argon2idPassword::default(); +let record = passwords.hash_password(password)?; + +match passwords.verify_password(password, &record)? { + PasswordStatus::Current => {} + PasswordStatus::NeedsRehash => { + let replacement = passwords.hash_password(password)?; + // Store replacement atomically. + } +} ``` -### PHC verify (constant-time) +Generation always uses Argon2id v1.3, a fresh 16-byte OS-random salt, a 32-byte verifier, and canonical `$argon2id$v=19$m=...,t=...,p=...$...$...` encoding. There is no caller-salt password helper. + +For a custom generation profile: ```rust -// Before: requires the `password-hash` crate -use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}}; -let parsed = PasswordHash::new(stored_phc)?; -Argon2::default().verify_password(password, &parsed)?; +use rscrypto::{Argon2Params, Argon2idPassword}; + +let generation = Argon2Params::new(64 * 1024, 3, 1)?; +let passwords = Argon2idPassword::new(generation)?; ``` +To accept a broader finite migration envelope, derive limits from the largest deployment profile you intend to admit: + ```rust -// After -use rscrypto::Argon2id; -Argon2id::verify_string(password, stored_phc)?; +use rscrypto::{ + Argon2Params, Argon2VerificationLimits, Argon2idPassword, +}; + +let generation = Argon2Params::new(19_456, 2, 1)?; +let accepted_profile = Argon2Params::new(64 * 1024, 3, 1)?; +let limits = Argon2VerificationLimits::for_profile(accepted_profile); +let passwords = Argon2idPassword::with_limits(generation, limits)?; ``` -`verify_string` applies `Argon2VerifyPolicy::default()` before hashing, so PHC -strings with excessive encoded memory, time, parallelism, or output length -reject without allocating the requested work buffer. - -### Custom PHC verify policy +Limits compare the actual rounded matrix bytes, total block work, and parallelism—not merely each encoded integer in isolation. Verification rejects over-budget parameters before base64 decoding, allocation, or KDF work. -Services with stricter local CPU or memory budgets can set explicit ceilings: +Pepper and associated data remain external to the PHC record: ```rust -// After -use rscrypto::{Argon2id, Argon2VerifyPolicy}; -let policy = Argon2VerifyPolicy::new(max_m_kib, max_t, max_p, max_output_len); -Argon2id::verify_string_with_policy(password, stored_phc, &policy)?; +use rscrypto::{Argon2Context, Argon2idPassword}; + +let passwords = Argon2idPassword::default(); +let context = Argon2Context::new(pepper, tenant_id); +let record = passwords.hash_password_with_context(password, context)?; +let status = passwords.verify_password_with_context(password, &record, context)?; ``` -RustCrypto's `argon2` crate has no equivalent policy gate; you would have to parse -the PHC string, reject high-cost cases, then re-encode. The built-in default -policy is a hardening upgrade. +## Compatibility boundary -### Verify with secret / associated data (pepper) +The password verifier accepts only the protocol rscrypto emits: -```rust -// After -use rscrypto::Argon2id; -Argon2id::verify_string_with_context(password, stored_phc, secret, associated_data)?; -``` +- Argon2id, not Argon2d or Argon2i password records; +- Argon2 v1.3 (`v=19`), not v1.0; +- canonical ordered `m,t,p` parameters; +- an 8–48-byte decoded salt; +- exactly 32 decoded verifier bytes; +- costs admitted by the configured finite limits. -The secret (pepper) and associated data are not embedded in the PHC string; they live in your application config / KMS. The `with_context` helpers thread them through verification. RustCrypto's `argon2` exposes the same on the `Argon2` builder; the rscrypto helper collapses it into one call. +Common canonical RustCrypto Argon2id v1.3 records with 32-byte outputs remain verifiable. Noncanonical encodings, v1.0 records, different output sizes, and out-of-envelope profiles must be rehashed through the old stack during migration. rscrypto intentionally exposes neither a public PHC decoder nor an unbounded verifier. -## Notes +## Operational notes -- **Variant as type, not enum value.** `Argon2id::hash(...)` instead of `argon2.algorithm(Algorithm::Argon2id).hash_password_into(...)`. Enum-driven dispatch becomes type-driven; in tests this catches accidental variant swaps at compile time. -- **`Argon2VerifyPolicy` is the migration win.** RustCrypto requires hand-rolling the cost-cap check before calling `verify_password`. rscrypto bakes it into `verify_string`; use `verify_string_with_policy` only when your deployment needs explicit ceilings, and `verify_string_unbounded` only for trusted migration/test-vector inputs. -- **PHC strings without `password-hash`.** rscrypto rolls its own PHC parser/encoder under `features = ["phc-strings"]`. The output format is identical and round-trips with `password-hash`-encoded strings: your existing stored hashes still verify. -- **`Argon2Params::default()` matches OWASP Password Storage Cheat Sheet guidance.** `m=19456 KiB, t=2, p=1, output_len=32 bytes` per the sheet as checked on 2026-06-14. RustCrypto's `Params::default()` matches; both crates will track the cheat sheet over time. -- **`v=0x10` decode-only support.** Both crates can decode legacy `v=16` (Argon2 v1.0) PHC strings for compatibility; both produce `v=19` (v1.3) on encode. -- **Memory cost is real.** `Argon2id::hash` allocates `memory_cost_kib * 1024` bytes for the lane state. `params.build()?` enforces `m >= 8 * p`; allocate failures (out-of-memory) surface as `Argon2Error::AllocationFailed` at hash time. -- **Parallel lanes (`parallel` feature).** rscrypto's `parallel` umbrella feature enables Rayon-based lane parallelism for `p > 1`. RustCrypto's `argon2` is single-threaded only. -- **`no_std` requires `alloc`.** Both crates need a heap for the memory matrix. The deepest-embedded targets (no `alloc`) cannot run Argon2. Use `pbkdf2-hmac-sha256` with a high iteration count instead. +- `Argon2Params::default()` is `m=19_456 KiB, t=2, p=1`, the current [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) baseline. +- Argon2d and Argon2id use data-dependent memory access and are not local side-channel constant-time claims. Argon2i is the data-independent raw variant. +- The memory matrix is zeroized on drop. Target-size overflow and allocation failure are distinct errors on raw derivation. +- The `parallel` feature enables Rayon lane parallelism when the profile and workload justify it. +- Argon2 requires `alloc` and is not FIPS 140-3 approved. diff --git a/docs/migration/RustCrypto/scrypt.md b/docs/migration/RustCrypto/scrypt.md index 0e8dfc00..b3c7f4df 100644 --- a/docs/migration/RustCrypto/scrypt.md +++ b/docs/migration/RustCrypto/scrypt.md @@ -1,130 +1,113 @@ # Migration: `scrypt` (RustCrypto) → `rscrypto` -> Replace the bare `scrypt(password, salt, &Params, &mut out)` function with `Scrypt::hash(&ScryptParams, password, salt, &mut out)`. Same RFC 7914 algorithm, byte-identical output, PHC string round-trip built in (no `password-hash` crate dependency). +rscrypto separates deterministic key derivation from password-record handling: -Verified against `scrypt = "0.12.0"` and the `rscrypto` 0.5.0 line. -Evidence: `tests/scrypt_vectors.rs`, `tests/scrypt_differential.rs`, and `tests/phc_roundtrip.rs`. +- `Scrypt` is the raw RFC 7914 KDF. +- `ScryptPassword` generates canonical password records and verifies hostile PHC input under finite resource limits. -## TL;DR +The implementation is checked against RFC 7914 vectors and the RustCrypto `scrypt 0.12.0` oracle in `tests/scrypt_vectors.rs` and `tests/scrypt_differential.rs`. -| | Before (`scrypt` 0.12.x) | After (`rscrypto` 0.5.0) | -|---|---|---| -| Cargo dep | `scrypt = "0.12"` (+ `password-hash` for PHC) | `rscrypto = { version = "0.5.0", features = ["scrypt", "phc-strings"] }` | -| Import | `use scrypt::{scrypt, Params};` | `use rscrypto::{Scrypt, ScryptParams};` | -| Raw KDF | `scrypt(pw, salt, &Params::new(log_n, r, p)?, &mut out)?` | `Scrypt::hash(&ScryptParams::new()...build()?, pw, salt, &mut out)?` | - -## Cargo.toml +## Cargo features ```toml -# Before -[dependencies] -scrypt = "0.12" -password-hash = "0.5" # only if you need PHC strings +# Raw scrypt KDF +rscrypto = { version = "0.7", default-features = false, features = ["scrypt"] } + +# Password-record generation and verification +rscrypto = { version = "0.7", default-features = false, features = [ + "scrypt", + "phc-strings", + "getrandom", +] } ``` -```toml -# After -[dependencies] -rscrypto = { version = "0.5.0", features = ["scrypt", "phc-strings"] } -``` +`getrandom` is needed only to generate password records. Verification needs `scrypt` and `phc-strings`. -Drop `phc-strings` if you only need the raw KDF and not PHC `$scrypt$...$...` storage strings. Drop `getrandom` from the feature list if you supply your own salt. +## Raw KDF -## API patterns - -### Raw KDF +RustCrypto: ```rust -// Before -use scrypt::{scrypt, Params}; -let params = Params::new(15, 8, 1).unwrap(); // (log_n, r, p): output len is the buffer's len -let mut out = [0u8; 32]; -scrypt(password, salt, ¶ms, &mut out).unwrap(); +use scrypt::{Params, scrypt}; + +let params = Params::new(15, 8, 1)?; +let mut output = [0u8; 32]; +scrypt(password, salt, ¶ms, &mut output)?; ``` +rscrypto: + ```rust -// After use rscrypto::{Scrypt, ScryptParams}; -let params = ScryptParams::new() - .log_n(15) - .r(8) - .p(1) - .output_len(32) // explicit output length - .build()?; // validates ranges at build time -let mut out = [0u8; 32]; -Scrypt::hash(¶ms, password, salt, &mut out)?; + +let params = ScryptParams::new(15, 8, 1)?; +let mut output = [0u8; 32]; +Scrypt::derive(¶ms, password, salt, &mut output)?; ``` -Fixed-size convenience: `let out: [u8; 32] = Scrypt::hash_array(¶ms, password, salt)?;`. +`ScryptParams::new(log_n, r, p)` rejects invalid profiles. As in RFC 7914 and RustCrypto, output length comes from the destination slice. The raw API accepts caller-provided salts, including the empty salt required by the RFC test vector; application salt policy belongs to the password-record API. -### PHC encode (auto-generate salt) +## Password records -```rust -// Before: requires the `password-hash` crate -use scrypt::{Scrypt, password_hash::{PasswordHasher, SaltString}}; -use rand_core::OsRng; -let salt = SaltString::generate(&mut OsRng); -let phc = Scrypt - .hash_password(password, &salt)? - .to_string(); -// phc: "$scrypt$ln=17,r=8,p=1$$" -``` +`ScryptPassword` owns the generation profile and finite verification limits: ```rust -// After (with `phc-strings` + `getrandom` features) -use rscrypto::{Scrypt, ScryptParams}; -let phc: String = Scrypt::hash_string(&ScryptParams::default(), password)?; +use rscrypto::{PasswordStatus, ScryptPassword}; + +let passwords = ScryptPassword::default(); +let record = passwords.hash_password(password)?; + +match passwords.verify_password(password, &record)? { + PasswordStatus::Current => {} + PasswordStatus::NeedsRehash => { + let replacement = passwords.hash_password(password)?; + // Store replacement atomically. + } +} ``` -### PHC encode (explicit salt) - -```rust -// After (with `phc-strings`, no `getrandom` needed) -use rscrypto::{Scrypt, ScryptParams}; -let phc = Scrypt::hash_string_with_salt(&ScryptParams::default(), password, salt)?; -``` +Generation always uses a fresh 16-byte OS-random salt, a 32-byte verifier, and canonical `$scrypt$ln=...,r=...,p=...$...$...` encoding. There is no caller-salt password helper. -### PHC verify (constant-time) +For a custom generation profile: ```rust -// Before: requires the `password-hash` crate -use scrypt::{Scrypt, password_hash::{PasswordHash, PasswordVerifier}}; -let parsed = PasswordHash::new(stored_phc)?; -Scrypt.verify_password(password, &parsed)?; +use rscrypto::{ScryptParams, ScryptPassword}; + +let generation = ScryptParams::new(17, 8, 1)?; +let passwords = ScryptPassword::new(generation)?; ``` +To accept a broader finite migration envelope, derive limits from the largest deployment profile you intend to admit: + ```rust -// After -use rscrypto::Scrypt; -Scrypt::verify_string(password, stored_phc)?; +use rscrypto::{ + ScryptParams, ScryptPassword, ScryptVerificationLimits, +}; + +let generation = ScryptParams::new(17, 8, 1)?; +let accepted_profile = ScryptParams::new(18, 8, 1)?; +let limits = ScryptVerificationLimits::for_profile(accepted_profile); +let passwords = ScryptPassword::with_limits(generation, limits)?; ``` -`verify_string` applies `ScryptVerifyPolicy::default()` before hashing, so PHC -strings with excessive encoded CPU/memory cost or output length reject before -allocating the requested work buffer. +Limits compare the portable implementation's complete working-set bytes and `N × r × p` work. Verification rejects target-size overflow and over-budget parameters before base64 decoding, allocation, or KDF work. -### Custom PHC verify policy +## Compatibility boundary -Services with stricter local CPU or memory budgets can set explicit ceilings: +The password verifier accepts only the protocol rscrypto emits: -```rust -// After -use rscrypto::{Scrypt, ScryptVerifyPolicy}; -let policy = ScryptVerifyPolicy::new(max_log_n, max_r, max_p, max_output_len); -Scrypt::verify_string_with_policy(password, stored_phc, &policy)?; -``` +- no PHC version segment; +- canonical ordered `ln,r,p` parameters; +- an 8–48-byte decoded salt; +- exactly 32 decoded verifier bytes; +- costs admitted by the configured finite limits. -RustCrypto's `scrypt` has no equivalent policy gate; adding one externally -requires parsing the PHC string with `password-hash`, rejecting high-cost cases, -then re-validating. The built-in default policy is a hardening upgrade. +Common canonical RustCrypto scrypt records with 32-byte outputs remain verifiable. Noncanonical encodings, different output sizes, and out-of-envelope profiles must be rehashed through the old stack during migration. rscrypto intentionally exposes neither a public PHC decoder nor an unbounded verifier. -## Notes +## Operational notes -- **`Params::new` is 3 args, not 4.** RustCrypto `scrypt = "0.12"` removed the explicit output-length parameter; output size is whatever buffer you pass to `scrypt(...)`. rscrypto restores it on the builder for cross-call safety (`output_len` is part of the `ScryptParams`, not the call site). -- **OWASP Password Storage Cheat Sheet defaults.** `ScryptParams::default()` is `log_n=17 (N=131,072), r=8, p=1, output_len=32`; it matches the sheet as checked on 2026-06-14. RustCrypto's `Params::default()` matches. -- **`MIN_SALT_LEN` is policy, not enforcement.** rscrypto exposes `Scrypt::MIN_SALT_LEN = 16` as a guidance constant. Neither crate rejects shorter salts at hash time (RFC 7914 §12 test vectors include empty salts). Assert against the constant in your wrapper if you want enforcement. -- **PHC strings without `password-hash`.** rscrypto rolls its own PHC parser/encoder under `features = ["phc-strings"]`. The output format is identical and round-trips with `password-hash`-encoded strings. -- **Memory cost.** `Scrypt::hash` allocates `128 * r * N` bytes (default: ~128 MB at `log_n=17, r=8`). `Scrypt::verify_string` caps incoming PHC strings with `ScryptVerifyPolicy::default()`; use `verify_string_unbounded` only for trusted migration/test-vector inputs. -- **No streaming API.** Scrypt is fundamentally one-shot; both crates expose only stateless functions. -- **`no_std` requires `alloc`.** Both crates need a heap for the working buffers (B, V, scratch). The deepest-embedded targets cannot run Scrypt. Use `pbkdf2-hmac-sha256` with a high iteration count instead. -- **Algorithm is identical.** Bit-equivalent at every parameter set tested in the harness, including the RFC 7914 §12 first test vector (empty password, empty salt, N=16, r=1, p=1, output=64). +- `ScryptParams::default()` is `log_n=17, r=8, p=1`, the current [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) baseline when Argon2id is unavailable. +- The raw algorithm accepts arbitrary salt lengths; generated password records use 16 bytes. +- Working buffers are zeroized on drop. Target-size overflow and allocation failure are distinct errors. +- scrypt ROMix uses password-derived, data-dependent memory access and is not a local side-channel constant-time claim. Final verifier comparison is constant-time. +- scrypt requires `alloc` and is not FIPS 140-3 approved. diff --git a/docs/migration/dryoc.md b/docs/migration/dryoc.md index ffb98263..db22596a 100644 --- a/docs/migration/dryoc.md +++ b/docs/migration/dryoc.md @@ -117,7 +117,8 @@ of accepting a zero output. `dryoc::classic::crypto_pwhash` takes libsodium-style `opslimit`, `memlimit` bytes, and algorithm constants. rscrypto takes explicit `Argon2Params` -(`memory_cost_kib`, `time_cost`, `parallelism`, `output_len`). +(`memory_cost_kib`, `time_cost`, `parallelism`); output length comes from the +destination slice passed to the raw KDF. Do not copy parameters blindly. Convert `memlimit` from bytes to KiB, choose the variant (`Argon2id` or `Argon2i`), and document the resulting policy at the call diff --git a/docs/test-vector-coverage.md b/docs/test-vector-coverage.md index aa0e72c5..acf7da6e 100644 --- a/docs/test-vector-coverage.md +++ b/docs/test-vector-coverage.md @@ -55,8 +55,8 @@ the concrete inputs and outputs of each cryptography API. | HKDF-SHA-512 | `tests/hkdf_sha512_vectors.rs` | RFC 5869 case 1, RustCrypto differential coverage, derive-vs-expand equivalence, and oversized-output rejection | Current suite maps directly | | Poly1305 | `tests/poly1305_vectors.rs` | RFC 8439 §2.5.2 vector, streaming/one-shot equivalence, corrupted tag rejection, and fallible key-generation hook coverage | No Wycheproof standalone Poly1305 suite currently mapped | | PBKDF2-SHA-256/SHA-512 | `tests/pbkdf2_kat_vectors.rs`, `tests/pbkdf2_differential.rs` | `tests/pbkdf2_wycheproof.rs` covers Wycheproof valid derived-key vectors plus explicit wrong-password/wrong-output rejection | Wycheproof PBKDF2 suites contain valid KATs only for the mapped SHA-2 profiles | -| Argon2d/i/id | `tests/argon2_vectors.rs`, `tests/argon2_differential.rs`, `tests/argon2_kernels.rs`, `tests/argon2_parallel.rs`, `tests/argon2_miri.rs` | PHC malformed strings and wrong-password rejection in `tests/phc_roundtrip.rs`; fuzz corpus replay in `fuzz/tests/corpus_replay.rs` | No Wycheproof PHC string suite exists | -| scrypt | `tests/scrypt_vectors.rs`, `tests/scrypt_differential.rs` | PHC malformed strings, oversize strings, and wrong-password rejection in `tests/phc_roundtrip.rs`; fuzz corpus replay in `fuzz/tests/corpus_replay.rs` | Wycheproof PBKDF2 exists, but not scrypt PHC strings | +| Argon2d/i/id | `tests/argon2_vectors.rs`, `tests/argon2_differential.rs`, `tests/argon2_kernels.rs`, `tests/argon2_parallel.rs`, `tests/argon2_miri.rs` | `tests/phc_roundtrip.rs` covers generated records, wrong passwords, canonical-only parsing, rehash status, and zero-allocation resource rejection; fuzz corpus replay covers the bounded public verifier | No Wycheproof PHC string suite exists | +| scrypt | `tests/scrypt_vectors.rs`, `tests/scrypt_differential.rs` | `tests/phc_roundtrip.rs` covers generated records, wrong passwords, canonical-only parsing, rehash status, and zero-allocation resource rejection; fuzz corpus replay covers the bounded public verifier | Wycheproof PBKDF2 exists, but not scrypt PHC strings | | AES-128-GCM | `tests/aes128gcm_oracle.rs` | `tests/aead_wycheproof.rs` covers Wycheproof AES-GCM 128-bit key, 96-bit nonce open failure; oracle tamper tests cover modified tag/ciphertext/AAD | AES-192 vectors are unsupported by API and skipped | | AES-256-GCM | `tests/aes256gcm_oracle.rs` | `tests/aead_wycheproof.rs` covers Wycheproof AES-GCM 256-bit key, 96-bit nonce open failure; oracle tamper tests cover modified tag/ciphertext/AAD | Non-96-bit Wycheproof nonce cases are unsupported by API and skipped | | AES-128-GCM-SIV | `tests/aes128gcmsiv_oracle.rs` | `tests/aead_wycheproof.rs` covers Wycheproof AES-GCM-SIV 128-bit key open failure; oracle tamper tests cover modified tag/ciphertext/AAD | Current suite maps directly | diff --git a/docs/types.md b/docs/types.md index 7cc13202..ce2d3541 100644 --- a/docs/types.md +++ b/docs/types.md @@ -30,7 +30,8 @@ Prelude: `rscrypto::prelude` re-exports `Aead`, `Checksum`, - Prefer caller-provided output buffers and scratch buffers when both forms exist. - Use `alloc` helpers such as `*_to_vec` only when an owned allocation is the right boundary. -- Enable `getrandom` for OS-backed one-liners; caller-supplied entropy remains the base path. +- Enable `getrandom` for OS-backed one-liners. Password-record salts are intentionally OS-owned; + other randomized APIs expose caller-supplied entropy where deterministic or constrained use needs it. - Bind RSA generic signing and verification through `RsaPrivateKey::signer(profile)` and `RsaPublicKey::verifier(profile)` so the padding and hash policy are explicit. @@ -115,10 +116,15 @@ Features: `password-hashing` or `argon2` / `scrypt` / `phc-strings`. | Type | Output | Standard | |------|--------|----------| | `Argon2d` / `Argon2i` / `Argon2id` | variable | RFC 9106 | -| `Argon2Params`, `Argon2VerifyPolicy`, `Argon2Version` | -- | RFC 9106 | -| `Scrypt`, `ScryptParams`, `ScryptVerifyPolicy` | variable | RFC 7914 | +| `Argon2Params`, `Argon2Context` | -- | RFC 9106 raw-KDF configuration | +| `Argon2idPassword`, `Argon2VerificationLimits` | 32B verifier | Bounded canonical Argon2id PHC records | +| `Scrypt`, `ScryptParams` | variable | RFC 7914 raw KDF | +| `ScryptPassword`, `ScryptVerificationLimits` | 32B verifier | Bounded canonical scrypt PHC records | +| `PasswordStatus` | -- | Current-profile / rehash decision | -PHC string-format encode/decode shared by both families: `auth::phc` (feature `phc-strings`). +Password-record operations require `phc-strings`; OS-salted generation also requires `getrandom`. +PHC parsing and encoding are intentionally internal so attacker-controlled costs cannot bypass the +algorithm-specific verification limits. ## Signatures & Key Exchange @@ -191,7 +197,7 @@ also has `seal_random_to_vec`. | Error | When | Recovery | |-------|------|----------| -| `VerificationError` | MAC/AEAD/signature check fails | Reject input without revealing failure detail | +| `VerificationError` | MAC/AEAD/signature/password verification fails | Reject input without revealing failure detail | | `EcdsaKeyGenerationError` | ECDSA random source failure or bounded scalar rejection exhaustion | Fix entropy source; investigate deterministic fillers | | `AeadBufferError` | Output buffer wrong size | Fix buffer length | | `SealError` | AEAD combined encrypt failure | Buffer / nonce / counter | @@ -199,9 +205,8 @@ also has `seal_random_to_vec`. | `NonceCounterSealError` | Deterministic-IV counter exhausted or buffer error | Rotate key / fix buffer | | `HkdfOutputLengthError` | HKDF expand exceeds max | Request less output | | `Pbkdf2Error` | PBKDF2 parameter validation | Adjust iterations / output length | -| `Argon2Error` | Argon2 parameter or input validation | Adjust params per RFC 9106 | -| `ScryptError` | scrypt parameter validation | Adjust N / r / p per RFC 7914 | -| `PhcError` | PHC string parse / encode | Fix encoded form | +| `Argon2Error` | Argon2 configuration, input, entropy, or resource failure | Fix the profile/input or restore resources | +| `ScryptError` | scrypt configuration, entropy, or resource failure | Fix N/r/p or restore resources | | `X25519Error` | Low-order DH point | Reject peer key | | `MlKemError` | ML-KEM random source, key, or ciphertext validation failure | Reject input or fix entropy source | | `RsaKeyError` | RSA DER or component validation failure | Reject key / tighten import policy | diff --git a/examples/README.md b/examples/README.md index aa2e745b..c8cbb320 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,13 +20,16 @@ cargo run --example basic --features full,getrandom Walks through checksums (`Crc32C`), digests (`Sha256`, `Blake3`), MACs (`HmacSha256`), KDFs (`HkdfSha256`), XOFs (`Shake256`, `Blake3`), fast hashes (`Xxh3`, `RapidHash`), AEAD (`ChaCha20Poly1305` with a fresh random nonce), hex formatting, secret-key Debug masking, byte-array round-trips through `from_bytes` / `to_bytes` / `as_bytes`, and the `std::io::{Read, Write}` adapters for streaming digests and checksums. Every section asserts that one-shot equals streaming. That is the API contract every primitive in `rscrypto` follows. -### `password_hashing`: Argon2id and scrypt with bounded-policy verify +### `password_hashing`: generated and bounded password records ```bash cargo run --example password_hashing --features password-hashing,getrandom ``` -Hashes a password with both Argon2id and scrypt, encodes the result as a PHC string, then verifies it through bounded `verify_string`. The default verify policy caps the cost parameters the verifier will accept, blocking attacker-supplied high-cost PHC strings. Prints both PHC encodings to stdout so you can inspect the format. +Generates Argon2id and scrypt password records with fresh OS-random salts, then verifies them through +the bounded `Argon2idPassword` and `ScryptPassword` APIs. Encoded costs outside each verifier's finite +resource envelope are rejected before base64 decoding, allocation, or KDF work. The example prints +both canonical PHC records so you can inspect their format. ### `aead_seal_open`: ChaCha20-Poly1305 seal/open diff --git a/examples/password_hashing.rs b/examples/password_hashing.rs index a3d9375c..071cb91d 100644 --- a/examples/password_hashing.rs +++ b/examples/password_hashing.rs @@ -6,20 +6,20 @@ //! cargo run --example password_hashing --features password-hashing,getrandom //! ``` -use rscrypto::{Argon2Params, Argon2id, Scrypt, ScryptParams}; +use rscrypto::{Argon2idPassword, ScryptPassword}; fn main() -> Result<(), Box> { let password = b"correct horse battery staple"; - let argon2 = Argon2Params::new().build()?; - let argon2_phc = Argon2id::hash_string(&argon2, password)?; - assert!(Argon2id::verify_string(password, &argon2_phc).is_ok()); - assert!(Argon2id::verify_string(b"wrong password", &argon2_phc).is_err()); + let argon2 = Argon2idPassword::default(); + let argon2_phc = argon2.hash_password(password)?; + assert!(argon2.verify_password(password, &argon2_phc).is_ok()); + assert!(argon2.verify_password(b"wrong password", &argon2_phc).is_err()); - let scrypt = ScryptParams::new().build()?; - let scrypt_phc = Scrypt::hash_string(&scrypt, password)?; - assert!(Scrypt::verify_string(password, &scrypt_phc).is_ok()); - assert!(Scrypt::verify_string(b"wrong password", &scrypt_phc).is_err()); + let scrypt = ScryptPassword::default(); + let scrypt_phc = scrypt.hash_password(password)?; + assert!(scrypt.verify_password(password, &scrypt_phc).is_ok()); + assert!(scrypt.verify_password(b"wrong password", &scrypt_phc).is_err()); println!("{argon2_phc}"); println!("{scrypt_phc}"); diff --git a/fuzz-packages/auth-phc/Cargo.lock b/fuzz-packages/auth-phc/Cargo.lock index b91212b6..4f316416 100644 --- a/fuzz-packages/auth-phc/Cargo.lock +++ b/fuzz-packages/auth-phc/Cargo.lock @@ -77,7 +77,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rscrypto" -version = "0.6.4" +version = "0.7.8" [[package]] name = "rscrypto-fuzz-auth-phc" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 97feaa70..b6ac9b68 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -1000,7 +1000,7 @@ dependencies = [ [[package]] name = "rscrypto" -version = "0.6.4" +version = "0.7.8" [[package]] name = "rscrypto-fuzz" diff --git a/fuzz/target_impls/auth_argon2d.rs b/fuzz/target_impls/auth_argon2d.rs index c46985ac..0b1c083d 100644 --- a/fuzz/target_impls/auth_argon2d.rs +++ b/fuzz/target_impls/auth_argon2d.rs @@ -1,4 +1,4 @@ -use rscrypto::{Argon2Params, Argon2Version, Argon2d}; +use rscrypto::{Argon2Params, Argon2d}; use rscrypto_fuzz::{FuzzInput, pad_salt_to, some_or_return, split_at_ratio}; pub fn run(data: &[u8]) { @@ -13,13 +13,7 @@ pub fn run(data: &[u8]) { let t = 1u32.strict_add(u32::from(t_byte) % 4); let out_len = 4u32.strict_add(u32::from(out_len_byte) % 29); - let params = Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(1) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() + let params = Argon2Params::new(m_kib, t, 1) .expect("params must be valid for fuzzer ranges"); // See `auth_argon2id.rs` for cost-parameter rationale. @@ -28,7 +22,7 @@ pub fn run(data: &[u8]) { // Property: hash succeeds for valid inputs within the fuzz-constrained range. let mut actual = vec![0u8; out_len as usize]; - Argon2d::hash(¶ms, password, &salt_buf, &mut actual).expect("hash within fuzz ranges"); + Argon2d::derive(¶ms, password, &salt_buf, &mut actual).expect("hash within fuzz ranges"); // Property: verify accepts the hash it just produced. Argon2d::verify(¶ms, password, &salt_buf, &actual).expect("verify must accept the hash it just produced"); diff --git a/fuzz/target_impls/auth_argon2i.rs b/fuzz/target_impls/auth_argon2i.rs index 839a975a..799f2adf 100644 --- a/fuzz/target_impls/auth_argon2i.rs +++ b/fuzz/target_impls/auth_argon2i.rs @@ -1,4 +1,4 @@ -use rscrypto::{Argon2Params, Argon2Version, Argon2i}; +use rscrypto::{Argon2Params, Argon2i}; use rscrypto_fuzz::{FuzzInput, pad_salt_to, some_or_return, split_at_ratio}; pub fn run(data: &[u8]) { @@ -13,13 +13,7 @@ pub fn run(data: &[u8]) { let t = 1u32.strict_add(u32::from(t_byte) % 4); let out_len = 4u32.strict_add(u32::from(out_len_byte) % 29); - let params = Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(1) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() + let params = Argon2Params::new(m_kib, t, 1) .expect("params must be valid for fuzzer ranges"); // See `auth_argon2id.rs` for cost-parameter rationale. @@ -27,7 +21,7 @@ pub fn run(data: &[u8]) { let salt_buf = pad_salt_to::<16>(salt_material, pw_salt_split); let mut actual = vec![0u8; out_len as usize]; - Argon2i::hash(¶ms, password, &salt_buf, &mut actual).expect("hash within fuzz ranges"); + Argon2i::derive(¶ms, password, &salt_buf, &mut actual).expect("hash within fuzz ranges"); Argon2i::verify(¶ms, password, &salt_buf, &actual).expect("verify must accept the hash it just produced"); diff --git a/fuzz/target_impls/auth_argon2id.rs b/fuzz/target_impls/auth_argon2id.rs index 2158d31f..57fef803 100644 --- a/fuzz/target_impls/auth_argon2id.rs +++ b/fuzz/target_impls/auth_argon2id.rs @@ -1,4 +1,4 @@ -use rscrypto::{Argon2Params, Argon2Version, Argon2id}; +use rscrypto::{Argon2Params, Argon2id}; use rscrypto_fuzz::{FuzzInput, pad_salt_to, some_or_return, split_at_ratio}; pub fn run(data: &[u8]) { @@ -16,13 +16,7 @@ pub fn run(data: &[u8]) { let t = 1u32.strict_add(u32::from(t_byte) % 4); let out_len = 4u32.strict_add(u32::from(out_len_byte) % 29); // 4..=32 per RFC 9106 §3.1 - let params = Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(1) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() + let params = Argon2Params::new(m_kib, t, 1) .expect("params must be valid for fuzzer ranges"); let (password, salt_material) = split_at_ratio(rest, pw_salt_split); @@ -30,7 +24,7 @@ pub fn run(data: &[u8]) { // Property: hash succeeds for valid inputs within the fuzz-constrained range. let mut actual = vec![0u8; out_len as usize]; - Argon2id::hash(¶ms, password, &salt_buf, &mut actual).expect("hash within fuzz ranges"); + Argon2id::derive(¶ms, password, &salt_buf, &mut actual).expect("hash within fuzz ranges"); // Property: verify accepts the hash it just produced. Argon2id::verify(¶ms, password, &salt_buf, &actual).expect("verify must accept the hash it just produced"); diff --git a/fuzz/target_impls/auth_phc.rs b/fuzz/target_impls/auth_phc.rs index ad4ec136..0bced237 100644 --- a/fuzz/target_impls/auth_phc.rs +++ b/fuzz/target_impls/auth_phc.rs @@ -1,118 +1,27 @@ -// PHC-string fuzzer. Two complementary properties: +// Hostile PHC input reaches only the bounded public password verifiers. // -// 1. No-panic on adversarial input. `decode_string` for every supported algorithm must return a -// `Result`, never panic, regardless of how structurally invalid the candidate string is. -// 2. Roundtrip on freshly-emitted strings. A variant byte selects Argon2id/2d/2i/Scrypt; -// verify_string must accept the fresh emission, and any single-byte XOR mutation must produce -// verify_string-Err. -// -// `verify_string` is intentionally NOT called on attacker-controlled -// candidates: doing so re-executes the KDF with attacker-chosen cost -// parameters, collapsing fuzz throughput. KDF panic-surface is fuzzed -// directly by `auth_argon2{id,d,i}.rs` and `auth_scrypt.rs`. - -use rscrypto::{Argon2Params, Argon2Version, Argon2d, Argon2i, Argon2id, Scrypt, ScryptParams}; -use rscrypto_fuzz::{FuzzInput, pad_salt_to, some_or_return, split_at_ratio}; - -fn no_panic_on_decode(candidate: &str) { - // `_` discards both Ok and Err — the assertion is "did not panic". - // `decode_string` is the parser surface; running it for all four - // hashers exposes algorithm-tag, version, parameter, and base64 - // edge cases without paying a KDF cost. - let _ = Argon2id::decode_string(candidate); - let _ = Argon2d::decode_string(candidate); - let _ = Argon2i::decode_string(candidate); - let _ = Scrypt::decode_string(candidate); -} - -fn argon2_params(out_len: u32) -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(8) - .time_cost(1) - .parallelism(1) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() - .expect("argon2 fuzz params must validate") -} - -fn scrypt_params(out_len: u32) -> ScryptParams { - ScryptParams::new() - .log_n(1) - .r(1) - .p(1) - .output_len(out_len) - .build() - .expect("scrypt fuzz params must validate") -} +// The verification profiles are deliberately tiny so a fuzzer-generated +// canonical record remains cheap while malformed or over-budget inputs +// exercise the parser and approval boundary at full throughput. -fn assert_roundtrip(encoded: &str, password: &[u8], variant: u8) { - match variant % 4 { - 0 => Argon2id::verify_string(password, encoded).expect("argon2id verify_string must accept its own emission"), - 1 => Argon2d::verify_string(password, encoded).expect("argon2d verify_string must accept its own emission"), - 2 => Argon2i::verify_string(password, encoded).expect("argon2i verify_string must accept its own emission"), - _ => Scrypt::verify_string(password, encoded).expect("scrypt verify_string must accept its own emission"), - } -} - -fn assert_byte_flip_rejected(encoded: &str, password: &[u8], variant: u8, flip_byte: u8) { - if encoded.is_empty() { - return; - } - // PHC strings are pure ASCII (`$,/=+0-9A-Za-z`) — XOR with 0x01 keeps - // every byte in the ASCII range, so the mutation always lands in valid - // UTF-8 and the verify_string surface is fully exercised. This matches - // the single-bit-flip idiom used in every other rscrypto fuzz target. - let mut bytes = encoded.as_bytes().to_vec(); - let idx = (flip_byte as usize) % bytes.len(); - bytes[idx] ^= 0x01; - let mutated = core::str::from_utf8(&bytes).expect("ASCII PHC string XOR-1 must remain valid UTF-8"); - - let result = match variant % 4 { - 0 => Argon2id::verify_string(password, mutated), - 1 => Argon2d::verify_string(password, mutated), - 2 => Argon2i::verify_string(password, mutated), - _ => Scrypt::verify_string(password, mutated), - }; - assert!(result.is_err(), "verify_string must reject single-bit XOR mutation"); -} +use rscrypto::{ + Argon2Params, Argon2idPassword, ScryptParams, ScryptPassword, +}; pub fn run(data: &[u8]) { - let mut input = FuzzInput::new(data); - let variant: u8 = some_or_return!(input.byte()); - let pw_salt_split: u8 = some_or_return!(input.byte()); - let out_len_byte: u8 = some_or_return!(input.byte()); - let flip_byte: u8 = some_or_return!(input.byte()); - let rest = input.rest(); - - // Output length 4..=32 — RFC 9106 §3.1 minimum is 4; scrypt accepts ≥ 1 - // but the unified range keeps the cross-variant property surface flat. - let out_len = 4u32.strict_add(u32::from(out_len_byte) % 29); - let (password, salt_material) = split_at_ratio(rest, pw_salt_split); - let salt = pad_salt_to::<16>(salt_material, pw_salt_split); - - // ── Property 1 — no panic on adversarial input ────────────────────── - // - // `from_utf8_lossy` preserves byte information through replacement- - // character substitution (U+FFFD), so non-UTF-8 fuzzer bytes still - // produce a structurally rich candidate string rather than collapsing - // to empty. - let candidate = String::from_utf8_lossy(rest); - no_panic_on_decode(&candidate); - - // ── Property 2 — roundtrip and tamper-rejection ───────────────────── - let encoded = match variant % 4 { - 0 => Argon2id::hash_string_with_salt(&argon2_params(out_len), password, &salt) - .expect("argon2id hash_string_with_salt must succeed for fuzz params"), - 1 => Argon2d::hash_string_with_salt(&argon2_params(out_len), password, &salt) - .expect("argon2d hash_string_with_salt must succeed for fuzz params"), - 2 => Argon2i::hash_string_with_salt(&argon2_params(out_len), password, &salt) - .expect("argon2i hash_string_with_salt must succeed for fuzz params"), - _ => Scrypt::hash_string_with_salt(&scrypt_params(out_len), password, &salt) - .expect("scrypt hash_string_with_salt must succeed for fuzz params"), - }; - - assert_roundtrip(&encoded, password, variant); - assert_byte_flip_rejected(&encoded, password, variant, flip_byte); - no_panic_on_decode(&encoded); + let split = data.len() / 2; + let (password, encoded_bytes) = data.split_at(split); + let encoded = String::from_utf8_lossy(encoded_bytes); + + let argon2 = Argon2idPassword::new( + Argon2Params::new(8, 1, 1).expect("fixed Argon2 fuzz profile is valid"), + ) + .expect("fixed Argon2 fuzz profile fits the target"); + let scrypt = ScryptPassword::new( + ScryptParams::new(1, 1, 1).expect("fixed scrypt fuzz profile is valid"), + ) + .expect("fixed scrypt fuzz profile fits the target"); + + let _ = argon2.verify_password(password, &encoded); + let _ = scrypt.verify_password(password, &encoded); } diff --git a/fuzz/target_impls/auth_scrypt.rs b/fuzz/target_impls/auth_scrypt.rs index 9ebfe63b..487dbfc9 100644 --- a/fuzz/target_impls/auth_scrypt.rs +++ b/fuzz/target_impls/auth_scrypt.rs @@ -16,19 +16,14 @@ pub fn run(data: &[u8]) { let r = 1u32.strict_add(u32::from(r_byte) % 4); let out_len = 1u32.strict_add(u32::from(out_len_byte) % 64); - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(1) - .output_len(out_len) - .build() + let params = ScryptParams::new(log_n, r, 1) .expect("params must be valid for fuzzer ranges"); let (password, salt) = split_at_ratio(rest, pw_salt_split); // Property: hash succeeds for valid inputs. let mut actual = vec![0u8; out_len as usize]; - Scrypt::hash(¶ms, password, salt, &mut actual).expect("hash within fuzz ranges"); + Scrypt::derive(¶ms, password, salt, &mut actual).expect("hash within fuzz ranges"); // Property: verify accepts the hash it just produced. Scrypt::verify(¶ms, password, salt, &actual).expect("verify must accept the hash it just produced"); diff --git a/scripts/ci/run-bench.sh b/scripts/ci/run-bench.sh index 52d86418..8e7b5380 100755 --- a/scripts/ci/run-bench.sh +++ b/scripts/ci/run-bench.sh @@ -377,7 +377,7 @@ bench_features_for_target() { blake2) echo "parallel,blake2b,blake2s" ;; blake3) echo "parallel,blake3" ;; auth) echo "parallel,hmac,hkdf,pbkdf2,ecdsa,ed25519,x25519,ml-kem,diag" ;; - password_hashing) echo "parallel,argon2,scrypt,phc-strings" ;; + password_hashing) echo "parallel,argon2,scrypt,phc-strings,getrandom" ;; rsa) echo "parallel,rsa,diag" ;; aead_diag) echo "parallel,sha2,aes-gcm,aes-gcm-siv,chacha20poly1305,xchacha20poly1305,aegis256,ascon-aead,diag" ;; aead) echo "parallel,aes-gcm,aes-gcm-siv,chacha20poly1305,xchacha20poly1305,aegis256,ascon-aead" ;; diff --git a/src/auth/argon2/mod.rs b/src/auth/argon2/mod.rs index 638b9046..33bae3ba 100644 --- a/src/auth/argon2/mod.rs +++ b/src/auth/argon2/mod.rs @@ -8,7 +8,8 @@ //! - [`Argon2i`] — data-independent indexing: memory-access patterns do not depend on the password. //! Useful when the adversary can observe cache or memory-access timing. //! - [`Argon2id`] — hybrid: first half of the first pass runs Argon2i, the rest runs Argon2d. -//! **Recommended default for password hashing** per RFC 9106 §4 and OWASP 2024 guidance. +//! **Recommended default for password hashing** per RFC 9106 §4 and the [OWASP Password Storage +//! Cheat Sheet][owasp-passwords]. //! //! The implementation uses the BlaMka compression function from RFC 9106 //! §3.6 layered on top of [`crate::Blake2b`]. A runtime-cached [`KernelId`] @@ -21,18 +22,13 @@ //! ```rust //! use rscrypto::{Argon2Params, Argon2id}; //! -//! let params = Argon2Params::new() -//! .memory_cost_kib(19 * 1024) -//! .time_cost(2) -//! .parallelism(1) -//! .build() -//! .expect("valid params"); +//! let params = Argon2Params::new(19 * 1024, 2, 1).expect("valid params"); //! //! let password = b"correct horse battery staple"; //! let salt = b"random-salt-1234"; //! //! let mut hash = [0u8; 32]; -//! Argon2id::hash(¶ms, password, salt, &mut hash).expect("hash"); +//! Argon2id::derive(¶ms, password, salt, &mut hash).expect("derive"); //! //! assert!(Argon2id::verify(¶ms, password, salt, &hash).is_ok()); //! assert!(Argon2id::verify(¶ms, b"wrong", salt, &hash).is_err()); @@ -42,8 +38,8 @@ //! //! - Salt length must be ≥ 8 bytes (RFC 9106 §3.1). 16 bytes recommended. //! - Memory cost must satisfy `m ≥ 8 · p` (KiB). -//! - [`Argon2Params::validate`] enforces all RFC 9106 bounds; invalid inputs surface as -//! [`Argon2Error`] at build time, not at hash time. +//! - [`Argon2Params::new`] rejects invalid cost profiles, so constructed parameters are always +//! valid. //! - The memory matrix is zeroized on drop. //! - [`Argon2id::verify`] is constant-time with respect to the stored hash bytes. //! @@ -57,6 +53,8 @@ //! Requires `alloc` — the memory matrix (`m_kib · 1024` bytes) cannot be //! stack-allocated. Bare-metal / heap-less targets should select //! [`crate::Pbkdf2Sha256`] (alloc-free). +//! +//! [owasp-passwords]: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html #![allow(clippy::indexing_slicing)] #![allow(clippy::unwrap_used)] @@ -109,11 +107,13 @@ const MAX_VAR_BYTES: u64 = u32::MAX as u64; /// Minimum output length in bytes (RFC 9106 §3.1). pub const MIN_OUTPUT_LEN: usize = 4; -/// Default OWASP 2024 parameters (see [`Argon2Params::new`]). +/// OWASP baseline parameters (see [`Argon2Params::new`]). const DEFAULT_MEMORY_KIB: u32 = 19 * 1024; const DEFAULT_TIME_COST: u32 = 2; const DEFAULT_PARALLELISM: u32 = 1; +#[cfg(feature = "phc-strings")] const DEFAULT_OUTPUT_LEN: usize = 32; +const ARGON2_VERSION: u32 = 0x13; /// BlaMka P permutation operates on 8×8 matrices of 16-byte "registers", /// i.e. on 16-u64 slabs. @@ -159,7 +159,7 @@ fn zeroize_u64_slice(words: &mut [u64]) { core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst); } -// ─── Variant, Version ─────────────────────────────────────────────────────── +// ─── Variant ──────────────────────────────────────────────────────────────── /// Argon2 variant selector. /// @@ -170,7 +170,6 @@ fn zeroize_u64_slice(words: &mut [u64]) { /// - `Argon2id` — data-independent for the first half of the first pass, data-dependent afterwards /// (RFC 9106 §3.4.1). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[non_exhaustive] pub enum Argon2Variant { Argon2d, Argon2i, @@ -189,34 +188,11 @@ impl Argon2Variant { } } -/// Argon2 algorithm version. -/// -/// `V0x13` (1.3) is the RFC 9106 default and should be used for all new -/// deployments. `V0x10` (1.0) is supported only for decoding legacy PHC -/// strings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] -#[non_exhaustive] -pub enum Argon2Version { - V0x10, - #[default] - V0x13, -} - -impl Argon2Version { - #[inline] - const fn as_u32(self) -> u32 { - match self { - Self::V0x10 => 0x10, - Self::V0x13 => 0x13, - } - } -} - // ─── Error ────────────────────────────────────────────────────────────────── /// Invalid Argon2 parameter or input. /// -/// Surfaced at `build` or `hash` time — never at `verify` time (a parameter +/// Surfaced at construction or derivation time — never at `verify` time (a parameter /// error during verification would leak whether the encoded hash was valid, /// so verify collapses these into [`crate::VerificationError`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -240,8 +216,16 @@ pub enum Argon2Error { SecretTooLong, /// Optional associated-data length exceeds 2^32-1 bytes. AssociatedDataTooLong, + /// The requested memory matrix exceeds the target's address space. + ResourceOverflow, + /// The allocator refused to provide the memory matrix. + AllocationFailed, /// The platform entropy source failed while generating a PHC salt. + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] EntropyUnavailable, + /// Password generation parameters exceed the verifier's resource limits. + #[cfg(feature = "phc-strings")] + VerificationLimitTooLow, } impl fmt::Display for Argon2Error { @@ -256,7 +240,12 @@ impl fmt::Display for Argon2Error { Self::PasswordTooLong => "Argon2 password exceeds 2^32-1 bytes", Self::SecretTooLong => "Argon2 secret exceeds 2^32-1 bytes", Self::AssociatedDataTooLong => "Argon2 associated data exceeds 2^32-1 bytes", + Self::ResourceOverflow => "Argon2 memory matrix exceeds the target's address space", + Self::AllocationFailed => "Argon2 memory-matrix allocation failed", + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] Self::EntropyUnavailable => "Argon2 entropy source unavailable", + #[cfg(feature = "phc-strings")] + Self::VerificationLimitTooLow => "Argon2 verification limits do not admit the generation parameters", }) } } @@ -265,175 +254,59 @@ impl core::error::Error for Argon2Error {} // ─── Parameters ───────────────────────────────────────────────────────────── -/// Validated Argon2 cost-parameter set. -/// -/// Constructed via [`Argon2Params::new`] and the setters; call -/// [`Argon2Params::build`] to validate and produce a `Result`. The output length is enforced by validators but is the -/// same for every hash produced with this parameter set. +/// Validated Argon2 cost parameters. /// /// # Examples /// /// ```rust /// use rscrypto::{Argon2Params, Argon2id}; /// -/// let params = Argon2Params::new() -/// .time_cost(3) -/// .memory_cost_kib(65_536) -/// .parallelism(4) -/// .output_len(32) -/// .build() -/// .expect("valid params"); +/// let params = Argon2Params::new(65_536, 3, 4)?; /// /// let mut out = [0u8; 32]; -/// Argon2id::hash(¶ms, b"password", b"random-salt-1234", &mut out).unwrap(); +/// Argon2id::derive(¶ms, b"password", b"random-salt-1234", &mut out)?; +/// # Ok::<(), rscrypto::Argon2Error>(()) /// ``` -#[derive(Clone)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct Argon2Params { time_cost: u32, memory_cost_kib: u32, parallelism: u32, - output_len: u32, - version: Argon2Version, - secret: Vec, - associated_data: Vec, -} - -impl fmt::Debug for Argon2Params { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Argon2Params") - .field("time_cost", &self.time_cost) - .field("memory_cost_kib", &self.memory_cost_kib) - .field("parallelism", &self.parallelism) - .field("output_len", &self.output_len) - .field("version", &self.version) - .field("secret_len", &self.secret.len()) - .field("associated_data_len", &self.associated_data.len()) - .finish() - } -} - -impl Drop for Argon2Params { - fn drop(&mut self) { - if !self.secret.is_empty() { - ct::zeroize(&mut self.secret); - } - if !self.associated_data.is_empty() { - ct::zeroize(&mut self.associated_data); - } - } } impl Default for Argon2Params { fn default() -> Self { - Self::new() - } -} - -impl Argon2Params { - /// Create a new parameter builder pre-populated with OWASP 2024 defaults - /// (m = 19 MiB, t = 2, p = 1, output = 32 bytes, v = 1.3). Call the setters - /// to override, then [`Argon2Params::build`] to validate. - #[must_use] - pub fn new() -> Self { Self { time_cost: DEFAULT_TIME_COST, memory_cost_kib: DEFAULT_MEMORY_KIB, parallelism: DEFAULT_PARALLELISM, - output_len: DEFAULT_OUTPUT_LEN as u32, - version: Argon2Version::V0x13, - secret: Vec::new(), - associated_data: Vec::new(), } } +} - /// Set the time cost (iterations). Must be ≥ 1. - #[must_use] - pub const fn time_cost(mut self, t: u32) -> Self { - self.time_cost = t; - self - } - - /// Set the memory cost in KiB. Must satisfy `m ≥ 8 · p`. - #[must_use] - pub const fn memory_cost_kib(mut self, m: u32) -> Self { - self.memory_cost_kib = m; - self - } - - /// Set the parallelism (number of lanes). Must be 1..=2^24-1. - #[must_use] - pub const fn parallelism(mut self, p: u32) -> Self { - self.parallelism = p; - self - } - - /// Set the output tag length in bytes. Must be 4..=2^32-1. - #[must_use] - pub const fn output_len(mut self, t: u32) -> Self { - self.output_len = t; - self - } - - /// Set the algorithm version. - #[must_use] - pub const fn version(mut self, v: Argon2Version) -> Self { - self.version = v; - self - } - - /// Set the optional secret (pepper). Empty disables pepper. - #[must_use] - pub fn secret(mut self, secret: &[u8]) -> Self { - ct::zeroize(&mut self.secret); - self.secret.clear(); - self.secret.extend_from_slice(secret); - self - } - - /// Set the optional associated data. Empty means "none". - #[must_use] - pub fn associated_data(mut self, data: &[u8]) -> Self { - ct::zeroize(&mut self.associated_data); - self.associated_data.clear(); - self.associated_data.extend_from_slice(data); - self - } - - /// Validate every field against RFC 9106 bounds and return the finalised - /// parameter set. - pub fn build(self) -> Result { - self.validate()?; - Ok(self) - } - - /// Run validation without consuming the builder — returns the first error. - pub fn validate(&self) -> Result<(), Argon2Error> { - if self.time_cost < 1 { +impl Argon2Params { + /// Construct an Argon2 v1.3 parameter profile. + pub const fn new(memory_cost_kib: u32, time_cost: u32, parallelism: u32) -> Result { + if time_cost < 1 { return Err(Argon2Error::InvalidTimeCost); } - if self.parallelism < 1 || self.parallelism > (1 << 24) - 1 { + if parallelism < 1 || parallelism > (1 << 24) - 1 { return Err(Argon2Error::InvalidParallelism); } - // RFC 9106 §3.1: m >= 8 * p, m <= 2^32 - 1. - let min_memory = self.parallelism.checked_mul(8).ok_or(Argon2Error::InvalidMemoryCost)?; - if self.memory_cost_kib < min_memory { + let Some(min_memory) = parallelism.checked_mul(8) else { + return Err(Argon2Error::InvalidMemoryCost); + }; + if memory_cost_kib < min_memory { return Err(Argon2Error::InvalidMemoryCost); } - if (self.output_len as usize) < MIN_OUTPUT_LEN { - return Err(Argon2Error::InvalidOutputLen); - } - if self.secret.len() as u64 > MAX_VAR_BYTES { - return Err(Argon2Error::SecretTooLong); - } - if self.associated_data.len() as u64 > MAX_VAR_BYTES { - return Err(Argon2Error::AssociatedDataTooLong); - } - Ok(()) + Ok(Self { + time_cost, + memory_cost_kib, + parallelism, + }) } - /// Common length checks for `password` / `salt` shared by all three variants. - fn check_inputs(&self, password: &[u8], salt: &[u8]) -> Result<(), Argon2Error> { + fn check_inputs(password: &[u8], salt: &[u8], context: Argon2Context<'_>) -> Result<(), Argon2Error> { if password.len() as u64 > MAX_VAR_BYTES { return Err(Argon2Error::PasswordTooLong); } @@ -443,6 +316,12 @@ impl Argon2Params { if salt.len() as u64 > MAX_VAR_BYTES { return Err(Argon2Error::SaltTooLong); } + if context.secret.len() as u64 > MAX_VAR_BYTES { + return Err(Argon2Error::SecretTooLong); + } + if context.associated_data.len() as u64 > MAX_VAR_BYTES { + return Err(Argon2Error::AssociatedDataTooLong); + } Ok(()) } @@ -463,68 +342,90 @@ impl Argon2Params { pub const fn get_parallelism(&self) -> u32 { self.parallelism } +} - /// Output tag length in bytes. - #[must_use] - pub const fn get_output_len(&self) -> u32 { - self.output_len +/// Borrowed optional Argon2 inputs that are not encoded in a PHC string. +#[derive(Clone, Copy, Default)] +pub struct Argon2Context<'a> { + secret: &'a [u8], + associated_data: &'a [u8], +} + +impl fmt::Debug for Argon2Context<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Argon2Context") + .field("secret", &"[REDACTED]") + .field("secret_len", &self.secret.len()) + .field("associated_data_len", &self.associated_data.len()) + .finish() } +} - /// Algorithm version. +impl<'a> Argon2Context<'a> { + /// Borrow a pepper and associated data for one Argon2 operation. #[must_use] - pub const fn get_version(&self) -> Argon2Version { - self.version + pub const fn new(secret: &'a [u8], associated_data: &'a [u8]) -> Self { + Self { + secret, + associated_data, + } } } -/// Operational limits for verifying Argon2 PHC strings from untrusted storage. -/// -/// PHC strings encode their own memory, iteration, parallelism, and output -/// lengths. `Argon2id::verify_string` and the matching variant helpers apply -/// the default policy. Use `verify_string_with_policy` when a service needs -/// tighter or looser deployment ceilings. +/// Finite memory and work ceilings for Argon2id password verification. +#[cfg(feature = "phc-strings")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Argon2VerifyPolicy { - /// Maximum encoded memory cost in KiB. - pub max_memory_cost_kib: u32, - /// Maximum encoded time cost. - pub max_time_cost: u32, - /// Maximum encoded parallelism. - pub max_parallelism: u32, - /// Maximum encoded output length in bytes. - pub max_output_len: usize, -} - -impl Argon2VerifyPolicy { - /// Build a policy from explicit upper bounds. +pub struct Argon2VerificationLimits { + max_memory_bytes: u64, + max_block_work: u64, + max_parallelism: u32, +} + +#[derive(Clone, Copy)] +struct Argon2Shape { + blocks: u32, + memory_bytes: u64, + #[cfg(feature = "phc-strings")] + block_work: u64, +} + +const fn argon2_shape(params: Argon2Params) -> Argon2Shape { + let lane_group = params.parallelism.strict_mul(SYNC_POINTS); + let blocks = (params.memory_cost_kib / lane_group).strict_mul(lane_group); + Argon2Shape { + blocks, + memory_bytes: (blocks as u64).strict_mul(BLOCK_SIZE as u64), + #[cfg(feature = "phc-strings")] + block_work: (blocks as u64).strict_mul(params.time_cost as u64), + } +} + +#[cfg(feature = "phc-strings")] +impl Argon2VerificationLimits { + /// Derive ceilings from the largest deployment profile the verifier admits. #[must_use] - pub const fn new(max_memory_cost_kib: u32, max_time_cost: u32, max_parallelism: u32, max_output_len: usize) -> Self { + pub const fn for_profile(params: Argon2Params) -> Self { + let shape = argon2_shape(params); Self { - max_memory_cost_kib, - max_time_cost, - max_parallelism, - max_output_len, + max_memory_bytes: shape.memory_bytes, + max_block_work: shape.block_work, + max_parallelism: params.parallelism, } } - /// Return `true` when `params` and `output_len` are within this policy. - #[must_use] - pub const fn allows(&self, params: &Argon2Params, output_len: usize) -> bool { - params.memory_cost_kib <= self.max_memory_cost_kib - && params.time_cost <= self.max_time_cost - && params.parallelism <= self.max_parallelism - && output_len <= self.max_output_len + const fn allows(&self, params: Argon2Params) -> bool { + let usage = Self::for_profile(params); + usage.max_memory_bytes <= self.max_memory_bytes + && usage.max_block_work <= self.max_block_work + && usage.max_parallelism <= self.max_parallelism } } -impl Default for Argon2VerifyPolicy { +#[cfg(feature = "phc-strings")] +impl Default for Argon2VerificationLimits { fn default() -> Self { - Self::new( - DEFAULT_MEMORY_KIB, - DEFAULT_TIME_COST, - DEFAULT_PARALLELISM, - DEFAULT_OUTPUT_LEN, - ) + Self::for_profile(Argon2Params::default()) } } @@ -552,8 +453,7 @@ pub fn diag_active_kernel() -> KernelId { /// /// # Errors /// -/// Returns [`Argon2Error`] for invalid parameters per -/// [`Argon2Params::validate`]. +/// Returns [`Argon2Error`] for invalid operation inputs or output length. #[cfg(feature = "diag")] pub fn diag_hash_active( params: &Argon2Params, @@ -569,8 +469,7 @@ pub fn diag_hash_active( /// /// # Errors /// -/// Returns [`Argon2Error`] for invalid parameters per -/// [`Argon2Params::validate`]. +/// Returns [`Argon2Error`] for invalid operation inputs or output length. #[cfg(feature = "diag")] pub fn diag_hash_portable( params: &Argon2Params, @@ -967,7 +866,7 @@ fn h_prime(input_parts: &[&[u8]], out: &mut [u8]) { // Feed LE32(out_len) then the input parts into Blake2b. For out_len <= 64 // the single-block output is the answer directly. let len_le = u32::try_from(out_len) - .unwrap_or_else(|_| unreachable!("Argon2 H' out_len <= u32::MAX, validated by Argon2Params::validate")) + .unwrap_or_else(|_| unreachable!("Argon2 H' output length was checked before expansion")) .to_le_bytes(); if out_len <= 64 { @@ -1021,7 +920,7 @@ fn h_prime_diag_blake2b_portable(input_parts: &[&[u8]], out: &mut [u8]) { let out_len = out.len(); assert!(out_len > 0, "H' output length must be positive"); let len_le = u32::try_from(out_len) - .unwrap_or_else(|_| unreachable!("Argon2 H' out_len <= u32::MAX, validated by Argon2Params::validate")) + .unwrap_or_else(|_| unreachable!("Argon2 H' output length was checked before expansion")) .to_le_bytes(); if out_len <= 64 { @@ -1072,7 +971,14 @@ fn h_prime_diag_blake2b_portable(input_parts: &[&[u8]], out: &mut [u8]) { // ─── H0 initialisation (RFC 9106 §3.2) ────────────────────────────────────── /// Compute the 64-byte `H0` seed. -fn compute_h0(params: &Argon2Params, password: &[u8], salt: &[u8], variant: Argon2Variant) -> [u8; 64] { +fn compute_h0( + params: &Argon2Params, + context: Argon2Context<'_>, + password: &[u8], + salt: &[u8], + variant: Argon2Variant, + output_len: usize, +) -> [u8; 64] { // All input lengths have been bounded ≤ MAX_VAR_BYTES (= u32::MAX) by // `check_inputs` before reaching here, so the `try_from` calls below are // infallible. We use `try_from + expect` rather than `as u32` to keep the @@ -1085,28 +991,30 @@ fn compute_h0(params: &Argon2Params, password: &[u8], salt: &[u8], variant: Argo let mut hasher = Blake2b512::new(); hasher.update(¶ms.parallelism.to_le_bytes()); - hasher.update(¶ms.output_len.to_le_bytes()); + hasher.update(&len_u32("output", output_len)); hasher.update(¶ms.memory_cost_kib.to_le_bytes()); hasher.update(¶ms.time_cost.to_le_bytes()); - hasher.update(¶ms.version.as_u32().to_le_bytes()); + hasher.update(&ARGON2_VERSION.to_le_bytes()); hasher.update(&variant.y().to_le_bytes()); hasher.update(&len_u32("password", password.len())); hasher.update(password); hasher.update(&len_u32("salt", salt.len())); hasher.update(salt); - hasher.update(&len_u32("secret", params.secret.len())); - hasher.update(¶ms.secret); - hasher.update(&len_u32("associated_data", params.associated_data.len())); - hasher.update(¶ms.associated_data); + hasher.update(&len_u32("secret", context.secret.len())); + hasher.update(context.secret); + hasher.update(&len_u32("associated_data", context.associated_data.len())); + hasher.update(context.associated_data); hasher.finalize() } #[cfg(feature = "diag")] fn compute_h0_diag_blake2b_portable( params: &Argon2Params, + context: Argon2Context<'_>, password: &[u8], salt: &[u8], variant: Argon2Variant, + output_len: usize, ) -> [u8; 64] { let len_u32 = |label: &'static str, len: usize| -> [u8; 4] { u32::try_from(len) @@ -1115,15 +1023,15 @@ fn compute_h0_diag_blake2b_portable( }; let parallelism = params.parallelism.to_le_bytes(); - let output_len = params.output_len.to_le_bytes(); + let output_len = len_u32("output", output_len); let memory_cost = params.memory_cost_kib.to_le_bytes(); let time_cost = params.time_cost.to_le_bytes(); - let version = params.version.as_u32().to_le_bytes(); + let version = ARGON2_VERSION.to_le_bytes(); let variant = variant.y().to_le_bytes(); let password_len = len_u32("password", password.len()); let salt_len = len_u32("salt", salt.len()); - let secret_len = len_u32("secret", params.secret.len()); - let associated_data_len = len_u32("associated_data", params.associated_data.len()); + let secret_len = len_u32("secret", context.secret.len()); + let associated_data_len = len_u32("associated_data", context.associated_data.len()); let mut out = [0u8; 64]; crate::hashes::crypto::blake2b::diag_hash_parts_portable( 64, @@ -1139,9 +1047,9 @@ fn compute_h0_diag_blake2b_portable( &salt_len, salt, &secret_len, - ¶ms.secret, + context.secret, &associated_data_len, - ¶ms.associated_data, + context.associated_data, ], &mut out, ); @@ -1235,17 +1143,20 @@ struct Matrix { } impl Matrix { - fn new(mem_kib: u32, lanes: u32) -> Result { - // m' = 4 · p · ⌊m / (4p)⌋ - let four_p = lanes.strict_mul(SYNC_POINTS); - let m_prime = mem_kib / four_p * four_p; + fn new(params: Argon2Params) -> Result { + let shape = argon2_shape(params); + let lanes = params.parallelism; + let m_prime = shape.blocks; let lane_len = m_prime / lanes; let segment_len = lane_len / SYNC_POINTS; + if shape.memory_bytes > isize::MAX as u64 { + return Err(Argon2Error::ResourceOverflow); + } let total = m_prime as usize; let mut blocks = Vec::new(); blocks .try_reserve_exact(total) - .map_err(|_| Argon2Error::InvalidMemoryCost)?; + .map_err(|_| Argon2Error::AllocationFailed)?; blocks.resize(total, MemoryBlock::zero()); Ok(Self { blocks, @@ -1470,7 +1381,6 @@ fn fill_segment( lane: u32, slice: u32, variant: Argon2Variant, - version: Argon2Version, time_cost: u32, ) { let lanes = matrix.lanes; @@ -1494,7 +1404,6 @@ fn fill_segment( lane_len, total_blocks, variant, - version, time_cost, ); } @@ -1533,7 +1442,6 @@ unsafe fn fill_segment_inner( lane_len: u32, total_blocks: u32, variant: Argon2Variant, - version: Argon2Version, time_cost: u32, ) { let variant_y = variant.y(); @@ -1615,7 +1523,7 @@ unsafe fn fill_segment_inner( // Compute new block: G(B[lane][prev], B[ref_lane][ref_index]), optionally // XOR-accumulated into existing block (v1.3, pass > 0). - let xor_into = pass > 0 && version == Argon2Version::V0x13; + let xor_into = pass > 0; let ref_idx = (ref_lane as usize) .strict_mul(lane_len_usize) .strict_add(ref_index as usize); @@ -1655,12 +1563,11 @@ fn fill_slice_sequential( pass: u32, slice: u32, variant: Argon2Variant, - version: Argon2Version, time_cost: u32, ) { let lanes = matrix.lanes; for lane in 0..lanes { - fill_segment(matrix, compress, pass, lane, slice, variant, version, time_cost); + fill_segment(matrix, compress, pass, lane, slice, variant, time_cost); } } @@ -1683,7 +1590,6 @@ fn fill_slice_parallel( pass: u32, slice: u32, variant: Argon2Variant, - version: Argon2Version, time_cost: u32, ) { let lanes = matrix.lanes; @@ -1727,7 +1633,6 @@ fn fill_slice_parallel( lane_len, total_blocks, variant, - version, time_cost, ); } @@ -1749,7 +1654,6 @@ fn fill_slice_parallel( lane_len, total_blocks, variant, - version, time_cost, ); } @@ -1768,15 +1672,14 @@ fn fill_slice( pass: u32, slice: u32, variant: Argon2Variant, - version: Argon2Version, time_cost: u32, ) { #[cfg(feature = "parallel")] if matrix.lanes > 1 && matrix.segment_len >= MIN_PARALLEL_SEGMENT_BLOCKS { - fill_slice_parallel(matrix, compress, pass, slice, variant, version, time_cost); + fill_slice_parallel(matrix, compress, pass, slice, variant, time_cost); return; } - fill_slice_sequential(matrix, compress, pass, slice, variant, version, time_cost); + fill_slice_sequential(matrix, compress, pass, slice, variant, time_cost); } // ─── Full Argon2 hash function ───────────────────────────────────────────── @@ -1788,9 +1691,21 @@ fn argon2_hash( variant: Argon2Variant, out: &mut [u8], ) -> Result<(), Argon2Error> { - argon2_hash_with_kernel(params, password, salt, variant, out, active_compress()) + argon2_hash_with_context(params, Argon2Context::default(), password, salt, variant, out) } +fn argon2_hash_with_context( + params: &Argon2Params, + context: Argon2Context<'_>, + password: &[u8], + salt: &[u8], + variant: Argon2Variant, + out: &mut [u8], +) -> Result<(), Argon2Error> { + argon2_hash_with_kernel_inner(params, context, password, salt, variant, out, active_compress(), false) +} + +#[cfg(feature = "diag")] fn argon2_hash_with_kernel( params: &Argon2Params, password: &[u8], @@ -1799,7 +1714,16 @@ fn argon2_hash_with_kernel( out: &mut [u8], compress: CompressFn, ) -> Result<(), Argon2Error> { - argon2_hash_with_kernel_inner(params, password, salt, variant, out, compress, false) + argon2_hash_with_kernel_inner( + params, + Argon2Context::default(), + password, + salt, + variant, + out, + compress, + false, + ) } #[cfg(feature = "diag")] @@ -1811,11 +1735,22 @@ fn argon2_hash_with_kernel_diag_blake2b( out: &mut [u8], compress: CompressFn, ) -> Result<(), Argon2Error> { - argon2_hash_with_kernel_inner(params, password, salt, variant, out, compress, true) + argon2_hash_with_kernel_inner( + params, + Argon2Context::default(), + password, + salt, + variant, + out, + compress, + true, + ) } +#[allow(clippy::too_many_arguments)] // Params, context, inputs, variant, output, and kernel are the real operation boundary. fn argon2_hash_with_kernel_inner( params: &Argon2Params, + context: Argon2Context<'_>, password: &[u8], salt: &[u8], variant: Argon2Variant, @@ -1823,9 +1758,8 @@ fn argon2_hash_with_kernel_inner( compress: CompressFn, #[cfg_attr(not(feature = "diag"), allow(unused_variables))] diag_blake2b: bool, ) -> Result<(), Argon2Error> { - params.validate()?; - params.check_inputs(password, salt)?; - if out.len() < MIN_OUTPUT_LEN || out.len() as u64 > MAX_VAR_BYTES || out.len() != params.output_len as usize { + Argon2Params::check_inputs(password, salt, context)?; + if out.len() < MIN_OUTPUT_LEN || out.len() as u64 > MAX_VAR_BYTES { return Err(Argon2Error::InvalidOutputLen); } @@ -1834,19 +1768,19 @@ fn argon2_hash_with_kernel_inner( #[cfg(feature = "diag")] { if diag_blake2b { - compute_h0_diag_blake2b_portable(params, password, salt, variant) + compute_h0_diag_blake2b_portable(params, context, password, salt, variant, out.len()) } else { - compute_h0(params, password, salt, variant) + compute_h0(params, context, password, salt, variant, out.len()) } } #[cfg(not(feature = "diag"))] { - compute_h0(params, password, salt, variant) + compute_h0(params, context, password, salt, variant, out.len()) } }; // Allocate the memory matrix. - let mut matrix = Matrix::new(params.memory_cost_kib, params.parallelism)?; + let mut matrix = Matrix::new(*params)?; let lane_len = matrix.lane_len; let lanes = matrix.lanes; @@ -1883,15 +1817,7 @@ fn argon2_hash_with_kernel_inner( // skipped when `parallelism == 1` to avoid rayon overhead. for pass in 0..params.time_cost { for slice in 0..SYNC_POINTS { - fill_slice( - &mut matrix, - compress, - pass, - slice, - variant, - params.version, - params.time_cost, - ); + fill_slice(&mut matrix, compress, pass, slice, variant, params.time_cost); } } @@ -1926,24 +1852,22 @@ fn argon2_hash_with_kernel_inner( macro_rules! define_argon2_variant { ( $(#[$meta:meta])* - $name:ident { variant: $variant:expr, phc_algorithm: $phc_alg:literal } + $name:ident { variant: $variant:expr, algorithm: $algorithm:literal } ) => { $(#[$meta])* #[derive(Debug, Clone, Copy, Default)] pub struct $name; impl $name { - /// Algorithm identifier used in PHC strings and diagnostics (e.g. - /// `"argon2d"`, `"argon2i"`, `"argon2id"`). - pub const ALGORITHM: &'static str = $phc_alg; + /// Algorithm identifier used in diagnostics. + pub const ALGORITHM: &'static str = $algorithm; - /// Hash `password` with `salt` into `out`. + /// Derive bytes from `password` and `salt` into `out`. /// /// # Errors /// - /// Returns [`Argon2Error`] if parameters are out of range, the salt is - /// too short, or `out.len()` does not match `params.output_len`. - pub fn hash( + /// Returns [`Argon2Error`] if the salt or output length is invalid. + pub fn derive( params: &Argon2Params, password: &[u8], salt: &[u8], @@ -1952,29 +1876,23 @@ macro_rules! define_argon2_variant { argon2_hash(params, password, salt, $variant, out) } - /// Hash `password` with `salt` into a fixed-size array. + /// Derive bytes with borrowed pepper and associated data. /// /// # Errors /// - /// Returns [`Argon2Error`] if `N != params.output_len`. - pub fn hash_array( + /// Returns [`Argon2Error`] if any input length is invalid. + pub fn derive_with_context( params: &Argon2Params, + context: Argon2Context<'_>, password: &[u8], salt: &[u8], - ) -> Result<[u8; N], Argon2Error> { - let mut out = [0u8; N]; - Self::hash(params, password, salt, &mut out)?; - Ok(out) + out: &mut [u8], + ) -> Result<(), Argon2Error> { + argon2_hash_with_context(params, context, password, salt, $variant, out) } /// Verify `expected` against a freshly-computed hash in constant time. /// - /// The Argon2 hash always runs, even when `expected.len()` does not - /// match `params.output_len`. The length check is folded into the - /// final boolean, not an early return, so wall-clock cost does not - /// depend on whether the supplied tag has the right length — - /// `params.output_len` is not leaked through timing. - /// /// # Errors /// /// Returns an opaque [`VerificationError`] on any mismatch, malformed @@ -1986,17 +1904,15 @@ macro_rules! define_argon2_variant { salt: &[u8], expected: &[u8], ) -> Result<(), VerificationError> { - // Always allocate and hash to `params.output_len` regardless of - // `expected.len()`. The dominant cost (Argon2 fill) is constant; the - // residual byte-compare cost difference (≤ tens of ns) is dwarfed by - // the hash itself (≥ milliseconds at any realistic cost knobs). - let actual_len = params.output_len as usize; - let mut actual = alloc::vec![0u8; actual_len]; - let hash_failed = Self::hash(params, password, salt, &mut actual).is_err(); - - // `constant_time_eq` returns false for length mismatches; combined - // with the always-run hash above, length-vs-bytes cases collapse - // into a single failure path. + if expected.len() < MIN_OUTPUT_LEN || expected.len() as u64 > MAX_VAR_BYTES { + return Err(VerificationError::new()); + } + let mut actual = Vec::new(); + actual + .try_reserve_exact(expected.len()) + .map_err(|_| VerificationError::new())?; + actual.resize(expected.len(), 0); + let hash_failed = Self::derive(params, password, salt, &mut actual).is_err(); let bytes_match = ct::constant_time_eq(&actual, expected); ct::zeroize(&mut actual); @@ -2008,228 +1924,6 @@ macro_rules! define_argon2_variant { } } - /// Hash `password` with `salt` and encode the result as a PHC string. - /// - /// Emits `$argon2{d|i|id}$v=$m=,t=,p=

$$` - /// (standard RFC 4648 base64, no padding). `params.secret` and - /// `params.associated_data` are honoured during hashing but are **not** - /// embedded in the PHC string, per the PHC spec — callers holding - /// secrets must re-supply them when verifying. - /// - /// # Errors - /// - /// Propagates any [`Argon2Error`] from parameter validation or input - /// length checks. - #[cfg(feature = "phc-strings")] - pub fn hash_string_with_salt( - params: &Argon2Params, - password: &[u8], - salt: &[u8], - ) -> Result { - let mut hash = alloc::vec![0u8; params.output_len as usize]; - Self::hash(params, password, salt, &mut hash)?; - let encoded = phc_integration::encode_string(Self::ALGORITHM, params, salt, &hash); - ct::zeroize(&mut hash); - Ok(encoded) - } - - /// Hash `password` with a fresh 16-byte salt from the operating - /// system CSPRNG and encode the result as a PHC string. - /// - /// # Errors - /// - /// Propagates any [`Argon2Error`] from parameter validation or input - /// length checks. Returns [`Argon2Error::EntropyUnavailable`] if the - /// platform entropy source fails. - #[cfg(all(feature = "phc-strings", feature = "getrandom"))] - pub fn hash_string( - params: &Argon2Params, - password: &[u8], - ) -> Result { - let mut salt = [0u8; 16]; - getrandom::fill(&mut salt).map_err(|_| Argon2Error::EntropyUnavailable)?; - Self::hash_string_with_salt(params, password, &salt) - } - - /// Verify `password` against a PHC-encoded hash in constant time. - /// - /// Parses the encoded string, rebuilds the cost parameters, re-hashes - /// `password` with the embedded salt, and compares in constant time. - /// Works only when the encoded string's algorithm matches `Self` — a - /// PHC string emitted by another variant is rejected opaquely. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// variant mismatch, parameter error, or default-policy violation. Errors - /// are intentionally opaque — callers needing to distinguish parse - /// failures should use the lower-level `decode_string` helper. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string( - password: &[u8], - encoded: &str, - ) -> Result<(), VerificationError> { - Self::verify_string_with_policy(password, encoded, &Argon2VerifyPolicy::default()) - } - - /// Verify `password` against a PHC string after enforcing operational - /// bounds on its encoded cost parameters. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// variant mismatch, parameter error, or policy violation. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_with_policy( - password: &[u8], - encoded: &str, - policy: &Argon2VerifyPolicy, - ) -> Result<(), VerificationError> { - Self::verify_string_with_context_and_policy(password, encoded, &[], &[], policy) - } - - /// Verify `password` against a PHC-encoded hash using an external - /// pepper and/or associated data. - /// - /// PHC strings carry the algorithm, cost parameters, salt, and hash. - /// They do not carry secret pepper material. This helper is the - /// verification counterpart for PHC strings produced from - /// [`Argon2Params::secret`] or [`Argon2Params::associated_data`]. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// variant mismatch, parameter error, or default-policy violation. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_with_context( - password: &[u8], - encoded: &str, - secret: &[u8], - associated_data: &[u8], - ) -> Result<(), VerificationError> { - Self::verify_string_with_context_and_policy( - password, - encoded, - secret, - associated_data, - &Argon2VerifyPolicy::default(), - ) - } - - /// Verify `password` against a PHC-encoded hash without cost bounds. - /// - /// This compatibility helper accepts the encoded memory, iteration, - /// parallelism, and output length as long as the PHC string itself is - /// structurally valid. Use it only for trusted local migration inputs or - /// test-vector harnesses. For stored password verification, prefer - /// [`verify_string`](Self::verify_string) or - /// [`verify_string_with_policy`](Self::verify_string_with_policy). - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// variant mismatch, or parameter error. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_unbounded( - password: &[u8], - encoded: &str, - ) -> Result<(), VerificationError> { - Self::verify_string_with_context_unbounded(password, encoded, &[], &[]) - } - - /// Verify `password` against a PHC string with external context and no - /// cost bounds. - /// - /// This is the compatibility counterpart to - /// [`verify_string_with_context`](Self::verify_string_with_context). Use - /// it only when the PHC string source is trusted and the caller has - /// already accepted the encoded cost parameters. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// variant mismatch, or parameter error. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_with_context_unbounded( - password: &[u8], - encoded: &str, - secret: &[u8], - associated_data: &[u8], - ) -> Result<(), VerificationError> { - Self::verify_string_with_context_and_policy( - password, - encoded, - secret, - associated_data, - &Argon2VerifyPolicy::new(u32::MAX, u32::MAX, u32::MAX, usize::MAX), - ) - } - - /// Verify `password` against a PHC string with external context and - /// explicit operational bounds. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// variant mismatch, parameter error, or policy violation. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_with_context_and_policy( - password: &[u8], - encoded: &str, - secret: &[u8], - associated_data: &[u8], - policy: &Argon2VerifyPolicy, - ) -> Result<(), VerificationError> { - let parsed = phc_integration::decode_string(encoded, Self::ALGORITHM) - .map_err(|_| VerificationError::new())?; - let mut params = parsed.params; - if !secret.is_empty() { - params = params.secret(secret); - } - if !associated_data.is_empty() { - params = params.associated_data(associated_data); - } - params = params.build().map_err(|_| VerificationError::new())?; - if !policy.allows(¶ms, parsed.hash.len()) { - return Err(VerificationError::new()); - } - let mut actual = alloc::vec![0u8; parsed.hash.len()]; - if Self::hash(¶ms, password, &parsed.salt, &mut actual).is_err() { - ct::zeroize(&mut actual); - return Err(VerificationError::new()); - } - let ok = ct::constant_time_eq(&actual, &parsed.hash); - ct::zeroize(&mut actual); - if core::hint::black_box(ok) { - Ok(()) - } else { - Err(VerificationError::new()) - } - } - - /// Decode a PHC string without re-hashing. - /// - /// Returns the parsed cost parameters, salt, and reference hash. Use - /// when you need programmatic access to the encoded components — - /// e.g. to re-hash with a new salt or compare multiple candidates. - /// - /// # Errors - /// - /// Returns [`crate::auth::phc::PhcError`] on any parse failure, or if - /// the encoded algorithm does not match `Self::ALGORITHM`. - #[cfg(feature = "phc-strings")] - pub fn decode_string( - encoded: &str, - ) -> Result<(Argon2Params, alloc::vec::Vec, alloc::vec::Vec), crate::auth::phc::PhcError> { - let parsed = phc_integration::decode_string(encoded, Self::ALGORITHM)?; - Ok((parsed.params, parsed.salt, parsed.hash)) - } } }; } @@ -2245,11 +1939,11 @@ define_argon2_variant! { /// /// ```rust /// use rscrypto::{Argon2Params, Argon2d}; - /// let params = Argon2Params::new().memory_cost_kib(32).time_cost(3).parallelism(4).output_len(32).build().unwrap(); + /// let params = Argon2Params::new(32, 3, 4).unwrap(); /// let mut h = [0u8; 32]; - /// Argon2d::hash(¶ms, &[0x01; 32], &[0x02; 16], &mut h).unwrap(); + /// Argon2d::derive(¶ms, &[0x01; 32], &[0x02; 16], &mut h).unwrap(); /// ``` - Argon2d { variant: Argon2Variant::Argon2d, phc_algorithm: "argon2d" } + Argon2d { variant: Argon2Variant::Argon2d, algorithm: "argon2d" } } define_argon2_variant! { @@ -2264,133 +1958,250 @@ define_argon2_variant! { /// /// ```rust /// use rscrypto::{Argon2Params, Argon2i}; - /// let params = Argon2Params::new().memory_cost_kib(32).time_cost(3).parallelism(4).output_len(32).build().unwrap(); + /// let params = Argon2Params::new(32, 3, 4).unwrap(); /// let mut h = [0u8; 32]; - /// Argon2i::hash(¶ms, &[0x01; 32], &[0x02; 16], &mut h).unwrap(); + /// Argon2i::derive(¶ms, &[0x01; 32], &[0x02; 16], &mut h).unwrap(); /// ``` - Argon2i { variant: Argon2Variant::Argon2i, phc_algorithm: "argon2i" } + Argon2i { variant: Argon2Variant::Argon2i, algorithm: "argon2i" } } define_argon2_variant! { /// Argon2id — hybrid variant of Argon2 (RFC 9106). **Recommended default.** /// /// Runs Argon2i (data-independent) for the first half of the first pass - /// and Argon2d (data-dependent) for the rest. OWASP 2024 recommends this + /// and Argon2d (data-dependent) for the rest. OWASP recommends this /// variant for password hashing. /// /// # Examples /// /// ```rust /// use rscrypto::{Argon2Params, Argon2id}; - /// let params = Argon2Params::new().memory_cost_kib(32).time_cost(3).parallelism(4).output_len(32).build().unwrap(); + /// let params = Argon2Params::new(32, 3, 4).unwrap(); /// let mut h = [0u8; 32]; - /// Argon2id::hash(¶ms, &[0x01; 32], &[0x02; 16], &mut h).unwrap(); + /// Argon2id::derive(¶ms, &[0x01; 32], &[0x02; 16], &mut h).unwrap(); /// ``` - Argon2id { variant: Argon2Variant::Argon2id, phc_algorithm: "argon2id" } + Argon2id { variant: Argon2Variant::Argon2id, algorithm: "argon2id" } } -// ─── PHC string integration (feature: phc-strings) ───────────────────────── +/// Argon2id password generation and bounded PHC verification. +#[cfg(feature = "phc-strings")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Argon2idPassword { + generation: Argon2Params, + limits: Argon2VerificationLimits, +} + +#[cfg(feature = "phc-strings")] +impl Default for Argon2idPassword { + fn default() -> Self { + let generation = Argon2Params::default(); + Self { + generation, + limits: Argon2VerificationLimits::for_profile(generation), + } + } +} #[cfg(feature = "phc-strings")] -mod phc_integration { - use alloc::{string::String, vec::Vec}; +impl Argon2idPassword { + /// Use `generation` for new hashes and derive matching verification limits. + /// + /// # Errors + /// + /// Returns [`Argon2Error::ResourceOverflow`] if the profile cannot fit the + /// target address space. + pub fn new(generation: Argon2Params) -> Result { + if argon2_shape(generation).memory_bytes > isize::MAX as u64 { + return Err(Argon2Error::ResourceOverflow); + } + Ok(Self { + generation, + limits: Argon2VerificationLimits::for_profile(generation), + }) + } + + /// Use distinct generation parameters and finite verification limits. + pub fn with_limits(generation: Argon2Params, limits: Argon2VerificationLimits) -> Result { + if argon2_shape(generation).memory_bytes > isize::MAX as u64 { + return Err(Argon2Error::ResourceOverflow); + } + if !limits.allows(generation) { + return Err(Argon2Error::VerificationLimitTooLow); + } + Ok(Self { generation, limits }) + } - use super::{Argon2Params, Argon2Version}; + /// Hash a password with an OS-generated salt and canonical Argon2id PHC encoding. + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] + pub fn hash_password(&self, password: &[u8]) -> Result { + self.hash_password_with_context(password, Argon2Context::default()) + } + + /// Hash a password with borrowed pepper and associated data. + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] + pub fn hash_password_with_context( + &self, + password: &[u8], + context: Argon2Context<'_>, + ) -> Result { + let mut salt = [0u8; PASSWORD_SALT_LEN]; + getrandom::fill(&mut salt).map_err(|_| Argon2Error::EntropyUnavailable)?; + let mut verifier = crate::secret::ZeroizingBytes::::zeroed(); + Argon2id::derive_with_context(&self.generation, context, password, &salt, verifier.as_mut_array())?; + Ok(password_phc::encode(self.generation, &salt, verifier.as_array())) + } + + /// Verify a password after approving all encoded resource requests. + #[cfg(feature = "phc-strings")] + #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] + pub fn verify_password( + &self, + password: &[u8], + encoded: &str, + ) -> Result { + self.verify_password_with_context(password, encoded, Argon2Context::default()) + } + + /// Verify a password with borrowed pepper and associated data. + #[cfg(feature = "phc-strings")] + #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] + pub fn verify_password_with_context( + &self, + password: &[u8], + encoded: &str, + context: Argon2Context<'_>, + ) -> Result { + let approved = password_phc::approve(encoded, self.limits).map_err(|_| VerificationError::new())?; + let mut actual = crate::secret::ZeroizingBytes::::zeroed(); + Argon2id::derive_with_context( + &approved.params, + context, + password, + approved.salt(), + actual.as_mut_array(), + ) + .map_err(|_| VerificationError::new())?; + let verified = ct::constant_time_eq(actual.as_array(), &approved.expected); + if !core::hint::black_box(verified) { + return Err(VerificationError::new()); + } + if approved.params == self.generation && approved.salt_len as usize == PASSWORD_SALT_LEN { + Ok(crate::auth::PasswordStatus::Current) + } else { + Ok(crate::auth::PasswordStatus::NeedsRehash) + } + } +} + +#[cfg(feature = "phc-strings")] +const PASSWORD_SALT_LEN: usize = 16; +#[cfg(feature = "phc-strings")] +const MAX_PHC_SALT_LEN: usize = 48; +#[cfg(feature = "phc-strings")] +const PASSWORD_OUTPUT_LEN: usize = DEFAULT_OUTPUT_LEN; + +#[cfg(feature = "phc-strings")] +mod password_phc { + #[cfg(any(feature = "getrandom", test))] + use alloc::string::String; + + #[cfg(any(feature = "getrandom", test))] + use super::ARGON2_VERSION; + use super::{ + Argon2Params, Argon2VerificationLimits, MAX_PHC_SALT_LEN, MIN_SALT_LEN, PASSWORD_OUTPUT_LEN, argon2_shape, + }; use crate::auth::phc::{self, PhcError}; - /// Parsed PHC components reconstituted into rscrypto types. - pub(super) struct ParsedPhc { + pub(super) struct ApprovedPhc { pub params: Argon2Params, - pub salt: Vec, - pub hash: Vec, + pub salt: [u8; MAX_PHC_SALT_LEN], + pub salt_len: u8, + pub expected: [u8; PASSWORD_OUTPUT_LEN], } - /// Build `$argon2{d|i|id}$v=$m=,t=,p=

$$`. - pub(super) fn encode_string(algorithm: &str, params: &Argon2Params, salt: &[u8], hash: &[u8]) -> String { - let mut out = String::new(); - out.push('$'); - out.push_str(algorithm); - out.push_str("$v="); - phc::push_u32_decimal(&mut out, params.get_version().as_u32()); - out.push_str("$m="); - phc::push_u32_decimal(&mut out, params.get_memory_cost_kib()); - out.push_str(",t="); - phc::push_u32_decimal(&mut out, params.get_time_cost()); - out.push_str(",p="); - phc::push_u32_decimal(&mut out, params.get_parallelism()); - out.push('$'); - phc::base64_encode_into(salt, &mut out); - out.push('$'); - phc::base64_encode_into(hash, &mut out); - out + impl ApprovedPhc { + pub fn salt(&self) -> &[u8] { + &self.salt[..self.salt_len as usize] + } + } + + fn next_param<'a>(params: &mut phc::PhcParamIter<'a>, expected: &str) -> Result<&'a str, PhcError> { + let (key, value) = params.next().ok_or(PhcError::MissingParam)??; + if key != expected { + return Err(if matches!(key, "m" | "t" | "p") { + PhcError::DuplicateParam + } else { + PhcError::UnknownParam + }); + } + Ok(value) } - /// Parse a PHC string and reconstitute `(params, salt, hash)`. - pub(super) fn decode_string(encoded: &str, expected_algorithm: &str) -> Result { + pub(super) fn approve(encoded: &str, limits: Argon2VerificationLimits) -> Result { let parts = phc::parse(encoded)?; - if parts.algorithm != expected_algorithm { + if parts.algorithm != "argon2id" { return Err(PhcError::AlgorithmMismatch); } + if parts.version != Some("19") { + return Err(PhcError::UnsupportedVersion); + } - // Parse version. Required for Argon2 (RFC 9106 recommends v=19). - let version = match parts.version { - Some(v) => match phc::parse_param_u32(v)? { - 0x10 => Argon2Version::V0x10, - 0x13 => Argon2Version::V0x13, - _ => return Err(PhcError::UnsupportedVersion), - }, - None => Argon2Version::V0x13, // Permissive default: some encoders omit v=. - }; - - // Parse the params segment: exactly {m, t, p}, each exactly once, in any order. - let mut memory_kib: Option = None; - let mut time_cost: Option = None; - let mut parallelism: Option = None; - for pair in phc::PhcParamIter::new(parts.parameters) { - let (k, v) = pair?; - let value = phc::parse_param_u32(v)?; - match k { - "m" => { - if memory_kib.replace(value).is_some() { - return Err(PhcError::DuplicateParam); - } - } - "t" => { - if time_cost.replace(value).is_some() { - return Err(PhcError::DuplicateParam); - } - } - "p" => { - if parallelism.replace(value).is_some() { - return Err(PhcError::DuplicateParam); - } - } - _ => return Err(PhcError::UnknownParam), - } + let mut values = phc::PhcParamIter::new(parts.parameters); + let memory_cost_kib = phc::parse_param_u32(next_param(&mut values, "m")?)?; + let time_cost = phc::parse_param_u32(next_param(&mut values, "t")?)?; + let parallelism = phc::parse_param_u32(next_param(&mut values, "p")?)?; + if let Some(extra) = values.next() { + let (key, _) = extra?; + return Err(if matches!(key, "m" | "t" | "p") { + PhcError::DuplicateParam + } else { + PhcError::UnknownParam + }); + } + let params = Argon2Params::new(memory_cost_kib, time_cost, parallelism).map_err(|_| PhcError::ParamOutOfRange)?; + let shape = argon2_shape(params); + if shape.memory_bytes > isize::MAX as u64 || !limits.allows(params) { + return Err(PhcError::ParamOutOfRange); } - let m = memory_kib.ok_or(PhcError::MissingParam)?; - let t = time_cost.ok_or(PhcError::MissingParam)?; - let p = parallelism.ok_or(PhcError::MissingParam)?; - // Decode salt and hash. - let salt = phc::decode_base64_to_vec(parts.salt_b64)?; - let hash = phc::decode_base64_to_vec(parts.hash_b64)?; + let salt_len = phc::base64_decoded_len(parts.salt_b64.len()); + let output_len = phc::base64_decoded_len(parts.hash_b64.len()); + if !(MIN_SALT_LEN..=MAX_PHC_SALT_LEN).contains(&salt_len) || output_len != PASSWORD_OUTPUT_LEN { + return Err(PhcError::InvalidLength); + } - if salt.len() < super::MIN_SALT_LEN || hash.len() < super::MIN_OUTPUT_LEN { + let mut salt = [0u8; MAX_PHC_SALT_LEN]; + let decoded_salt_len = phc::base64_decode_into(parts.salt_b64, &mut salt)?; + let mut expected = [0u8; PASSWORD_OUTPUT_LEN]; + let decoded_output_len = phc::base64_decode_into(parts.hash_b64, &mut expected)?; + if decoded_salt_len != salt_len || decoded_output_len != PASSWORD_OUTPUT_LEN { return Err(PhcError::InvalidLength); } - // Build params with the extracted cost knobs; output_len equals the - // decoded hash length so verify's length check passes. - let params = Argon2Params::new() - .memory_cost_kib(m) - .time_cost(t) - .parallelism(p) - .output_len(hash.len() as u32) - .version(version); - let params = params.build().map_err(|_| PhcError::ParamOutOfRange)?; + Ok(ApprovedPhc { + params, + salt, + salt_len: decoded_salt_len as u8, + expected, + }) + } - Ok(ParsedPhc { params, salt, hash }) + #[cfg(any(feature = "getrandom", test))] + pub(super) fn encode(params: Argon2Params, salt: &[u8], verifier: &[u8; PASSWORD_OUTPUT_LEN]) -> String { + let mut out = String::with_capacity(128); + out.push_str("$argon2id$v="); + phc::push_u32_decimal(&mut out, ARGON2_VERSION); + out.push_str("$m="); + phc::push_u32_decimal(&mut out, params.get_memory_cost_kib()); + out.push_str(",t="); + phc::push_u32_decimal(&mut out, params.get_time_cost()); + out.push_str(",p="); + phc::push_u32_decimal(&mut out, params.get_parallelism()); + out.push('$'); + phc::base64_encode_into(salt, &mut out); + out.push('$'); + phc::base64_encode_into(verifier, &mut out); + out } } @@ -2403,155 +2214,83 @@ mod tests { use super::*; - // RFC 9106 Appendix A test vectors (version 0x13, m=32, t=3, p=4, T=32). #[cfg(not(miri))] - const PASSWORD: &[u8] = &[0x01u8; 32]; + const PASSWORD: &[u8] = &[0x01; 32]; #[cfg(not(miri))] - const SALT: &[u8] = &[0x02u8; 16]; + const SALT: &[u8] = &[0x02; 16]; #[cfg(not(miri))] - const SECRET: &[u8] = &[0x03u8; 8]; + const SECRET: &[u8] = &[0x03; 8]; #[cfg(not(miri))] - const AD: &[u8] = &[0x04u8; 12]; + const AD: &[u8] = &[0x04; 12]; #[cfg(not(miri))] fn canon_params() -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(32) - .time_cost(3) - .parallelism(4) - .output_len(32) - .version(Argon2Version::V0x13) - .secret(SECRET) - .associated_data(AD) - .build() - .unwrap() + Argon2Params::new(32, 3, 4).unwrap() } - // ── RFC 9106 Appendix A ──────────────────────────────────────────────── + #[cfg(not(miri))] + fn canon_context() -> Argon2Context<'static> { + Argon2Context::new(SECRET, AD) + } #[test] #[cfg(not(miri))] - fn argon2d_rfc_appendix_a_vector() { - let expected: [u8; 32] = [ + fn rfc9106_appendix_a_vectors() { + let expected_d: [u8; 32] = [ 0x51, 0x2b, 0x39, 0x1b, 0x6f, 0x11, 0x62, 0x97, 0x53, 0x71, 0xd3, 0x09, 0x19, 0x73, 0x42, 0x94, 0xf8, 0x68, 0xe3, 0xbe, 0x39, 0x84, 0xf3, 0xc1, 0xa1, 0x3a, 0x4d, 0xb9, 0xfa, 0xbe, 0x4a, 0xcb, ]; - let mut out = [0u8; 32]; - Argon2d::hash(&canon_params(), PASSWORD, SALT, &mut out).unwrap(); - assert_eq!(out, expected); - } - - #[test] - #[cfg(not(miri))] - fn argon2i_rfc_appendix_a_vector() { - let expected: [u8; 32] = [ + let expected_i: [u8; 32] = [ 0xc8, 0x14, 0xd9, 0xd1, 0xdc, 0x7f, 0x37, 0xaa, 0x13, 0xf0, 0xd7, 0x7f, 0x24, 0x94, 0xbd, 0xa1, 0xc8, 0xde, 0x6b, 0x01, 0x6d, 0xd3, 0x88, 0xd2, 0x99, 0x52, 0xa4, 0xc4, 0x67, 0x2b, 0x6c, 0xe8, ]; - let mut out = [0u8; 32]; - Argon2i::hash(&canon_params(), PASSWORD, SALT, &mut out).unwrap(); - assert_eq!(out, expected); - } - - #[test] - #[cfg(not(miri))] - fn argon2id_rfc_appendix_a_vector() { - let expected: [u8; 32] = [ + let expected_id: [u8; 32] = [ 0x0d, 0x64, 0x0d, 0xf5, 0x8d, 0x78, 0x76, 0x6c, 0x08, 0xc0, 0x37, 0xa3, 0x4a, 0x8b, 0x53, 0xc9, 0xd0, 0x1e, 0xf0, 0x45, 0x2d, 0x75, 0xb6, 0x5e, 0xb5, 0x25, 0x20, 0xe9, 0x6b, 0x01, 0xe6, 0x59, ]; - let mut out = [0u8; 32]; - Argon2id::hash(&canon_params(), PASSWORD, SALT, &mut out).unwrap(); - assert_eq!(out, expected); - } - - // ── Verify ───────────────────────────────────────────────────────────── - #[test] - #[cfg(not(miri))] - fn argon2id_verify_accepts_correct() { - let params = canon_params(); - let h = Argon2id::hash_array::<32>(¶ms, PASSWORD, SALT).unwrap(); - assert!(Argon2id::verify(¶ms, PASSWORD, SALT, &h).is_ok()); + let mut actual = [0u8; 32]; + Argon2d::derive_with_context(&canon_params(), canon_context(), PASSWORD, SALT, &mut actual).unwrap(); + assert_eq!(actual, expected_d); + Argon2i::derive_with_context(&canon_params(), canon_context(), PASSWORD, SALT, &mut actual).unwrap(); + assert_eq!(actual, expected_i); + Argon2id::derive_with_context(&canon_params(), canon_context(), PASSWORD, SALT, &mut actual).unwrap(); + assert_eq!(actual, expected_id); } #[test] #[cfg(not(miri))] - fn argon2id_verify_rejects_wrong_password() { + fn raw_verify_accepts_only_the_exact_inputs() { let params = canon_params(); - let h = Argon2id::hash_array::<32>(¶ms, PASSWORD, SALT).unwrap(); - assert!(Argon2id::verify(¶ms, b"wrong_password_wrong_wrong!!wrong", SALT, &h).is_err()); - } - - #[test] - #[cfg(not(miri))] - fn argon2id_verify_rejects_wrong_salt() { - let params = canon_params(); - let h = Argon2id::hash_array::<32>(¶ms, PASSWORD, SALT).unwrap(); - let wrong_salt = [0xffu8; 16]; - assert!(Argon2id::verify(¶ms, PASSWORD, &wrong_salt, &h).is_err()); - } - - #[cfg(all(feature = "phc-strings", feature = "getrandom"))] - #[test] - #[cfg(not(miri))] - fn hash_string_uses_random_salt_and_verifies() { - let params = Argon2Params::new() - .memory_cost_kib(32) - .time_cost(1) - .parallelism(1) - .output_len(32) - .build() - .unwrap(); + let mut expected = [0u8; 32]; + Argon2id::derive(¶ms, PASSWORD, SALT, &mut expected).unwrap(); - let encoded = Argon2id::hash_string(¶ms, b"password").unwrap(); - assert!(Argon2id::verify_string(b"password", &encoded).is_ok()); - assert!(Argon2id::verify_string(b"wrong-password", &encoded).is_err()); + assert!(Argon2id::verify(¶ms, PASSWORD, SALT, &expected).is_ok()); + assert!(Argon2id::verify(¶ms, b"wrong", SALT, &expected).is_err()); + assert!(Argon2id::verify(¶ms, PASSWORD, &[0xff; 16], &expected).is_err()); } - // ── Errors ───────────────────────────────────────────────────────────── - #[test] - fn invalid_params_fail_build() { - assert_eq!( - Argon2Params::new().time_cost(0).build().unwrap_err(), - Argon2Error::InvalidTimeCost - ); - assert_eq!( - Argon2Params::new().parallelism(0).build().unwrap_err(), - Argon2Error::InvalidParallelism - ); - assert_eq!( - Argon2Params::new() - .parallelism(4) - .memory_cost_kib(16) // < 8 * p - .build() - .unwrap_err(), - Argon2Error::InvalidMemoryCost - ); - assert_eq!( - Argon2Params::new().output_len(3).build().unwrap_err(), - Argon2Error::InvalidOutputLen - ); + fn params_are_valid_by_construction() { + assert_eq!(Argon2Params::new(8, 0, 1), Err(Argon2Error::InvalidTimeCost)); + assert_eq!(Argon2Params::new(8, 1, 0), Err(Argon2Error::InvalidParallelism)); + assert_eq!(Argon2Params::new(16, 1, 4), Err(Argon2Error::InvalidMemoryCost)); + assert!(Argon2Params::new(32, 1, 4).is_ok()); } #[test] - fn short_salt_rejected() { - let params = Argon2Params::new().memory_cost_kib(32).parallelism(4).build().unwrap(); + fn derive_rejects_invalid_operation_lengths() { + let params = Argon2Params::new(32, 1, 4).unwrap(); let mut out = [0u8; 32]; assert_eq!( - Argon2id::hash(¶ms, b"pw", &[0u8; 7], &mut out).unwrap_err(), - Argon2Error::SaltTooShort + Argon2id::derive(¶ms, b"pw", &[0u8; 7], &mut out), + Err(Argon2Error::SaltTooShort) ); - } - #[test] - fn output_len_mismatch_rejected() { - let params = Argon2Params::new().memory_cost_kib(32).parallelism(4).build().unwrap(); - let mut out = [0u8; 16]; // params.output_len is 32 + let mut short = [0u8; 3]; assert_eq!( - Argon2id::hash(¶ms, b"pw", &[0u8; 16], &mut out).unwrap_err(), - Argon2Error::InvalidOutputLen + Argon2id::derive(¶ms, b"pw", &[0u8; 16], &mut short), + Err(Argon2Error::InvalidOutputLen) ); } @@ -2563,317 +2302,232 @@ mod tests { assert_err::(); } - // ── Differential: rscrypto vs rustcrypto `argon2` oracle ────────────── + #[test] + fn context_debug_redacts_borrowed_inputs() { + let debug = alloc::format!("{:?}", Argon2Context::new(b"pepper-value", b"tenant-value")); + assert!(!debug.contains("pepper-value")); + assert!(!debug.contains("tenant-value")); + assert!(debug.contains("[REDACTED]")); + } #[cfg(not(miri))] fn oracle_hash( - algo: argon2::Algorithm, + algorithm: argon2::Algorithm, password: &[u8], salt: &[u8], - m_kib: u32, - t: u32, - p: u32, - out_len: usize, + memory_kib: u32, + time: u32, + parallelism: u32, + output_len: usize, ) -> vec::Vec { - let params = argon2::Params::new(m_kib, t, p, Some(out_len)).unwrap(); - let ctx = argon2::Argon2::new(algo, argon2::Version::V0x13, params); - let mut out = alloc::vec![0u8; out_len]; - ctx.hash_password_into(password, salt, &mut out).unwrap(); - out + let params = argon2::Params::new(memory_kib, time, parallelism, Some(output_len)).unwrap(); + let oracle = argon2::Argon2::new(algorithm, argon2::Version::V0x13, params); + let mut output = alloc::vec![0u8; output_len]; + oracle.hash_password_into(password, salt, &mut output).unwrap(); + output } #[test] #[cfg(not(miri))] - fn argon2d_matches_oracle_small_params() { - let cases: &[(u32, u32, u32, usize)] = &[(8, 1, 1, 16), (16, 2, 1, 32), (32, 3, 2, 32)]; - for &(m, t, p, out_len) in cases { - let params = Argon2Params::new() - .memory_cost_kib(m) - .time_cost(t) - .parallelism(p) - .output_len(out_len as u32) - .build() - .unwrap(); - let mut actual = alloc::vec![0u8; out_len]; - Argon2d::hash(¶ms, b"password", &[0u8; 16], &mut actual).unwrap(); - let expected = oracle_hash(argon2::Algorithm::Argon2d, b"password", &[0u8; 16], m, t, p, out_len); - assert_eq!(actual, expected, "argon2d mismatch m={m} t={t} p={p} T={out_len}"); - } - } - - #[test] - #[cfg(not(miri))] - fn argon2i_matches_oracle_small_params() { - let cases: &[(u32, u32, u32, usize)] = &[(8, 1, 1, 16), (16, 2, 1, 32), (32, 3, 2, 32)]; - for &(m, t, p, out_len) in cases { - let params = Argon2Params::new() - .memory_cost_kib(m) - .time_cost(t) - .parallelism(p) - .output_len(out_len as u32) - .build() - .unwrap(); - let mut actual = alloc::vec![0u8; out_len]; - Argon2i::hash(¶ms, b"password", &[0u8; 16], &mut actual).unwrap(); - let expected = oracle_hash(argon2::Algorithm::Argon2i, b"password", &[0u8; 16], m, t, p, out_len); - assert_eq!(actual, expected, "argon2i mismatch m={m} t={t} p={p} T={out_len}"); - } - } + fn all_variants_match_the_oracle() { + let cases: &[(u32, u32, u32, usize)] = &[(8, 1, 1, 16), (16, 2, 1, 32), (32, 3, 2, 64)]; + for &(memory, time, parallelism, output_len) in cases { + let params = Argon2Params::new(memory, time, parallelism).unwrap(); + let mut actual = alloc::vec![0u8; output_len]; - #[test] - #[cfg(not(miri))] - fn argon2id_matches_oracle_small_params() { - let cases: &[(u32, u32, u32, usize)] = &[(8, 1, 1, 16), (16, 2, 1, 32), (32, 3, 2, 32), (32, 3, 4, 64)]; - for &(m, t, p, out_len) in cases { - let params = Argon2Params::new() - .memory_cost_kib(m) - .time_cost(t) - .parallelism(p) - .output_len(out_len as u32) - .build() - .unwrap(); - let mut actual = alloc::vec![0u8; out_len]; - Argon2id::hash(¶ms, b"password", &[0u8; 16], &mut actual).unwrap(); - let expected = oracle_hash(argon2::Algorithm::Argon2id, b"password", &[0u8; 16], m, t, p, out_len); - assert_eq!(actual, expected, "argon2id mismatch m={m} t={t} p={p} T={out_len}"); - } - } + Argon2d::derive(¶ms, b"password", &[0u8; 16], &mut actual).unwrap(); + assert_eq!( + actual, + oracle_hash( + argon2::Algorithm::Argon2d, + b"password", + &[0u8; 16], + memory, + time, + parallelism, + output_len, + ) + ); - // ── Kernel dispatch plumbing ───────────────────────────────────────── + Argon2i::derive(¶ms, b"password", &[0u8; 16], &mut actual).unwrap(); + assert_eq!( + actual, + oracle_hash( + argon2::Algorithm::Argon2i, + b"password", + &[0u8; 16], + memory, + time, + parallelism, + output_len, + ) + ); - #[test] - fn kernel_id_stringifies() { - assert_eq!(KernelId::Portable.as_str(), "portable"); + Argon2id::derive(¶ms, b"password", &[0u8; 16], &mut actual).unwrap(); + assert_eq!( + actual, + oracle_hash( + argon2::Algorithm::Argon2id, + b"password", + &[0u8; 16], + memory, + time, + parallelism, + output_len, + ) + ); + } } #[test] - fn portable_kernel_has_no_required_caps() { + fn kernel_contract_includes_the_portable_fallback() { + assert!(ALL_KERNELS.contains(&KernelId::Portable)); assert!(required_caps(KernelId::Portable).is_empty()); + assert_eq!(KernelId::Portable.as_str(), "portable"); } - // ── PHC string integration ─────────────────────────────────────────── - #[cfg(all(feature = "phc-strings", not(miri)))] mod phc_tests { - use alloc::{format, string::String, vec}; + use alloc::format; use super::*; - use crate::auth::phc::PhcError; + use crate::auth::{PasswordStatus, phc::PhcError}; fn small_params() -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(32) - .time_cost(2) - .parallelism(1) - .output_len(32) - .build() - .unwrap() + Argon2Params::new(32, 2, 1).unwrap() } - #[test] - fn hash_string_with_salt_round_trip_id() { - let params = small_params(); - let salt = [0xAAu8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); - assert!(encoded.starts_with("$argon2id$v=19$m=32,t=2,p=1$")); - assert!(Argon2id::verify_string(b"password", &encoded).is_ok()); - assert!(Argon2id::verify_string_unbounded(b"password", &encoded).is_ok()); - assert!(Argon2id::verify_string(b"wrongpassword", &encoded).is_err()); + fn encode(params: Argon2Params, password: &[u8], salt: &[u8], context: Argon2Context<'_>) -> alloc::string::String { + let mut verifier = [0u8; PASSWORD_OUTPUT_LEN]; + Argon2id::derive_with_context(¶ms, context, password, salt, &mut verifier).unwrap(); + password_phc::encode(params, salt, &verifier) } #[test] - fn verify_string_default_policy_rejects_oversized_argon2_costs() { + fn canonical_password_record_round_trips() { let params = small_params(); - let salt = [0xA0u8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); - let too_much_memory = Argon2VerifyPolicy::default().max_memory_cost_kib.strict_add(1); - let expensive = encoded.replace("m=32", &format!("m={too_much_memory}")); - - assert!(Argon2id::decode_string(&expensive).is_ok()); - assert!(Argon2id::verify_string(b"password", &expensive).is_err()); - assert!(Argon2id::verify_string_with_context(b"password", &expensive, &[], &[]).is_err()); - } - - #[test] - fn verify_string_with_policy_enforces_argon2_bounds() { - let params = small_params(); - let salt = [0xA1u8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); - - let allowed = Argon2VerifyPolicy::new(32, 2, 1, 32); - assert!(Argon2id::verify_string_with_policy(b"password", &encoded, &allowed).is_ok()); - - let low_memory = Argon2VerifyPolicy::new(31, 2, 1, 32); - assert!(Argon2id::verify_string_with_policy(b"password", &encoded, &low_memory).is_err()); + let password = Argon2idPassword::new(params).unwrap(); + let encoded = encode(params, b"password", &[0xaa; 16], Argon2Context::default()); - let short_output = Argon2VerifyPolicy::new(32, 2, 1, 31); - assert!(Argon2id::verify_string_with_policy(b"password", &encoded, &short_output).is_err()); + assert!(encoded.starts_with("$argon2id$v=19$m=32,t=2,p=1$")); + assert_eq!( + password.verify_password(b"password", &encoded), + Ok(PasswordStatus::Current) + ); + assert!(password.verify_password(b"wrong", &encoded).is_err()); } #[test] - fn verify_string_with_context_and_policy_enforces_argon2_bounds() { - let params = small_params().secret(b"pepper").build().unwrap(); - let salt = [0xA2u8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); + fn accepted_older_profile_requests_rehash() { + let generation = Argon2Params::new(40, 2, 1).unwrap(); + let password = Argon2idPassword::new(generation).unwrap(); + let encoded = encode(small_params(), b"password", &[0xbb; 16], Argon2Context::default()); - let allowed = Argon2VerifyPolicy::new(32, 2, 1, 32); - assert!(Argon2id::verify_string_with_context_and_policy(b"password", &encoded, b"pepper", &[], &allowed).is_ok()); - - let low_time = Argon2VerifyPolicy::new(32, 1, 1, 32); - assert!( - Argon2id::verify_string_with_context_and_policy(b"password", &encoded, b"pepper", &[], &low_time).is_err() + assert_eq!( + password.verify_password(b"password", &encoded), + Ok(PasswordStatus::NeedsRehash) ); } #[test] - fn hash_string_round_trip_d_and_i() { + fn accepted_noncurrent_salt_length_requests_rehash() { let params = small_params(); - let salt = [0xBBu8; 16]; - let encoded_d = Argon2d::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - let encoded_i = Argon2i::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - assert!(encoded_d.starts_with("$argon2d$")); - assert!(encoded_i.starts_with("$argon2i$")); - assert!(Argon2d::verify_string(b"pw", &encoded_d).is_ok()); - assert!(Argon2i::verify_string(b"pw", &encoded_i).is_ok()); - } + let password = Argon2idPassword::new(params).unwrap(); + let encoded = encode(params, b"password", &[0xbb; 8], Argon2Context::default()); - #[test] - fn verify_string_rejects_variant_mismatch() { - let params = small_params(); - let salt = [0xCCu8; 16]; - let encoded_d = Argon2d::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - // Cross-feed: Argon2id expects its own algorithm label. - assert!(Argon2id::verify_string(b"pw", &encoded_d).is_err()); - assert!(Argon2i::verify_string(b"pw", &encoded_d).is_err()); + assert_eq!( + password.verify_password(b"password", &encoded), + Ok(PasswordStatus::NeedsRehash) + ); } #[test] - fn decode_string_extracts_params_salt_hash() { + fn borrowed_context_is_required_for_context_bound_records() { let params = small_params(); - let salt = vec![0xDDu8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - - let (decoded_params, decoded_salt, decoded_hash) = Argon2id::decode_string(&encoded).unwrap(); - assert_eq!(decoded_params.get_memory_cost_kib(), 32); - assert_eq!(decoded_params.get_time_cost(), 2); - assert_eq!(decoded_params.get_parallelism(), 1); - assert_eq!(decoded_params.get_output_len(), 32); - assert_eq!(decoded_params.get_version(), Argon2Version::V0x13); - assert_eq!(decoded_salt, salt); - assert_eq!(decoded_hash.len(), 32); - - // Re-hashing with decoded params should give the same hash. - let mut rehashed = [0u8; 32]; - Argon2id::hash(&decoded_params, b"pw", &decoded_salt, &mut rehashed).unwrap(); - assert_eq!(rehashed.as_slice(), decoded_hash.as_slice()); - } + let password = Argon2idPassword::new(params).unwrap(); + let context = Argon2Context::new(b"pepper", b"tenant"); + let encoded = encode(params, b"password", &[0xcc; 16], context); - #[test] - fn decode_string_rejects_malformed() { + assert!(password.verify_password(b"password", &encoded).is_err()); assert_eq!( - Argon2id::decode_string("not a phc string").unwrap_err(), - PhcError::MalformedInput + password.verify_password_with_context(b"password", &encoded, context), + Ok(PasswordStatus::Current) ); - // Short salt (2 bytes of "aa") should fail InvalidLength (< MIN_SALT_LEN = 8). + assert!( + password + .verify_password_with_context(b"password", &encoded, Argon2Context::new(b"wrong", b"tenant"),) + .is_err() + ); + } + + #[test] + fn approval_rejects_resource_requests_before_base64_decode() { + let limits = Argon2VerificationLimits::for_profile(small_params()); + let expensive = "$argon2id$v=19$m=40,t=2,p=1$*$*"; assert_eq!( - Argon2id::decode_string("$argon2id$m=32,t=2,p=1$YWE$aGFzaA").unwrap_err(), - PhcError::InvalidLength + password_phc::approve(expensive, limits).err(), + Some(PhcError::ParamOutOfRange) ); - // Hash segment with 2 bytes ("aa" → base64 "YWE") is below MIN_OUTPUT_LEN. + + let admitted = "$argon2id$v=19$m=32,t=2,p=1$*$*"; assert_eq!( - Argon2id::decode_string("$argon2id$m=32,t=2,p=1$QUFBQUFBQUFBQUFBQUFBQQ$YWE").unwrap_err(), - PhcError::InvalidLength + password_phc::approve(admitted, limits).err(), + Some(PhcError::InvalidLength) ); } #[test] - fn verify_string_rejects_tampered_hash() { - let params = small_params(); - let salt = [0xEEu8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - // Flip a byte in the hash segment (last character). - let mut tampered: String = encoded.chars().collect(); - let len = tampered.len(); - tampered.replace_range(len - 1..len, "A"); // the original value was "A" or not — either way the test verifies rejection on corruption - // The tampering may coincidentally match; try multiple flips. - let mut matched_tamper = encoded.clone(); - let variants = ["A", "B", "C"]; - let last_char = encoded.chars().last().unwrap(); - for v in variants { - if v.chars().next().unwrap() != last_char { - let mut t = encoded.clone(); - let len = t.len(); - t.replace_range(len - 1..len, v); - matched_tamper = t; - break; - } - } - assert!(Argon2id::verify_string(b"pw", &matched_tamper).is_err()); + fn actual_argon2_shape_defines_the_limit() { + let limits = Argon2VerificationLimits::for_profile(small_params()); + let rounded_equivalent = Argon2Params::new(35, 2, 1).unwrap(); + assert!(limits.allows(rounded_equivalent)); + let next_matrix = Argon2Params::new(36, 2, 1).unwrap(); + assert!(!limits.allows(next_matrix)); } #[test] - fn decode_string_rejects_duplicate_params() { - // Encode a hash and munge the params segment to include a duplicate `m=`. - let params = small_params(); - let encoded = Argon2id::hash_string_with_salt(¶ms, b"pw", &[0xFFu8; 16]).unwrap(); - let broken = encoded.replace("t=2", "m=99"); - assert_eq!(Argon2id::decode_string(&broken).unwrap_err(), PhcError::DuplicateParam); - } + fn generator_and_parser_share_the_full_argon2_parallelism_domain() { + let params = Argon2Params::new(2_048, 1, 256).unwrap(); + let encoded = password_phc::encode(params, &[0x44; 16], &[0u8; PASSWORD_OUTPUT_LEN]); + let limits = Argon2VerificationLimits::for_profile(params); - #[test] - fn decode_string_accepts_missing_version() { - // Omitting the `$v=19` segment is permissive and defaults to V0x13. - let params = small_params(); - let salt = [0x77u8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - let no_v = encoded.replace("$v=19", ""); - let (p, s, h) = Argon2id::decode_string(&no_v).unwrap(); - assert_eq!(p.get_version(), Argon2Version::V0x13); - assert_eq!(s, salt); - assert_eq!(h.len(), 32); + assert!(password_phc::approve(&encoded, limits).is_ok()); } #[test] - fn verify_string_with_context_handles_secret_and_associated_data() { - let params = small_params() - .secret(b"pepper") - .associated_data(b"context") - .build() - .unwrap(); - let salt = [0x99u8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - - assert!(Argon2id::verify_string(b"pw", &encoded).is_err()); - assert!(Argon2id::verify_string_with_context(b"pw", &encoded, b"pepper", b"context").is_ok()); - assert!(Argon2id::verify_string_with_context_unbounded(b"pw", &encoded, b"pepper", b"context").is_ok()); - assert!(Argon2id::verify_string_with_context(b"pw", &encoded, b"wrong", b"context").is_err()); - assert!(Argon2id::verify_string_with_context(b"pw", &encoded, b"pepper", b"wrong").is_err()); + fn parser_accepts_only_the_canonical_argon2id_protocol() { + let limits = Argon2VerificationLimits::for_profile(small_params()); + let salt = "AAAAAAAAAAAAAAAAAAAAAA"; + let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let cases = [ + format!("$argon2i$v=19$m=32,t=2,p=1$${salt}$${hash}"), + format!("$argon2id$m=32,t=2,p=1$${salt}$${hash}"), + format!("$argon2id$v=16$m=32,t=2,p=1$${salt}$${hash}"), + format!("$argon2id$v=19$t=2,m=32,p=1$${salt}$${hash}"), + format!("$argon2id$v=19$m=32,m=32,p=1$${salt}$${hash}"), + format!("$argon2id$v=19$m=32,t=2,x=1$${salt}$${hash}"), + ]; + for encoded in cases { + assert!(password_phc::approve(&encoded, limits).is_err(), "{encoded}"); + } } + #[cfg(feature = "getrandom")] #[test] - fn encoded_format_exact_for_known_vector() { - // Verify exact string shape with fixed params + salt. - let params = Argon2Params::new() - .memory_cost_kib(8) - .time_cost(1) - .parallelism(1) - .output_len(16) - .version(Argon2Version::V0x13) - .build() - .unwrap(); - let salt = b"exampleSALTvalue"; // 16 bytes - let encoded = Argon2id::hash_string_with_salt(¶ms, b"password", salt).unwrap(); - // Shape check: exactly 5 segments after leading $. - let segments: alloc::vec::Vec<&str> = encoded.split('$').collect(); - assert_eq!(segments[0], ""); - assert_eq!(segments[1], "argon2id"); - assert_eq!(segments[2], "v=19"); - assert_eq!(segments[3], "m=8,t=1,p=1"); - // segments[4] is base64 salt (16 bytes → 22 chars) - assert_eq!(segments[4].len(), 22); - // segments[5] is base64 hash (16 bytes → 22 chars) - assert_eq!(segments[5].len(), 22); - assert_eq!(segments.len(), 6); + fn generated_records_use_fresh_salts() { + let password = Argon2idPassword::new(small_params()).unwrap(); + let first = password.hash_password(b"password").unwrap(); + let second = password.hash_password(b"password").unwrap(); + + assert_ne!(first, second); + assert_eq!( + password.verify_password(b"password", &first), + Ok(PasswordStatus::Current) + ); + assert_eq!( + password.verify_password(b"password", &second), + Ok(PasswordStatus::Current) + ); } } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index b8a873f8..12c34038 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -107,7 +107,6 @@ //! - `hkdf` - HKDF extract-then-expand key derivation. //! - `kmac` - KMAC128/KMAC256 variable-output MACs. //! - `mlkem` - ML-KEM typed key, ciphertext, and shared-secret foundations. -//! - `phc` - PHC string-format codec shared by password hashers. //! - `poly1305` - Standalone Poly1305 one-time authenticator. //! - `rsa` - RSA key import/export/generation, signing, verification, OAEP, and legacy //! RSAES-PKCS1-v1_5. @@ -135,7 +134,7 @@ pub mod mlkem; #[cfg(feature = "pbkdf2")] pub mod pbkdf2; #[cfg(feature = "phc-strings")] -pub mod phc; +pub(crate) mod phc; #[cfg(feature = "poly1305")] pub mod poly1305; #[cfg(feature = "rsa")] @@ -145,8 +144,29 @@ pub mod scrypt; #[cfg(feature = "x25519")] pub mod x25519; +#[cfg(all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt")))] +/// Result of a successful password verification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PasswordStatus { + /// The stored cost profile and salt length match the generation profile. + Current, + /// The password is correct, but the stored profile should be regenerated. + NeedsRehash, +} + +#[cfg(all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt")))] +impl PasswordStatus { + /// Whether the verified password should be hashed again with the current profile. + #[must_use] + pub const fn needs_rehash(self) -> bool { + matches!(self, Self::NeedsRehash) + } +} + #[cfg(feature = "argon2")] -pub use argon2::{Argon2Error, Argon2Params, Argon2VerifyPolicy, Argon2Version, Argon2d, Argon2i, Argon2id}; +pub use argon2::{Argon2Context, Argon2Error, Argon2Params, Argon2d, Argon2i, Argon2id}; +#[cfg(all(feature = "argon2", feature = "phc-strings"))] +pub use argon2::{Argon2VerificationLimits, Argon2idPassword}; #[cfg(all(feature = "diag", feature = "ed25519"))] pub use curve25519_edwards::diag_ed25519_select_basepoint_cached_limb_digest; #[cfg(all(feature = "diag", feature = "ed25519", target_arch = "x86_64"))] @@ -253,8 +273,6 @@ pub use mlkem::{ pub use pbkdf2::{Pbkdf2Error, Pbkdf2Params, Pbkdf2Sha256, Pbkdf2Sha512, Pbkdf2VerifyPolicy}; #[cfg(all(feature = "diag", feature = "pbkdf2"))] pub use pbkdf2::{diag_pbkdf2_sha256_verify_portable, diag_pbkdf2_sha512_verify_portable}; -#[cfg(feature = "phc-strings")] -pub use phc::PhcError; #[cfg(feature = "poly1305")] pub use poly1305::{Poly1305, Poly1305OneTimeKey, Poly1305Tag}; #[cfg(feature = "rsa")] @@ -272,7 +290,9 @@ pub use rsa::{ diag_rsa_validate_pkcs8_private_key_der_stage, }; #[cfg(feature = "scrypt")] -pub use scrypt::{Scrypt, ScryptError, ScryptParams, ScryptVerifyPolicy}; +pub use scrypt::{Scrypt, ScryptError, ScryptParams}; +#[cfg(all(feature = "scrypt", feature = "phc-strings"))] +pub use scrypt::{ScryptPassword, ScryptVerificationLimits}; #[cfg(feature = "x25519")] pub use x25519::{X25519Error, X25519PublicKey, X25519SecretKey, X25519SharedSecret}; diff --git a/src/auth/phc.rs b/src/auth/phc.rs index 90fea342..9bbf5385 100644 --- a/src/auth/phc.rs +++ b/src/auth/phc.rs @@ -6,15 +6,13 @@ //! out-of-range parameter values, and trailing bytes in base64 are all //! rejected. //! -//! This module is used internally by `crate::auth::argon2` and -//! `crate::auth::scrypt` for their `hash_string` / `verify_string` -//! helpers. The only public surface is [`PhcError`], which surfaces parse -//! failures when callers use the typed `decode_*` helpers on the hashers. +//! This module is crate-private. Algorithm modules own format-specific +//! validation and expose only bounded password operations. //! //! [phc]: https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md #![allow(clippy::indexing_slicing)] -// `decode_base64_to_vec` and `push_u32_decimal` are only reachable when a +// Base64 and decimal helpers are only reachable when a // PHC-aware hasher (argon2 or scrypt) is enabled. Without either, the // helpers are dead code — silence the warning rather than cfg-gate every // symbol individually. @@ -23,7 +21,8 @@ allow(dead_code) )] -use alloc::{string::String, vec, vec::Vec}; +#[cfg(any(feature = "getrandom", test))] +use alloc::string::String; use core::fmt; // ─── Base64 (standard alphabet, no padding) ───────────────────────────────── @@ -46,6 +45,7 @@ const B64_DECODE_TABLE: [u8; 256] = { /// /// Appends to `out` — callers managing multi-segment PHC strings reuse the /// same `String` buffer without intermediate allocation. +#[cfg(any(feature = "getrandom", test))] pub(crate) fn base64_encode_into(bytes: &[u8], out: &mut String) { let full_triples = bytes.len() / 3; let tail = bytes.len() % 3; @@ -89,11 +89,11 @@ pub(crate) const fn base64_decoded_len(encoded_len: usize) -> usize { // Each 4 chars → 3 bytes; each remaining 2 chars → 1 byte, 3 chars → 2 bytes. let full = encoded_len / 4; let tail = encoded_len % 4; - let bytes = full.wrapping_mul(3); + let bytes = full.strict_mul(3); match tail { 0 => bytes, - 2 => bytes.wrapping_add(1), - 3 => bytes.wrapping_add(2), + 2 => bytes.strict_add(1), + 3 => bytes.strict_add(2), _ => bytes, // tail == 1 is invalid but we don't fail from const } } @@ -175,23 +175,12 @@ pub(crate) fn base64_decode_into(s: &str, out: &mut [u8]) -> Result`. -/// -/// Shared between the Argon2 and scrypt PHC integrations — both call this -/// from `decode_string` to materialise the salt and hash bytes. -pub(crate) fn decode_base64_to_vec(encoded: &str) -> Result, PhcError> { - let cap = base64_decoded_len(encoded.len()); - let mut buf = vec![0u8; cap]; - let n = base64_decode_into(encoded, &mut buf)?; - buf.truncate(n); - Ok(buf) -} - /// Append `n` as base-10 decimal (no leading zero) to `out`. /// /// Shared decimal writer used by Argon2 and scrypt PHC encoders for cost /// parameters. Produces exactly the canonical form `PhcParamIter` + /// `parse_param_u32` accept on round-trip. +#[cfg(any(feature = "getrandom", test))] pub(crate) fn push_u32_decimal(out: &mut String, n: u32) { if n == 0 { out.push('0'); @@ -382,15 +371,9 @@ pub(crate) fn parse(encoded: &str) -> Result, PhcError> { // ─── Error type ───────────────────────────────────────────────────────────── -/// Parse or decode error for PHC-format strings. -/// -/// Surfaced by the explicit `decode_*` helpers on Argon2/scrypt hashers. -/// The `verify_string` flow collapses these into -/// [`crate::VerificationError`] to avoid leaking whether a failure was a -/// parse error vs. a wrong password. +/// Internal parse or decode error for PHC-format strings. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum PhcError { +pub(crate) enum PhcError { /// Input is longer than the implementation accepts. InputTooLong, /// Missing leading `$`, missing segment, or extra segments. diff --git a/src/auth/scrypt.rs b/src/auth/scrypt.rs index 416bc926..9cbc321f 100644 --- a/src/auth/scrypt.rs +++ b/src/auth/scrypt.rs @@ -3,8 +3,7 @@ //! Memory-hard key derivation combining PBKDF2-HMAC-SHA256 with a //! Salsa20/8 core, via the BlockMix / ROMix construction of //! Percival & Josefsson (RFC 7914 §3–§5). Parameters are driven by -//! [`ScryptParams`], whose defaults track OWASP 2024 (`log_n = 17`, -//! `r = 8`, `p = 1`, `output = 32 bytes`). +//! [`ScryptParams`], whose defaults use `log_n = 17`, `r = 8`, and `p = 1`. //! //! The implementation reuses [`crate::Pbkdf2Sha256`] for the setup / //! finalisation legs and is portable Rust throughout. @@ -14,19 +13,13 @@ //! ```rust //! use rscrypto::{Scrypt, ScryptParams}; //! -//! let params = ScryptParams::new() -//! .log_n(10) -//! .r(8) -//! .p(1) -//! .output_len(32) -//! .build() -//! .expect("valid params"); +//! let params = ScryptParams::new(10, 8, 1).expect("valid params"); //! //! let password = b"correct horse battery staple"; //! let salt = b"random-salt-1234"; //! //! let mut hash = [0u8; 32]; -//! Scrypt::hash(¶ms, password, salt, &mut hash).expect("hash"); +//! Scrypt::derive(¶ms, password, salt, &mut hash).expect("derive"); //! //! assert!(Scrypt::verify(¶ms, password, salt, &hash).is_ok()); //! assert!(Scrypt::verify(¶ms, b"wrong", salt, &hash).is_err()); @@ -34,10 +27,10 @@ //! //! # Security //! -//! - [`Scrypt::MIN_SALT_LEN`] documents the 16-byte OWASP minimum. The algorithmic API accepts any -//! salt length; policy enforcement is the caller's responsibility. -//! - [`ScryptParams::validate`] enforces RFC 7914 bounds (`log_n` in `1..=63`, `r ≥ 1`, `p ≥ 1`, `r -//! · p ≤ 2^30 − 1`, `output_len ≥ 1`). +//! - [`Scrypt::MIN_SALT_LEN`] documents the 16-byte production recommendation. The algorithmic API +//! accepts any salt length; policy enforcement is the caller's responsibility. +//! - [`ScryptParams::new`] rejects invalid cost profiles; output length is taken from the caller's +//! destination slice. //! - Allocation failure surfaces as [`ScryptError::AllocationFailed`] rather than a panic. //! - Working buffers (B, V, scratch) are zeroised on drop. //! - [`Scrypt::verify`] is constant-time with respect to the reference tag bytes. @@ -74,16 +67,17 @@ pub const BLOCK_SIZE: usize = 64; /// Salsa20/8 block size in 32-bit words. const BLOCK_WORDS: usize = BLOCK_SIZE / 4; // 16 -/// OWASP 2024 defaults: `log_n = 17`, `r = 8`, `p = 1`, `output_len = 32`. +/// OWASP baseline cost profile: `log_n = 17`, `r = 8`, `p = 1`. const DEFAULT_LOG_N: u8 = 17; const DEFAULT_R: u32 = 8; const DEFAULT_P: u32 = 1; -const DEFAULT_OUTPUT_LEN: u32 = 32; +#[cfg(feature = "phc-strings")] +const DEFAULT_OUTPUT_LEN: usize = 32; /// Minimum salt length (bytes) recommended for production deployments. /// /// The scrypt algorithm accepts arbitrary salt lengths; this is a policy -/// constant exposed for callers and not enforced at `hash`-time (RFC 7914 +/// constant exposed for callers and not enforced at derivation time (RFC 7914 /// §11 test vectors use empty / short salts). pub const MIN_SALT_LEN: usize = 16; @@ -97,7 +91,7 @@ const MAX_R_TIMES_P: u64 = (1u64 << 30) - 1; /// Invalid scrypt parameter, input length, or resource constraint. /// -/// Surfaced at `build` or `hash` time — never at `verify` time (a parameter +/// Surfaced at construction or derivation time — never at `verify` time (a parameter /// error during verification would leak information about the stored hash, /// so verify collapses these into [`crate::VerificationError`]). /// @@ -107,8 +101,8 @@ const MAX_R_TIMES_P: u64 = (1u64 << 30) - 1; /// use rscrypto::{ScryptParams, auth::scrypt::ScryptError}; /// /// assert_eq!( -/// ScryptParams::new().log_n(0).build().unwrap_err(), -/// ScryptError::InvalidLogN, +/// ScryptParams::new(0, 8, 1).unwrap_err(), +/// ScryptError::InvalidLogN /// ); /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -128,7 +122,11 @@ pub enum ScryptError { /// The allocator refused to provide the working-set buffers. AllocationFailed, /// The platform entropy source failed while generating a PHC salt. + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] EntropyUnavailable, + /// Password generation parameters exceed the verifier's resource limits. + #[cfg(feature = "phc-strings")] + VerificationLimitTooLow, } impl fmt::Display for ScryptError { @@ -140,7 +138,10 @@ impl fmt::Display for ScryptError { Self::InvalidOutputLen => "scrypt output length must be at least 1", Self::ResourceOverflow => "scrypt parameters exceed the target's address space", Self::AllocationFailed => "scrypt working-set allocation failed", + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] Self::EntropyUnavailable => "scrypt entropy source unavailable", + #[cfg(feature = "phc-strings")] + Self::VerificationLimitTooLow => "scrypt verification limits do not admit the generation parameters", }) } } @@ -149,141 +150,53 @@ impl core::error::Error for ScryptError {} // ─── Parameters ───────────────────────────────────────────────────────────── -/// Validated scrypt cost-parameter set. -/// -/// Constructed via [`ScryptParams::new`] and the `log_n` / `r` / `p` / -/// `output_len` setters; call [`ScryptParams::build`] to validate and -/// produce a `Result`. Every field is -/// stack-allocated — `ScryptParams` is `Copy` and cheap to clone. +/// Validated scrypt cost parameters. /// /// # Examples /// /// ```rust /// use rscrypto::{Scrypt, ScryptParams}; /// -/// let params = ScryptParams::new() -/// .log_n(10) -/// .r(8) -/// .p(1) -/// .output_len(32) -/// .build() -/// .expect("valid params"); +/// let params = ScryptParams::new(10, 8, 1)?; /// /// let mut out = [0u8; 32]; -/// Scrypt::hash(¶ms, b"password", b"salty-salty-salt", &mut out).unwrap(); +/// Scrypt::derive(¶ms, b"password", b"salty-salty-salt", &mut out)?; +/// # Ok::<(), rscrypto::ScryptError>(()) /// ``` -#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ScryptParams { log_n: u8, r: u32, p: u32, - output_len: u32, -} - -impl fmt::Debug for ScryptParams { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ScryptParams") - .field("log_n", &self.log_n) - .field("r", &self.r) - .field("p", &self.p) - .field("output_len", &self.output_len) - .finish() - } } impl Default for ScryptParams { fn default() -> Self { - Self::new() - } -} - -impl ScryptParams { - /// Create a new parameter builder pre-populated with OWASP 2024 defaults - /// (`log_n = 17`, `r = 8`, `p = 1`, `output = 32`). Override via setters, - /// then call [`ScryptParams::build`] to validate. - #[must_use] - pub const fn new() -> Self { Self { log_n: DEFAULT_LOG_N, r: DEFAULT_R, p: DEFAULT_P, - output_len: DEFAULT_OUTPUT_LEN, - } - } - - /// Set `log_n` (the base-2 log of the CPU/memory cost `N`). Must be - /// in `1..=63`. - #[must_use] - pub const fn log_n(mut self, lg_n: u8) -> Self { - self.log_n = lg_n; - self - } - - /// Set the block-size parameter `r`. Must be `≥ 1`. - #[must_use] - pub const fn r(mut self, r: u32) -> Self { - self.r = r; - self - } - - /// Set the parallelisation parameter `p`. Must satisfy - /// `1 ≤ p` and `r · p ≤ 2^30 − 1`. - #[must_use] - pub const fn p(mut self, p: u32) -> Self { - self.p = p; - self - } - - /// Set the derived-key length in bytes. Must be `≥ 1`. - /// - /// Capped at `2^32 - 1` bytes by the `u32` field type. RFC 7914 §2 permits - /// up to `(2^32 - 1) × 32 ≈ 137 GiB`; in practice deployments never need - /// more than a few hundred bytes, so the `u32` ceiling is a deliberate - /// design choice rather than a spec limitation. - #[must_use] - pub const fn output_len(mut self, t: u32) -> Self { - self.output_len = t; - self - } - - /// Validate every field against RFC 7914 bounds and return the finalised - /// parameter set. - /// - /// # Errors - /// - /// Returns [`ScryptError`] if any parameter is out of range. - pub const fn build(self) -> Result { - match self.validate() { - Ok(()) => Ok(self), - Err(e) => Err(e), } } +} - /// Run validation without consuming the builder — returns the first error. - /// - /// # Errors - /// - /// Returns [`ScryptError`] on the first invalid field. - pub const fn validate(&self) -> Result<(), ScryptError> { - if self.log_n < 1 || self.log_n > 63 { +impl ScryptParams { + /// Construct an RFC 7914 parameter profile. + pub const fn new(log_n: u8, r: u32, p: u32) -> Result { + if log_n < 1 || log_n > 63 { return Err(ScryptError::InvalidLogN); } - if self.r < 1 { + if r < 1 { return Err(ScryptError::InvalidR); } - if self.p < 1 { + if p < 1 { return Err(ScryptError::InvalidP); } - // `r` and `p` are both `u32`, so `r * p` always fits in `u64` - // (max `(2^32-1)^2 ≈ 2^64 - 2^33 + 1`). No overflow check needed. - let rp = (self.r as u64) * (self.p as u64); + let rp = (r as u64).strict_mul(p as u64); if rp > MAX_R_TIMES_P { return Err(ScryptError::InvalidP); } - if (self.output_len as usize) < MIN_OUTPUT_LEN { - return Err(ScryptError::InvalidOutputLen); - } - Ok(()) + Ok(Self { log_n, r, p }) } /// `log_n` (base-2 log of the CPU/memory cost). @@ -303,57 +216,42 @@ impl ScryptParams { pub const fn get_p(&self) -> u32 { self.p } - - /// Derived-key length in bytes. - #[must_use] - pub const fn get_output_len(&self) -> u32 { - self.output_len - } } -/// Operational limits for verifying scrypt PHC strings from untrusted storage. -/// -/// PHC strings encode their own CPU/memory parameters and output length. -/// `Scrypt::verify_string` applies the default policy. Use -/// `Scrypt::verify_string_with_policy` when a service needs tighter or looser -/// deployment ceilings. +/// Finite memory and work ceilings for scrypt password verification. +#[cfg(feature = "phc-strings")] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ScryptVerifyPolicy { - /// Maximum encoded `log_n` value. - pub max_log_n: u8, - /// Maximum encoded block-size parameter `r`. - pub max_r: u32, - /// Maximum encoded parallelisation parameter `p`. - pub max_p: u32, - /// Maximum encoded output length in bytes. - pub max_output_len: usize, +pub struct ScryptVerificationLimits { + max_memory_bytes: u128, + max_work: u128, } -impl ScryptVerifyPolicy { - /// Build a policy from explicit upper bounds. +#[cfg(feature = "phc-strings")] +impl ScryptVerificationLimits { + /// Derive ceilings from the largest deployment profile the verifier admits. #[must_use] - pub const fn new(max_log_n: u8, max_r: u32, max_p: u32, max_output_len: usize) -> Self { + pub const fn for_profile(params: ScryptParams) -> Self { + let n = 1u128 << params.log_n; + let r = params.r as u128; + let p = params.p as u128; Self { - max_log_n, - max_r, - max_p, - max_output_len, + max_memory_bytes: 128u128 + .strict_mul(r) + .strict_mul(n.strict_add(p.strict_mul(2)).strict_add(1)), + max_work: n.strict_mul(r).strict_mul(p), } } - /// Return `true` when `params` and `output_len` are within this policy. - #[must_use] - pub const fn allows(&self, params: &ScryptParams, output_len: usize) -> bool { - params.log_n <= self.max_log_n - && params.r <= self.max_r - && params.p <= self.max_p - && output_len <= self.max_output_len + const fn allows(&self, params: ScryptParams) -> bool { + let usage = Self::for_profile(params); + usage.max_memory_bytes <= self.max_memory_bytes && usage.max_work <= self.max_work } } -impl Default for ScryptVerifyPolicy { +#[cfg(feature = "phc-strings")] +impl Default for ScryptVerificationLimits { fn default() -> Self { - Self::new(DEFAULT_LOG_N, DEFAULT_R, DEFAULT_P, DEFAULT_OUTPUT_LEN as usize) + Self::for_profile(ScryptParams::default()) } } @@ -877,13 +775,18 @@ fn alloc_block_vec(len: usize) -> Result, ScryptError> { // ─── Full scrypt ──────────────────────────────────────────────────────────── -#[inline] -fn scrypt_shape(params: &ScryptParams, out: &[u8]) -> Result<(usize, usize, usize, usize, usize, usize), ScryptError> { - params.validate()?; - if out.len() != params.output_len as usize { - return Err(ScryptError::InvalidOutputLen); - } +#[derive(Clone, Copy)] +struct ScryptShape { + n: usize, + r: usize, + p: usize, + two_r: usize, + total_b_blocks: usize, + v_blocks: usize, +} +#[inline] +fn scrypt_shape(params: &ScryptParams) -> Result { let log_n = params.log_n as u32; // Guard against the 32-bit target where `1 << log_n` would wrap. if log_n >= (usize::BITS) { @@ -897,7 +800,28 @@ fn scrypt_shape(params: &ScryptParams, out: &[u8]) -> Result<(usize, usize, usiz let total_b_blocks = p.checked_mul(two_r).ok_or(ScryptError::ResourceOverflow)?; let v_blocks = n.checked_mul(two_r).ok_or(ScryptError::ResourceOverflow)?; - Ok((n, r, p, two_r, total_b_blocks, v_blocks)) + let b_bytes = total_b_blocks + .checked_mul(BLOCK_SIZE) + .ok_or(ScryptError::ResourceOverflow)?; + let v_bytes = v_blocks.checked_mul(BLOCK_SIZE).ok_or(ScryptError::ResourceOverflow)?; + let scratch_bytes = two_r.checked_mul(BLOCK_SIZE).ok_or(ScryptError::ResourceOverflow)?; + let portable_bytes = b_bytes + .checked_mul(2) + .and_then(|bytes| bytes.checked_add(v_bytes)) + .and_then(|bytes| bytes.checked_add(scratch_bytes)) + .ok_or(ScryptError::ResourceOverflow)?; + if portable_bytes > isize::MAX as usize { + return Err(ScryptError::ResourceOverflow); + } + + Ok(ScryptShape { + n, + r, + p, + two_r, + total_b_blocks, + v_blocks, + }) } fn scrypt_hash_portable( @@ -906,8 +830,11 @@ fn scrypt_hash_portable( salt: &[u8], out: &mut [u8], ) -> Result<(), ScryptError> { - let (n, r, p, two_r, total_b_blocks, v_blocks) = scrypt_shape(params, out)?; - let mut state = ScryptState::new(total_b_blocks, v_blocks, two_r)?; + if out.len() < MIN_OUTPUT_LEN { + return Err(ScryptError::InvalidOutputLen); + } + let shape = scrypt_shape(params)?; + let mut state = ScryptState::new(shape.total_b_blocks, shape.v_blocks, shape.two_r)?; // Pre-compute the HMAC prefix state from `password` once; both PBKDF2 // legs use the same key and `Pbkdf2Sha256::new` hashes the password @@ -929,11 +856,11 @@ fn scrypt_hash_portable( } // Step 2: for each p-chunk, apply ROMix. - for chunk_idx in 0..p { - let chunk_start = chunk_idx.strict_mul(two_r); - let chunk_end = chunk_start.strict_add(two_r); + for chunk_idx in 0..shape.p { + let chunk_start = chunk_idx.strict_mul(shape.two_r); + let chunk_end = chunk_start.strict_add(shape.two_r); let chunk = &mut state.b_u32[chunk_start..chunk_end]; - ro_mix(chunk, &mut state.v, &mut state.scratch, n, r); + ro_mix(chunk, &mut state.v, &mut state.scratch, shape.n, shape.r); } // Re-serialise the mixed B back into the byte buffer for the final @@ -961,10 +888,13 @@ fn scrypt_hash_x86_sse2( salt: &[u8], out: &mut [u8], ) -> Result<(), ScryptError> { - let (n, r, p, _, _, _) = scrypt_shape(params, out)?; - let r128 = r.checked_mul(128).ok_or(ScryptError::ResourceOverflow)?; - let b_len = p.checked_mul(r128).ok_or(ScryptError::ResourceOverflow)?; - let v_len = n.checked_mul(r128).ok_or(ScryptError::ResourceOverflow)?; + if out.len() < MIN_OUTPUT_LEN { + return Err(ScryptError::InvalidOutputLen); + } + let shape = scrypt_shape(params)?; + let r128 = shape.r.checked_mul(128).ok_or(ScryptError::ResourceOverflow)?; + let b_len = shape.p.checked_mul(r128).ok_or(ScryptError::ResourceOverflow)?; + let v_len = shape.n.checked_mul(r128).ok_or(ScryptError::ResourceOverflow)?; let mut state = ScryptByteState::new(b_len, v_len, r128)?; let prf = Pbkdf2Sha256::new(password); @@ -974,7 +904,7 @@ fn scrypt_hash_x86_sse2( .map_err(|_| ScryptError::InvalidOutputLen)?; for chunk in state.b.chunks_exact_mut(r128) { - x86_sse2::ro_mix(chunk, &mut state.v, &mut state.scratch, n); + x86_sse2::ro_mix(chunk, &mut state.v, &mut state.scratch, shape.n); } prf @@ -996,8 +926,8 @@ fn scrypt_hash(params: &ScryptParams, password: &[u8], salt: &[u8], out: &mut [u /// scrypt password-hashing (RFC 7914). /// -/// Mirrors the UX of [`crate::Argon2id`]: `hash`, `hash_array`, `verify` -/// for raw tags, and PHC-string helpers behind `feature = "phc-strings"`. +/// Provides raw derivation and constant-time verification. Use +/// `ScryptPassword` (feature `phc-strings`) for generated, bounded PHC password records. /// /// # Examples /// @@ -1005,16 +935,10 @@ fn scrypt_hash(params: &ScryptParams, password: &[u8], salt: &[u8], out: &mut [u /// use rscrypto::{Scrypt, ScryptParams}; /// /// // Small CI-friendly params — production deployments should use -/// // `ScryptParams::new()` for the OWASP 2024 defaults. -/// let params = ScryptParams::new() -/// .log_n(10) -/// .r(8) -/// .p(1) -/// .output_len(32) -/// .build() -/// .unwrap(); +/// let params = ScryptParams::new(10, 8, 1).unwrap(); /// -/// let hash = Scrypt::hash_array::<32>(¶ms, b"password", b"random-salt-1234").unwrap(); +/// let mut hash = [0u8; 32]; +/// Scrypt::derive(¶ms, b"password", b"random-salt-1234", &mut hash).unwrap(); /// assert!(Scrypt::verify(¶ms, b"password", b"random-salt-1234", &hash).is_ok()); /// ``` #[derive(Debug, Clone, Copy, Default)] @@ -1031,50 +955,33 @@ impl Scrypt { /// Minimum output length (bytes) accepted by the hasher. pub const MIN_OUTPUT_LEN: usize = MIN_OUTPUT_LEN; - /// Hash `password` with `salt` into `out`. + /// Derive bytes from `password` and `salt` into `out`. /// /// # Errors /// - /// Returns [`ScryptError`] if parameters are out of range, `out.len()` - /// does not match `params.output_len`, or the working-set allocation - /// fails. - pub fn hash(params: &ScryptParams, password: &[u8], salt: &[u8], out: &mut [u8]) -> Result<(), ScryptError> { + /// Returns [`ScryptError`] if the output length, resource shape, or + /// working-set allocation is invalid. + pub fn derive(params: &ScryptParams, password: &[u8], salt: &[u8], out: &mut [u8]) -> Result<(), ScryptError> { scrypt_hash(params, password, salt, out) } - /// Hash `password` with `salt` into a fixed-size array. - /// - /// # Errors - /// - /// Returns [`ScryptError`] if `N != params.output_len`, parameters are - /// out of range, or the working-set allocation fails. - pub fn hash_array( - params: &ScryptParams, - password: &[u8], - salt: &[u8], - ) -> Result<[u8; N], ScryptError> { - let mut out = [0u8; N]; - Self::hash(params, password, salt, &mut out)?; - Ok(out) - } - /// Verify `expected` against a freshly-computed hash in constant time. /// - /// scrypt always runs to completion regardless of `expected.len()` — the - /// length check is folded into the final boolean, not an early return, - /// so wall-clock cost does not leak `params.output_len`. The dominant - /// cost (ROMix at the configured `log_n / r / p`) is paid in every - /// failure path. - /// /// # Errors /// /// Returns an opaque [`VerificationError`] on any mismatch, malformed /// input, or parameter error. #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] pub fn verify(params: &ScryptParams, password: &[u8], salt: &[u8], expected: &[u8]) -> Result<(), VerificationError> { - let actual_len = params.output_len as usize; - let mut actual = alloc::vec![0u8; actual_len]; - let hash_failed = Self::hash(params, password, salt, &mut actual).is_err(); + if expected.len() < MIN_OUTPUT_LEN { + return Err(VerificationError::new()); + } + let mut actual = Vec::new(); + actual + .try_reserve_exact(expected.len()) + .map_err(|_| VerificationError::new())?; + actual.resize(expected.len(), 0); + let hash_failed = Self::derive(params, password, salt, &mut actual).is_err(); let bytes_match = ct::constant_time_eq(&actual, expected); ct::zeroize(&mut actual); @@ -1086,229 +993,196 @@ impl Scrypt { Err(VerificationError::new()) } } +} - /// Hash `password` with `salt` and encode the result as a PHC string. - /// - /// Emits `$scrypt$ln=,r=,p=

$$` (RFC 4648 - /// base64, no padding). scrypt has no version segment per PHC - /// convention. - /// - /// # Errors - /// - /// Propagates any [`ScryptError`] from parameter validation, input - /// length checks, or working-set allocation. - #[cfg(feature = "phc-strings")] - pub fn hash_string_with_salt( - params: &ScryptParams, - password: &[u8], - salt: &[u8], - ) -> Result { - let mut hash = alloc::vec![0u8; params.output_len as usize]; - Self::hash(params, password, salt, &mut hash)?; - let encoded = phc_integration::encode_string(params, salt, &hash); - ct::zeroize(&mut hash); - Ok(encoded) +/// scrypt password generation and bounded PHC verification. +#[cfg(feature = "phc-strings")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScryptPassword { + generation: ScryptParams, + limits: ScryptVerificationLimits, +} + +#[cfg(feature = "phc-strings")] +impl Default for ScryptPassword { + fn default() -> Self { + let generation = ScryptParams::default(); + Self { + generation, + limits: ScryptVerificationLimits::for_profile(generation), + } } +} - /// Hash `password` with a fresh 16-byte salt from the operating system - /// CSPRNG and encode the result as a PHC string. +#[cfg(feature = "phc-strings")] +impl ScryptPassword { + /// Use `generation` for new hashes and derive matching verification limits. /// /// # Errors /// - /// Propagates any [`ScryptError`] from parameter validation, input length - /// checks, working-set allocation, or entropy-source failure. - #[cfg(all(feature = "phc-strings", feature = "getrandom"))] - pub fn hash_string(params: &ScryptParams, password: &[u8]) -> Result { - let mut salt = [0u8; 16]; - getrandom::fill(&mut salt).map_err(|_| ScryptError::EntropyUnavailable)?; - Self::hash_string_with_salt(params, password, &salt) + /// Returns [`ScryptError::ResourceOverflow`] if the profile cannot fit the + /// target address space. + pub fn new(generation: ScryptParams) -> Result { + scrypt_shape(&generation)?; + Ok(Self { + generation, + limits: ScryptVerificationLimits::for_profile(generation), + }) } - /// Verify `password` against a PHC-encoded hash in constant time. - /// - /// Parses the encoded string, rebuilds the cost parameters, re-hashes - /// `password` with the embedded salt, and compares in constant time. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// parameter error, or default-policy violation. Errors are intentionally - /// opaque — callers needing to distinguish parse failures should use - /// [`Scrypt::decode_string`]. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string(password: &[u8], encoded: &str) -> Result<(), VerificationError> { - Self::verify_string_with_policy(password, encoded, &ScryptVerifyPolicy::default()) + /// Use distinct generation parameters and finite verification limits. + pub fn with_limits(generation: ScryptParams, limits: ScryptVerificationLimits) -> Result { + scrypt_shape(&generation)?; + if !limits.allows(generation) { + return Err(ScryptError::VerificationLimitTooLow); + } + Ok(Self { generation, limits }) } - /// Verify `password` against a PHC-encoded hash without cost bounds. - /// - /// This compatibility helper accepts the encoded CPU/memory parameters and - /// output length as long as the PHC string itself is structurally valid. Use - /// it only for trusted local migration inputs or test-vector harnesses. For - /// stored password verification, prefer [`verify_string`](Self::verify_string) - /// or [`verify_string_with_policy`](Self::verify_string_with_policy). - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, or - /// parameter error. - #[cfg(feature = "phc-strings")] - #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_unbounded(password: &[u8], encoded: &str) -> Result<(), VerificationError> { - Self::verify_string_with_policy( - password, - encoded, - &ScryptVerifyPolicy::new(u8::MAX, u32::MAX, u32::MAX, usize::MAX), - ) + /// Hash a password with an OS-generated salt and canonical scrypt PHC encoding. + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] + pub fn hash_password(&self, password: &[u8]) -> Result { + let mut salt = [0u8; PASSWORD_SALT_LEN]; + getrandom::fill(&mut salt).map_err(|_| ScryptError::EntropyUnavailable)?; + let mut verifier = crate::secret::ZeroizingBytes::::zeroed(); + Scrypt::derive(&self.generation, password, &salt, verifier.as_mut_array())?; + Ok(password_phc::encode(self.generation, &salt, verifier.as_array())) } - /// Verify `password` against a PHC string after enforcing operational - /// bounds on its encoded cost parameters. - /// - /// # Errors - /// - /// Returns [`VerificationError`] on any mismatch, malformed string, - /// parameter error, or policy violation. + /// Verify a password after approving all encoded resource requests. #[cfg(feature = "phc-strings")] #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] - pub fn verify_string_with_policy( + pub fn verify_password( + &self, password: &[u8], encoded: &str, - policy: &ScryptVerifyPolicy, - ) -> Result<(), VerificationError> { - let parsed = phc_integration::decode_string(encoded).map_err(|_| VerificationError::new())?; - if !policy.allows(&parsed.params, parsed.hash.len()) { - return Err(VerificationError::new()); - } - let mut actual = alloc::vec![0u8; parsed.hash.len()]; - if Self::hash(&parsed.params, password, &parsed.salt, &mut actual).is_err() { - ct::zeroize(&mut actual); + ) -> Result { + let approved = password_phc::approve(encoded, self.limits).map_err(|_| VerificationError::new())?; + let mut actual = crate::secret::ZeroizingBytes::::zeroed(); + Scrypt::derive(&approved.params, password, approved.salt(), actual.as_mut_array()) + .map_err(|_| VerificationError::new())?; + let verified = ct::constant_time_eq(actual.as_array(), &approved.expected); + if !core::hint::black_box(verified) { return Err(VerificationError::new()); } - let ok = ct::constant_time_eq(&actual, &parsed.hash); - ct::zeroize(&mut actual); - if core::hint::black_box(ok) { - Ok(()) + if approved.params == self.generation && approved.salt_len as usize == PASSWORD_SALT_LEN { + Ok(crate::auth::PasswordStatus::Current) } else { - Err(VerificationError::new()) + Ok(crate::auth::PasswordStatus::NeedsRehash) } } - - /// Decode a PHC string without re-hashing. - /// - /// Returns the parsed cost parameters, salt, and reference hash. - /// - /// # Errors - /// - /// Returns [`crate::auth::phc::PhcError`] on any parse failure, or if - /// the encoded algorithm does not match `Self::ALGORITHM`. - #[cfg(feature = "phc-strings")] - pub fn decode_string( - encoded: &str, - ) -> Result<(ScryptParams, alloc::vec::Vec, alloc::vec::Vec), crate::auth::phc::PhcError> { - let parsed = phc_integration::decode_string(encoded)?; - Ok((parsed.params, parsed.salt, parsed.hash)) - } } -// ─── PHC string integration (feature: phc-strings) ───────────────────────── +#[cfg(feature = "phc-strings")] +const PASSWORD_SALT_LEN: usize = 16; +#[cfg(feature = "phc-strings")] +const MIN_PHC_SALT_LEN: usize = 8; +#[cfg(feature = "phc-strings")] +const MAX_PHC_SALT_LEN: usize = 48; +#[cfg(feature = "phc-strings")] +const PASSWORD_OUTPUT_LEN: usize = DEFAULT_OUTPUT_LEN; #[cfg(feature = "phc-strings")] -mod phc_integration { - use alloc::{string::String, vec::Vec}; +mod password_phc { + #[cfg(any(feature = "getrandom", test))] + use alloc::string::String; - use super::{MIN_OUTPUT_LEN, ScryptParams}; + use super::{ + MAX_PHC_SALT_LEN, MIN_PHC_SALT_LEN, PASSWORD_OUTPUT_LEN, ScryptParams, ScryptVerificationLimits, scrypt_shape, + }; use crate::auth::phc::{self, PhcError}; - /// Parsed PHC components reconstituted into rscrypto types. - pub(super) struct ParsedPhc { + pub(super) struct ApprovedPhc { pub params: ScryptParams, - pub salt: Vec, - pub hash: Vec, + pub salt: [u8; MAX_PHC_SALT_LEN], + pub salt_len: u8, + pub expected: [u8; PASSWORD_OUTPUT_LEN], } - /// Build `$scrypt$ln=,r=,p=

$$`. - pub(super) fn encode_string(params: &ScryptParams, salt: &[u8], hash: &[u8]) -> String { - let mut out = String::new(); - out.push('$'); - out.push_str(super::Scrypt::ALGORITHM); - out.push_str("$ln="); - phc::push_u32_decimal(&mut out, u32::from(params.get_log_n())); - out.push_str(",r="); - phc::push_u32_decimal(&mut out, params.get_r()); - out.push_str(",p="); - phc::push_u32_decimal(&mut out, params.get_p()); - out.push('$'); - phc::base64_encode_into(salt, &mut out); - out.push('$'); - phc::base64_encode_into(hash, &mut out); - out + impl ApprovedPhc { + pub fn salt(&self) -> &[u8] { + &self.salt[..self.salt_len as usize] + } + } + + fn next_param<'a>(params: &mut phc::PhcParamIter<'a>, expected: &str) -> Result<&'a str, PhcError> { + let (key, value) = params.next().ok_or(PhcError::MissingParam)??; + if key != expected { + return Err(if matches!(key, "ln" | "r" | "p") { + PhcError::DuplicateParam + } else { + PhcError::UnknownParam + }); + } + Ok(value) } - /// Parse a PHC string and reconstitute `(params, salt, hash)`. - pub(super) fn decode_string(encoded: &str) -> Result { + pub(super) fn approve(encoded: &str, limits: ScryptVerificationLimits) -> Result { let parts = phc::parse(encoded)?; - if parts.algorithm != super::Scrypt::ALGORITHM { + if parts.algorithm != "scrypt" { return Err(PhcError::AlgorithmMismatch); } - // scrypt has no version segment per PHC convention. A `v=` prefix - // would have been parsed as the version slot; reject it. if parts.version.is_some() { return Err(PhcError::UnsupportedVersion); } - let mut log_n: Option = None; - let mut r: Option = None; - let mut p: Option = None; - - for pair in phc::PhcParamIter::new(parts.parameters) { - let (k, v) = pair?; - let value = phc::parse_param_u32(v)?; - match k { - "ln" => { - if log_n.replace(value).is_some() { - return Err(PhcError::DuplicateParam); - } - } - "r" => { - if r.replace(value).is_some() { - return Err(PhcError::DuplicateParam); - } - } - "p" => { - if p.replace(value).is_some() { - return Err(PhcError::DuplicateParam); - } - } - _ => return Err(PhcError::UnknownParam), - } + let mut values = phc::PhcParamIter::new(parts.parameters); + let log_n = phc::parse_param_u32(next_param(&mut values, "ln")?)?; + let r = phc::parse_param_u32(next_param(&mut values, "r")?)?; + let p = phc::parse_param_u32(next_param(&mut values, "p")?)?; + if let Some(extra) = values.next() { + let (key, _) = extra?; + return Err(if matches!(key, "ln" | "r" | "p") { + PhcError::DuplicateParam + } else { + PhcError::UnknownParam + }); } - - let log_n_u32 = log_n.ok_or(PhcError::MissingParam)?; - let r = r.ok_or(PhcError::MissingParam)?; - let p = p.ok_or(PhcError::MissingParam)?; - - if log_n_u32 > u8::MAX as u32 { + if log_n > u8::MAX as u32 { + return Err(PhcError::ParamOutOfRange); + } + let params = ScryptParams::new(log_n as u8, r, p).map_err(|_| PhcError::ParamOutOfRange)?; + scrypt_shape(¶ms).map_err(|_| PhcError::ParamOutOfRange)?; + if !limits.allows(params) { return Err(PhcError::ParamOutOfRange); } - let salt = phc::decode_base64_to_vec(parts.salt_b64)?; - let hash = phc::decode_base64_to_vec(parts.hash_b64)?; + let salt_len = phc::base64_decoded_len(parts.salt_b64.len()); + let output_len = phc::base64_decoded_len(parts.hash_b64.len()); + if !(MIN_PHC_SALT_LEN..=MAX_PHC_SALT_LEN).contains(&salt_len) || output_len != PASSWORD_OUTPUT_LEN { + return Err(PhcError::InvalidLength); + } - if hash.len() < MIN_OUTPUT_LEN { + let mut salt = [0u8; MAX_PHC_SALT_LEN]; + let decoded_salt_len = phc::base64_decode_into(parts.salt_b64, &mut salt)?; + let mut expected = [0u8; PASSWORD_OUTPUT_LEN]; + let decoded_output_len = phc::base64_decode_into(parts.hash_b64, &mut expected)?; + if decoded_salt_len != salt_len || decoded_output_len != PASSWORD_OUTPUT_LEN { return Err(PhcError::InvalidLength); } - let params = ScryptParams::new() - .log_n(log_n_u32 as u8) - .r(r) - .p(p) - .output_len(hash.len() as u32) - .build() - .map_err(|_| PhcError::ParamOutOfRange)?; + Ok(ApprovedPhc { + params, + salt, + salt_len: decoded_salt_len as u8, + expected, + }) + } - Ok(ParsedPhc { params, salt, hash }) + #[cfg(any(feature = "getrandom", test))] + pub(super) fn encode(params: ScryptParams, salt: &[u8], verifier: &[u8; PASSWORD_OUTPUT_LEN]) -> String { + let mut out = String::with_capacity(112); + out.push_str("$scrypt$ln="); + phc::push_u32_decimal(&mut out, u32::from(params.get_log_n())); + out.push_str(",r="); + phc::push_u32_decimal(&mut out, params.get_r()); + out.push_str(",p="); + phc::push_u32_decimal(&mut out, params.get_p()); + out.push('$'); + phc::base64_encode_into(salt, &mut out); + out.push('$'); + phc::base64_encode_into(verifier, &mut out); + out } } @@ -1320,8 +1194,6 @@ mod tests { use super::*; - // ── RFC 7914 §11 vector 1 ───────────────────────────────────────────── - // P="" S="" N=16 r=1 p=1 dkLen=64 const RFC_V1_EXPECTED: [u8; 64] = [ 0x77, 0xd6, 0x57, 0x62, 0x38, 0x65, 0x7b, 0x20, 0x3b, 0x19, 0xca, 0x42, 0xc1, 0x8a, 0x04, 0x97, 0xf1, 0x6b, 0x48, 0x44, 0xe3, 0x07, 0x4a, 0xe8, 0xdf, 0xdf, 0xfa, 0x3f, 0xed, 0xe2, 0x14, 0x42, 0xfc, 0xd0, 0x06, 0x9d, 0xed, 0x09, @@ -1329,478 +1201,253 @@ mod tests { 0x35, 0xe2, 0x0c, 0x38, 0xd1, 0x89, 0x06, ]; + fn small_params() -> ScryptParams { + ScryptParams::new(4, 1, 1).unwrap() + } + #[test] fn rfc7914_vector_1_empty_inputs() { - let params = ScryptParams::new() - .log_n(4) // N = 16 - .r(1) - .p(1) - .output_len(64) - .build() - .unwrap(); - let mut out = [0u8; 64]; - Scrypt::hash(¶ms, b"", b"", &mut out).unwrap(); - assert_eq!(out, RFC_V1_EXPECTED); + let mut output = [0u8; 64]; + Scrypt::derive(&small_params(), b"", b"", &mut output).unwrap(); + assert_eq!(output, RFC_V1_EXPECTED); } - // ── Differential: rscrypto vs RustCrypto `scrypt` ───────────────────── - - fn oracle_scrypt(password: &[u8], salt: &[u8], log_n: u8, r: u32, p: u32, out_len: usize) -> alloc::vec::Vec { + fn oracle_scrypt(password: &[u8], salt: &[u8], log_n: u8, r: u32, p: u32, output_len: usize) -> alloc::vec::Vec { let params = scrypt::Params::new(log_n, r, p).unwrap(); - let mut out = vec![0u8; out_len]; - scrypt::scrypt(password, salt, ¶ms, &mut out).unwrap(); - out + let mut output = vec![0u8; output_len]; + scrypt::scrypt(password, salt, ¶ms, &mut output).unwrap(); + output } #[test] - fn matches_oracle_small_params() { - // Small enough to run under Miri too. - let cases: &[(u8, u32, u32, usize)] = &[(4, 1, 1, 32), (5, 2, 1, 32), (6, 2, 2, 32)]; - for &(log_n, r, p, out_len) in cases { - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(out_len as u32) - .build() - .unwrap(); - let mut actual = vec![0u8; out_len]; - Scrypt::hash(¶ms, b"password", b"salty-salty-salt", &mut actual).unwrap(); - let expected = oracle_scrypt(b"password", b"salty-salty-salt", log_n, r, p, out_len); - assert_eq!(actual, expected, "mismatch log_n={log_n} r={r} p={p} T={out_len}"); + fn matches_the_oracle_across_shapes_and_output_lengths() { + let cases: &[(u8, u32, u32, usize)] = &[(4, 1, 1, 16), (5, 2, 1, 32), (6, 2, 2, 64)]; + for &(log_n, r, p, output_len) in cases { + let params = ScryptParams::new(log_n, r, p).unwrap(); + let mut actual = vec![0u8; output_len]; + Scrypt::derive(¶ms, b"password", b"salty-salty-salt", &mut actual).unwrap(); + assert_eq!( + actual, + oracle_scrypt(b"password", b"salty-salty-salt", log_n, r, p, output_len), + "mismatch log_n={log_n} r={r} p={p} output_len={output_len}", + ); } } - #[cfg(not(miri))] - #[test] - fn matches_oracle_owasp_shape() { - // log_n=10 keeps the test fast; OWASP uses 17 in production. - let log_n = 10; - let r = 8; - let p = 1; - let out_len = 32; - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(out_len as u32) - .build() - .unwrap(); - let password = b"correct horse battery staple"; - let salt = b"random-salt-1234"; - let mut actual = vec![0u8; out_len]; - Scrypt::hash(¶ms, password, salt, &mut actual).unwrap(); - let expected = oracle_scrypt(password, salt, log_n, r, p, out_len); - assert_eq!(actual, expected); - } - #[cfg(all(target_arch = "x86_64", not(miri), not(feature = "portable-only")))] #[test] - fn x86_sse2_backend_matches_portable() { + fn sse2_backend_matches_portable() { let cases: &[(u8, u32, u32, usize)] = &[(4, 1, 1, 32), (5, 2, 1, 48), (6, 2, 2, 32), (7, 8, 1, 64)]; - let password = b"backend-equivalence-password"; - let salt = b"backend-equivalence-salt"; - - for &(log_n, r, p, out_len) in cases { - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(out_len as u32) - .build() - .unwrap(); - let mut portable = vec![0u8; out_len]; - let mut sse2 = vec![0u8; out_len]; - - scrypt_hash_portable(¶ms, password, salt, &mut portable).unwrap(); - scrypt_hash_x86_sse2(¶ms, password, salt, &mut sse2).unwrap(); - - assert_eq!(sse2, portable, "mismatch log_n={log_n} r={r} p={p} T={out_len}"); + for &(log_n, r, p, output_len) in cases { + let params = ScryptParams::new(log_n, r, p).unwrap(); + let mut portable = vec![0u8; output_len]; + let mut sse2 = vec![0u8; output_len]; + scrypt_hash_portable(¶ms, b"password", b"salty-salty-salt", &mut portable).unwrap(); + scrypt_hash_x86_sse2(¶ms, b"password", b"salty-salty-salt", &mut sse2).unwrap(); + assert_eq!(sse2, portable); } } - // ── Verify ──────────────────────────────────────────────────────────── - - #[test] - fn verify_accepts_correct() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let h = Scrypt::hash_array::<32>(¶ms, b"password", b"random-salt-1234").unwrap(); - assert!(Scrypt::verify(¶ms, b"password", b"random-salt-1234", &h).is_ok()); - } - - #[test] - fn verify_rejects_wrong_password() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let h = Scrypt::hash_array::<32>(¶ms, b"password", b"random-salt-1234").unwrap(); - assert!(Scrypt::verify(¶ms, b"wrong-password!!", b"random-salt-1234", &h).is_err()); - } - - #[test] - fn verify_rejects_wrong_salt() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let h = Scrypt::hash_array::<32>(¶ms, b"password", b"random-salt-1234").unwrap(); - assert!(Scrypt::verify(¶ms, b"password", b"other-salt-000000", &h).is_err()); - } - - #[test] - fn verify_rejects_length_mismatch() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let wrong_len = [0u8; 16]; - assert!(Scrypt::verify(¶ms, b"password", b"random-salt-1234", &wrong_len).is_err()); - } - - #[cfg(all(feature = "phc-strings", feature = "getrandom"))] - #[test] - fn hash_string_uses_random_salt_and_verifies() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let encoded = Scrypt::hash_string(¶ms, b"password").unwrap(); - assert!(Scrypt::verify_string(b"password", &encoded).is_ok()); - assert!(Scrypt::verify_string(b"wrong-password", &encoded).is_err()); - } - - // ── Byte flips at every position ────────────────────────────────────── - #[test] - fn verify_rejects_byte_flip_at_every_position() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let password = b"correct horse battery staple"; - let salt = b"random-salt-1234"; - let hash = Scrypt::hash_array::<32>(¶ms, password, salt).unwrap(); - for pos in 0..hash.len() { - let mut tampered = hash; - tampered[pos] ^= 0x01; - assert!( - Scrypt::verify(¶ms, password, salt, &tampered).is_err(), - "verify must reject flip at byte {pos}", - ); + fn raw_verify_accepts_only_the_exact_inputs() { + let params = small_params(); + let mut expected = [0u8; 32]; + Scrypt::derive(¶ms, b"password", b"random-salt-1234", &mut expected).unwrap(); + + assert!(Scrypt::verify(¶ms, b"password", b"random-salt-1234", &expected).is_ok()); + assert!(Scrypt::verify(¶ms, b"wrong", b"random-salt-1234", &expected).is_err()); + assert!(Scrypt::verify(¶ms, b"password", b"other-salt-00000", &expected).is_err()); + + for position in 0..expected.len() { + let mut tampered = expected; + tampered[position] ^= 1; + assert!(Scrypt::verify(¶ms, b"password", b"random-salt-1234", &tampered).is_err()); } } - // ── Parameter validation ────────────────────────────────────────────── - - #[test] - fn validate_rejects_zero_log_n() { - assert_eq!( - ScryptParams::new().log_n(0).build().unwrap_err(), - ScryptError::InvalidLogN, - ); - } - - #[test] - fn validate_rejects_log_n_too_large() { - assert_eq!( - ScryptParams::new().log_n(64).build().unwrap_err(), - ScryptError::InvalidLogN, - ); - } - - #[test] - fn validate_rejects_zero_r() { - assert_eq!(ScryptParams::new().r(0).build().unwrap_err(), ScryptError::InvalidR,); - } - - #[test] - fn validate_rejects_zero_p() { - assert_eq!(ScryptParams::new().p(0).build().unwrap_err(), ScryptError::InvalidP,); - } - - #[test] - fn validate_rejects_r_times_p_over_limit() { - // r*p = 2^30 → exceeds 2^30 - 1. - assert_eq!( - ScryptParams::new() - .log_n(4) - .r(1 << 15) - .p(1 << 15) - .output_len(32) - .build() - .unwrap_err(), - ScryptError::InvalidP, - ); - } - #[test] - fn validate_rejects_zero_output_len() { - assert_eq!( - ScryptParams::new().output_len(0).build().unwrap_err(), - ScryptError::InvalidOutputLen, - ); + fn params_are_valid_by_construction() { + assert_eq!(ScryptParams::new(0, 1, 1), Err(ScryptError::InvalidLogN)); + assert_eq!(ScryptParams::new(64, 1, 1), Err(ScryptError::InvalidLogN)); + assert_eq!(ScryptParams::new(4, 0, 1), Err(ScryptError::InvalidR)); + assert_eq!(ScryptParams::new(4, 1, 0), Err(ScryptError::InvalidP)); + assert_eq!(ScryptParams::new(4, 1 << 15, 1 << 15), Err(ScryptError::InvalidP)); + assert!(ScryptParams::new(4, 1, 1).is_ok()); } - // ── Output length mismatch at hash-time ─────────────────────────────── - #[test] - fn output_len_mismatch_rejected() { - let params = ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap(); - let mut out = [0u8; 16]; + fn derive_rejects_empty_output() { + let mut output = []; assert_eq!( - Scrypt::hash(¶ms, b"pw", b"salty-salty-salt", &mut out).unwrap_err(), - ScryptError::InvalidOutputLen, + Scrypt::derive(&small_params(), b"password", b"salt", &mut output), + Err(ScryptError::InvalidOutputLen) ); } - // ── Resource-overflow path on 64-bit hosts ──────────────────────────── - - // `log_n = 63` with `r = 2^20` validates (r·p = 2^20 ≤ 2^30−1) but - // `N · 2r = 2^63 · 2^21 = 2^84` does not fit in a 64-bit usize. The - // `checked_mul` in `scrypt_hash` must surface this as `ResourceOverflow` - // instead of panicking or silently allocating a truncated buffer. #[cfg(target_pointer_width = "64")] #[test] - fn hash_rejects_impossible_memory_size_on_64bit() { - let params = ScryptParams::new() - .log_n(63) - .r(1 << 20) - .p(1) - .output_len(32) - .build() - .unwrap(); - let mut out = [0u8; 32]; + fn derive_rejects_impossible_memory_shape_before_allocation() { + let params = ScryptParams::new(63, 1 << 20, 1).unwrap(); + let mut output = [0u8; 32]; assert_eq!( - Scrypt::hash(¶ms, b"pw", b"salty-salty-salt", &mut out).unwrap_err(), - ScryptError::ResourceOverflow, + Scrypt::derive(¶ms, b"password", b"salt", &mut output), + Err(ScryptError::ResourceOverflow) ); + #[cfg(feature = "phc-strings")] + assert_eq!(ScryptPassword::new(params), Err(ScryptError::ResourceOverflow)); } - // ── Error trait plumbing ────────────────────────────────────────────── - #[test] - fn error_is_copy_and_implements_error_trait() { + fn error_contract_is_complete() { fn assert_copy() {} fn assert_err() {} assert_copy::(); assert_err::(); - } - #[test] - fn error_display_is_non_empty_for_every_variant() { - let all = [ + let variants = [ ScryptError::InvalidLogN, ScryptError::InvalidR, ScryptError::InvalidP, ScryptError::InvalidOutputLen, ScryptError::ResourceOverflow, ScryptError::AllocationFailed, + #[cfg(all(feature = "phc-strings", feature = "getrandom"))] ScryptError::EntropyUnavailable, + #[cfg(feature = "phc-strings")] + ScryptError::VerificationLimitTooLow, ]; - for e in all { - let s = alloc::format!("{e}"); - assert!(!s.is_empty()); + for error in variants { + assert!(!alloc::format!("{error}").is_empty()); } } - // ── Kernel dispatch ────────────────────────────────────────────────── - #[test] - fn kernel_id_stringifies() { - #[cfg(all(target_arch = "x86_64", not(miri), not(feature = "portable-only")))] - assert_eq!(KernelId::X86Sse2.as_str(), "x86-sse2"); + fn kernel_contract_includes_the_portable_fallback() { + assert!(ALL_KERNELS.contains(&KernelId::Portable)); + assert!(required_caps(KernelId::Portable).is_empty()); assert_eq!(KernelId::Portable.as_str(), "portable"); } #[test] - fn compiled_kernels_have_no_required_caps() { - for kernel in ALL_KERNELS { - assert!(required_caps(*kernel).is_empty()); - } + fn salsa20_8_is_deterministic_and_non_identity() { + let original = SalsaBlock([0x1234_5678; BLOCK_WORDS]); + let mut first = original; + let mut second = original; + salsa20_8(&mut first); + salsa20_8(&mut second); + assert_eq!(first.0, second.0); + assert_ne!(first.0, original.0); } - #[test] - fn active_kernel_matches_compiled_target() { - #[cfg(all(target_arch = "x86_64", not(miri), not(feature = "portable-only")))] - assert_eq!(active_kernel(), KernelId::X86Sse2); - - #[cfg(not(all(target_arch = "x86_64", not(miri), not(feature = "portable-only"))))] - assert_eq!(active_kernel(), KernelId::Portable); - } - - // ── Salsa20/8 self-consistency ─────────────────────────────────────── - - #[test] - fn salsa20_8_is_deterministic() { - let a = SalsaBlock([0x1234_5678; BLOCK_WORDS]); - let mut a1 = a; - let mut a2 = a; - salsa20_8(&mut a1); - salsa20_8(&mut a2); - assert_eq!(a1.0, a2.0); - assert_ne!(a1.0, a.0, "Salsa20/8 must not be the identity on a constant input"); - } - - // ── PHC integration ────────────────────────────────────────────────── - - #[cfg(feature = "phc-strings")] + #[cfg(all(feature = "phc-strings", not(miri)))] mod phc_tests { - use alloc::{format, vec}; + use alloc::format; use super::*; - use crate::auth::phc::PhcError; - - fn small_params() -> ScryptParams { - ScryptParams::new().log_n(4).r(1).p(1).output_len(32).build().unwrap() - } - - #[test] - fn hash_string_with_salt_round_trip() { - let params = small_params(); - let salt = [0xAAu8; 16]; - let encoded = Scrypt::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); - assert!(encoded.starts_with("$scrypt$ln=4,r=1,p=1$")); - assert!(Scrypt::verify_string(b"password", &encoded).is_ok()); - assert!(Scrypt::verify_string_unbounded(b"password", &encoded).is_ok()); - assert!(Scrypt::verify_string(b"wrongpassword", &encoded).is_err()); - } + use crate::auth::{PasswordStatus, phc::PhcError}; - #[test] - fn verify_string_default_policy_rejects_oversized_scrypt_costs() { - let params = small_params(); - let salt = [0xA0u8; 16]; - let encoded = Scrypt::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); - let too_much_log_n = ScryptVerifyPolicy::default().max_log_n.strict_add(1); - let expensive = encoded.replace("ln=4", &format!("ln={too_much_log_n}")); - - assert!(Scrypt::decode_string(&expensive).is_ok()); - assert!(Scrypt::verify_string(b"password", &expensive).is_err()); + fn encode(params: ScryptParams, password: &[u8], salt: &[u8]) -> alloc::string::String { + let mut verifier = [0u8; PASSWORD_OUTPUT_LEN]; + Scrypt::derive(¶ms, password, salt, &mut verifier).unwrap(); + password_phc::encode(params, salt, &verifier) } #[test] - fn verify_string_with_policy_enforces_scrypt_bounds() { + fn canonical_password_record_round_trips() { let params = small_params(); - let salt = [0xA1u8; 16]; - let encoded = Scrypt::hash_string_with_salt(¶ms, b"password", &salt).unwrap(); - - let allowed = ScryptVerifyPolicy::new(4, 1, 1, 32); - assert!(Scrypt::verify_string_with_policy(b"password", &encoded, &allowed).is_ok()); - - let low_log_n = ScryptVerifyPolicy::new(3, 1, 1, 32); - assert!(Scrypt::verify_string_with_policy(b"password", &encoded, &low_log_n).is_err()); + let password = ScryptPassword::new(params).unwrap(); + let encoded = encode(params, b"password", &[0xaa; 16]); - let short_output = ScryptVerifyPolicy::new(4, 1, 1, 31); - assert!(Scrypt::verify_string_with_policy(b"password", &encoded, &short_output).is_err()); + assert!(encoded.starts_with("$scrypt$ln=4,r=1,p=1$")); + assert_eq!( + password.verify_password(b"password", &encoded), + Ok(PasswordStatus::Current) + ); + assert!(password.verify_password(b"wrong", &encoded).is_err()); } #[test] - fn decode_string_extracts_params_salt_hash() { - let params = small_params(); - let salt = vec![0xDDu8; 16]; - let encoded = Scrypt::hash_string_with_salt(¶ms, b"pw", &salt).unwrap(); - - let (decoded_params, decoded_salt, decoded_hash) = Scrypt::decode_string(&encoded).unwrap(); - assert_eq!(decoded_params.get_log_n(), 4); - assert_eq!(decoded_params.get_r(), 1); - assert_eq!(decoded_params.get_p(), 1); - assert_eq!(decoded_params.get_output_len(), 32); - assert_eq!(decoded_salt, salt); - assert_eq!(decoded_hash.len(), 32); - - let mut rehashed = [0u8; 32]; - Scrypt::hash(&decoded_params, b"pw", &decoded_salt, &mut rehashed).unwrap(); - assert_eq!(rehashed.as_slice(), decoded_hash.as_slice()); - } + fn accepted_older_profile_requests_rehash() { + let generation = ScryptParams::new(5, 1, 1).unwrap(); + let password = ScryptPassword::new(generation).unwrap(); + let encoded = encode(small_params(), b"password", &[0xbb; 16]); - #[test] - fn decode_string_rejects_duplicate_params() { - let params = small_params(); - let encoded = Scrypt::hash_string_with_salt(¶ms, b"pw", &[0xFFu8; 16]).unwrap(); - let broken = encoded.replace("r=1", "ln=4"); - assert_eq!(Scrypt::decode_string(&broken).unwrap_err(), PhcError::DuplicateParam); + assert_eq!( + password.verify_password(b"password", &encoded), + Ok(PasswordStatus::NeedsRehash) + ); } #[test] - fn decode_string_rejects_unknown_param() { + fn accepted_noncurrent_salt_length_requests_rehash() { let params = small_params(); - let encoded = Scrypt::hash_string_with_salt(¶ms, b"pw", &[0xFFu8; 16]).unwrap(); - let broken = encoded.replace("ln=4", "bogus=1"); - assert_eq!(Scrypt::decode_string(&broken).unwrap_err(), PhcError::UnknownParam); - } + let password = ScryptPassword::new(params).unwrap(); + let encoded = encode(params, b"password", &[0xbb; 8]); - #[test] - fn decode_string_rejects_algorithm_mismatch() { - // Argon2id-shaped string with scrypt decoder. assert_eq!( - Scrypt::decode_string("$argon2id$v=19$m=32,t=2,p=1$c29tZXNhbHQ$c29tZWhhc2g").unwrap_err(), - PhcError::AlgorithmMismatch, + password.verify_password(b"password", &encoded), + Ok(PasswordStatus::NeedsRehash) ); } #[test] - fn decode_string_rejects_version_segment() { - // scrypt PHC has no version segment; a `v=19` slot must be refused. + fn approval_rejects_resource_requests_before_base64_decode() { + let limits = ScryptVerificationLimits::for_profile(small_params()); + let expensive = "$scrypt$ln=5,r=1,p=1$*$*"; assert_eq!( - Scrypt::decode_string("$scrypt$v=1$ln=4,r=1,p=1$c29tZXNhbHQ$c29tZWhhc2g").unwrap_err(), - PhcError::UnsupportedVersion, + password_phc::approve(expensive, limits).err(), + Some(PhcError::ParamOutOfRange) ); - } - #[test] - fn decode_string_distinguishes_version_segment_from_version_param() { - // `v=1` in the version slot → UnsupportedVersion (no version segment - // allowed). `version=1` in the params slot → UnknownParam. Pins both - // paths so a future parser regression that conflates them is caught. + let admitted = "$scrypt$ln=4,r=1,p=1$*$*"; assert_eq!( - Scrypt::decode_string("$scrypt$v=1$ln=4,r=1,p=1$c29tZXNhbHQ$c29tZWhhc2g").unwrap_err(), - PhcError::UnsupportedVersion, - ); - assert_eq!( - Scrypt::decode_string("$scrypt$version=1,ln=4,r=1,p=1$c29tZXNhbHQ$c29tZWhhc2g").unwrap_err(), - PhcError::UnknownParam, + password_phc::approve(admitted, limits).err(), + Some(PhcError::InvalidLength) ); } #[test] - fn decode_string_rejects_missing_required_param() { - // Drop the `ln=` key from an otherwise-valid string. - let params = small_params(); - let encoded = Scrypt::hash_string_with_salt(¶ms, b"pw", &[0x11u8; 16]).unwrap(); - let broken = encoded.replace("ln=4,", ""); - assert_eq!(Scrypt::decode_string(&broken).unwrap_err(), PhcError::MissingParam); + fn limits_bound_both_memory_and_work() { + let limits = ScryptVerificationLimits::for_profile(small_params()); + assert!(limits.allows(small_params())); + assert!(!limits.allows(ScryptParams::new(5, 1, 1).unwrap())); + assert!(!limits.allows(ScryptParams::new(4, 1, 2).unwrap())); } #[test] - fn decode_string_rejects_out_of_range_log_n() { - // `ln=999` exceeds `u8::MAX` / RFC 7914 log_n ≤ 63. The decoder rejects - // at `log_n > u8::MAX` check, then at `ScryptParams::build()` if it - // slipped past. - let params = small_params(); - let encoded = Scrypt::hash_string_with_salt(¶ms, b"pw", &[0x22u8; 16]).unwrap(); - let broken = encoded.replace("ln=4", "ln=999"); - assert_eq!(Scrypt::decode_string(&broken).unwrap_err(), PhcError::ParamOutOfRange); + fn parser_accepts_only_the_canonical_scrypt_protocol() { + let limits = ScryptVerificationLimits::for_profile(small_params()); + let salt = "AAAAAAAAAAAAAAAAAAAAAA"; + let hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let cases = [ + format!("$argon2id$v=19$m=32,t=2,p=1$${salt}$${hash}"), + format!("$scrypt$v=1$ln=4,r=1,p=1$${salt}$${hash}"), + format!("$scrypt$r=1,ln=4,p=1$${salt}$${hash}"), + format!("$scrypt$ln=4,ln=4,p=1$${salt}$${hash}"), + format!("$scrypt$ln=4,r=1,x=1$${salt}$${hash}"), + format!("$scrypt$ln=04,r=1,p=1$${salt}$${hash}"), + ]; + for encoded in cases { + assert!(password_phc::approve(&encoded, limits).is_err(), "{encoded}"); + } } + #[cfg(feature = "getrandom")] #[test] - fn hash_array_and_hash_agree_byte_for_byte() { - let params = small_params(); - let arr = Scrypt::hash_array::<32>(¶ms, b"pw", b"salty-salty-salt").unwrap(); - let mut via_hash = [0u8; 32]; - Scrypt::hash(¶ms, b"pw", b"salty-salty-salt", &mut via_hash).unwrap(); - assert_eq!(arr, via_hash); - } + fn generated_records_use_fresh_salts() { + let password = ScryptPassword::new(small_params()).unwrap(); + let first = password.hash_password(b"password").unwrap(); + let second = password.hash_password(b"password").unwrap(); - #[test] - fn hash_is_deterministic() { - // Two independent `Scrypt::hash` calls on identical inputs must - // produce byte-identical outputs. Guards against accidental reliance - // on uninitialised working-set memory in ROMix. - let params = small_params(); - let mut a = [0u8; 32]; - let mut b = [0u8; 32]; - Scrypt::hash(¶ms, b"pw", b"salty-salty-salt", &mut a).unwrap(); - Scrypt::hash(¶ms, b"pw", b"salty-salty-salt", &mut b).unwrap(); - assert_eq!(a, b); - } - - #[test] - fn encoded_format_exact_for_known_vector() { - let params = small_params(); - let salt = b"exampleSALTvalue"; // 16 bytes - let encoded = Scrypt::hash_string_with_salt(¶ms, b"password", salt).unwrap(); - let segments: alloc::vec::Vec<&str> = encoded.split('$').collect(); - assert_eq!(segments[0], ""); - assert_eq!(segments[1], "scrypt"); - assert_eq!(segments[2], "ln=4,r=1,p=1"); - assert_eq!(segments[3].len(), 22); // 16 bytes base64-nopad - assert_eq!(segments[4].len(), 43); // 32 bytes base64-nopad - assert_eq!(segments.len(), 5); + assert_ne!(first, second); + assert_eq!( + password.verify_password(b"password", &first), + Ok(PasswordStatus::Current) + ); + assert_eq!( + password.verify_password(b"password", &second), + Ok(PasswordStatus::Current) + ); } } } diff --git a/src/lib.rs b/src/lib.rs index a65bfe00..ec4ef55d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,18 +79,15 @@ assert_eq!(&opened, b"data"); # Password Hashing ```rust -use rscrypto::{Argon2Params, Argon2VerifyPolicy, Argon2id}; +use rscrypto::Argon2idPassword; -let params = Argon2Params::new().build()?; -let encoded = Argon2id::hash_string(¶ms, b"correct horse battery staple")?; +let passwords = Argon2idPassword::default(); +let encoded = passwords.hash_password(b"correct horse battery staple")?; assert!( - Argon2id::verify_string_with_policy( - b"correct horse battery staple", - &encoded, - &Argon2VerifyPolicy::default(), - ) - .is_ok() + passwords + .verify_password(b"correct horse battery staple", &encoded) + .is_ok() ); # Ok::<(), Box>(()) ``` @@ -386,8 +383,8 @@ pub use aead::{ChaCha20Poly1305, ChaCha20Poly1305Key, ChaCha20Poly1305Tag}; pub use aead::{XChaCha20Poly1305, XChaCha20Poly1305Key, XChaCha20Poly1305Tag}; #[cfg(feature = "hkdf")] pub use auth::HkdfOutputLengthError; -#[cfg(feature = "phc-strings")] -pub use auth::PhcError; +#[cfg(all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt")))] +pub use auth::PasswordStatus; #[cfg(all(feature = "diag", feature = "ed25519"))] pub use auth::diag_ed25519_select_basepoint_cached_limb_digest; #[cfg(all( @@ -400,7 +397,9 @@ pub use auth::diag_ed25519_select_basepoint_cached_limb_digest; ))] pub use auth::diag_ed25519_verify_aarch64_asm_double_scalar_digest; #[cfg(feature = "argon2")] -pub use auth::{Argon2Error, Argon2Params, Argon2VerifyPolicy, Argon2Version, Argon2d, Argon2i, Argon2id}; +pub use auth::{Argon2Context, Argon2Error, Argon2Params, Argon2d, Argon2i, Argon2id}; +#[cfg(all(feature = "argon2", feature = "phc-strings"))] +pub use auth::{Argon2VerificationLimits, Argon2idPassword}; #[cfg(all(feature = "diag", feature = "ed25519"))] pub use auth::{ DiagEd25519VerifyScalars, diag_ed25519_verify_challenge_reduce_digest, @@ -447,7 +446,9 @@ pub use auth::{ RsaX509PublicKeyAlgorithm, }; #[cfg(feature = "scrypt")] -pub use auth::{Scrypt, ScryptError, ScryptParams, ScryptVerifyPolicy}; +pub use auth::{Scrypt, ScryptError, ScryptParams}; +#[cfg(all(feature = "scrypt", feature = "phc-strings"))] +pub use auth::{ScryptPassword, ScryptVerificationLimits}; #[cfg(feature = "x25519")] pub use auth::{X25519Error, X25519PublicKey, X25519SecretKey, X25519SharedSecret}; #[cfg(all(feature = "diag", feature = "ecdsa-p256"))] diff --git a/src/secret.rs b/src/secret.rs index 27e42b72..50e78125 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -184,6 +184,7 @@ impl Drop for SecretVec { feature = "xchacha20poly1305", feature = "aegis256", feature = "ascon-aead", + all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt")), feature = "ecdsa-p256", feature = "ecdsa-p384", feature = "ed25519", @@ -200,6 +201,7 @@ pub(crate) struct ZeroizingBytes([u8; N]); feature = "xchacha20poly1305", feature = "aegis256", feature = "ascon-aead", + all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt")), feature = "ecdsa-p256", feature = "ecdsa-p384", feature = "ed25519", @@ -237,6 +239,7 @@ impl ZeroizingBytes { feature = "xchacha20poly1305", feature = "aegis256", feature = "ascon-aead", + all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt")), feature = "ecdsa-p256", feature = "ecdsa-p384", feature = "ed25519", diff --git a/tests/argon2_differential.rs b/tests/argon2_differential.rs index d7c23541..9c63baa9 100644 --- a/tests/argon2_differential.rs +++ b/tests/argon2_differential.rs @@ -7,7 +7,7 @@ #![cfg(all(feature = "argon2", not(miri)))] use proptest::{prelude::*, test_runner::Config as ProptestConfig}; -use rscrypto::{Argon2Params, Argon2Version, Argon2d, Argon2i, Argon2id}; +use rscrypto::{Argon2Params, Argon2d, Argon2i, Argon2id}; /// Number of proptest cases. Argon2 hashing is expensive (one case ≈ 1-100ms /// at the cost knobs we sweep), so we cap fast iterative dev runs at 16 and @@ -42,44 +42,23 @@ fn oracle_hash( } fn rs_hash_id(password: &[u8], salt: &[u8], m_kib: u32, t: u32, p: u32, out_len: usize) -> Vec { - let params = Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(p) - .output_len(out_len as u32) - .version(Argon2Version::V0x13) - .build() - .unwrap(); + let params = Argon2Params::new(m_kib, t, p).unwrap(); let mut out = vec![0u8; out_len]; - Argon2id::hash(¶ms, password, salt, &mut out).unwrap(); + Argon2id::derive(¶ms, password, salt, &mut out).unwrap(); out } fn rs_hash_d(password: &[u8], salt: &[u8], m_kib: u32, t: u32, p: u32, out_len: usize) -> Vec { - let params = Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(p) - .output_len(out_len as u32) - .version(Argon2Version::V0x13) - .build() - .unwrap(); + let params = Argon2Params::new(m_kib, t, p).unwrap(); let mut out = vec![0u8; out_len]; - Argon2d::hash(¶ms, password, salt, &mut out).unwrap(); + Argon2d::derive(¶ms, password, salt, &mut out).unwrap(); out } fn rs_hash_i(password: &[u8], salt: &[u8], m_kib: u32, t: u32, p: u32, out_len: usize) -> Vec { - let params = Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(p) - .output_len(out_len as u32) - .version(Argon2Version::V0x13) - .build() - .unwrap(); + let params = Argon2Params::new(m_kib, t, p).unwrap(); let mut out = vec![0u8; out_len]; - Argon2i::hash(¶ms, password, salt, &mut out).unwrap(); + Argon2i::derive(¶ms, password, salt, &mut out).unwrap(); out } @@ -140,15 +119,10 @@ proptest! { p in 1u32..=2, ) { prop_assume!(m >= 8 * p); - let params = Argon2Params::new() - .memory_cost_kib(m) - .time_cost(t) - .parallelism(p) - .output_len(32) - .build() + let params = Argon2Params::new(m, t, p) .unwrap(); let mut hash = [0u8; 32]; - Argon2id::hash(¶ms, &password, &salt, &mut hash).unwrap(); + Argon2id::derive(¶ms, &password, &salt, &mut hash).unwrap(); prop_assert!(Argon2id::verify(¶ms, &password, &salt, &hash).is_ok()); } } @@ -199,17 +173,11 @@ fn argon2id_p8_matches_oracle() { /// — see the doc comment on `verify` in `src/auth/argon2/mod.rs`. #[test] fn argon2id_verify_rejects_length_mismatch() { - let params = Argon2Params::new() - .memory_cost_kib(8) - .time_cost(1) - .parallelism(1) - .output_len(32) - .build() - .unwrap(); + let params = Argon2Params::new(8, 1, 1).unwrap(); let password = b"pw"; let salt = b"saltsalt"; let mut hash = [0u8; 32]; - Argon2id::hash(¶ms, password, salt, &mut hash).unwrap(); + Argon2id::derive(¶ms, password, salt, &mut hash).unwrap(); // Sanity: correct length verifies. assert!(Argon2id::verify(¶ms, password, salt, &hash).is_ok()); @@ -232,17 +200,11 @@ fn argon2id_verify_rejects_length_mismatch() { #[test] fn argon2id_verify_rejects_byte_flip_at_every_position() { - let params = Argon2Params::new() - .memory_cost_kib(32) - .time_cost(2) - .parallelism(1) - .output_len(32) - .build() - .unwrap(); + let params = Argon2Params::new(32, 2, 1).unwrap(); let password = b"correct horse battery staple"; let salt = b"random-salt-1234"; let mut hash = [0u8; 32]; - Argon2id::hash(¶ms, password, salt, &mut hash).unwrap(); + Argon2id::derive(¶ms, password, salt, &mut hash).unwrap(); for pos in 0..hash.len() { let mut tampered = hash; @@ -253,33 +215,3 @@ fn argon2id_verify_rejects_byte_flip_at_every_position() { ); } } - -#[cfg(feature = "phc-strings")] -mod phc_roundtrip { - use super::*; - - proptest! { - #![proptest_config(ProptestConfig::with_cases(8))] - - #[test] - fn argon2id_phc_roundtrip_verifies( - password in proptest::collection::vec(any::(), 1..48), - salt in proptest::collection::vec(any::(), 8..32), - m in proptest::sample::select(vec![8u32, 16, 32]), - t in 1u32..=2, - p in 1u32..=2, - ) { - prop_assume!(m >= 8 * p); - let params = Argon2Params::new() - .memory_cost_kib(m) - .time_cost(t) - .parallelism(p) - .output_len(32) - .build() - .unwrap(); - let encoded = Argon2id::hash_string_with_salt(¶ms, &password, &salt).unwrap(); - // This property intentionally varies policy-controlled PHC costs. - prop_assert!(Argon2id::verify_string_unbounded(&password, &encoded).is_ok()); - } - } -} diff --git a/tests/argon2_kernels.rs b/tests/argon2_kernels.rs index 6c022afc..99d8dd1b 100644 --- a/tests/argon2_kernels.rs +++ b/tests/argon2_kernels.rs @@ -16,19 +16,12 @@ #![allow(clippy::unwrap_used)] use rscrypto::{ - Argon2Params, Argon2Version, + Argon2Params, auth::{argon2, argon2::Argon2Variant}, }; -fn params(m_kib: u32, t: u32, p: u32, out_len: u32) -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(p) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() - .unwrap() +fn params(m_kib: u32, t: u32, p: u32, _out_len: u32) -> Argon2Params { + Argon2Params::new(m_kib, t, p).unwrap() } const PASSWORD: &[u8] = b"correct horse battery staple"; diff --git a/tests/argon2_miri.rs b/tests/argon2_miri.rs index 3a9aba90..aad94811 100644 --- a/tests/argon2_miri.rs +++ b/tests/argon2_miri.rs @@ -25,24 +25,17 @@ #![cfg(feature = "argon2")] -use rscrypto::{Argon2Params, Argon2Version, Argon2id}; +use rscrypto::{Argon2Params, Argon2id}; /// Single deterministic hash with the cheapest valid Argon2id config. Goal /// is exclusively memory-safety scrutiny under Miri; the actual hash output /// is irrelevant — we only care that hashing completes without UB. #[test] fn argon2id_minimal_p1_no_ub() { - let params = Argon2Params::new() - .memory_cost_kib(8) // RFC 9106 lower bound - .time_cost(1) - .parallelism(1) - .output_len(4) - .version(Argon2Version::V0x13) - .build() - .expect("minimal params are valid"); + let params = Argon2Params::new(8, 1, 1).expect("minimal params are valid"); let mut out = [0u8; 4]; - Argon2id::hash(¶ms, b"pw", b"abcdefgh", &mut out).expect("Argon2id hash must succeed"); + Argon2id::derive(¶ms, b"pw", b"abcdefgh", &mut out).expect("Argon2id hash must succeed"); // Sanity: the output is non-zero (Argon2id can't produce all-zero from // a valid password+salt with overwhelming probability — if this fails @@ -55,17 +48,10 @@ fn argon2id_minimal_p1_no_ub() { /// to hand out per-lane mutable borrows. #[test] fn argon2id_minimal_p2_no_ub() { - let params = Argon2Params::new() - .memory_cost_kib(16) // ≥ 8·p = 16 - .time_cost(1) - .parallelism(2) - .output_len(4) - .version(Argon2Version::V0x13) - .build() - .expect("p=2 params are valid"); + let params = Argon2Params::new(16, 1, 2).expect("p=2 params are valid"); let mut out = [0u8; 4]; - Argon2id::hash(¶ms, b"pw", b"abcdefgh", &mut out).expect("p=2 hash must succeed"); + Argon2id::derive(¶ms, b"pw", b"abcdefgh", &mut out).expect("p=2 hash must succeed"); assert_ne!(out, [0u8; 4]); } @@ -73,16 +59,10 @@ fn argon2id_minimal_p2_no_ub() { /// Verify path — exercises the `H'` expansion + constant-time tag compare. #[test] fn argon2id_minimal_verify_no_ub() { - let params = Argon2Params::new() - .memory_cost_kib(8) - .time_cost(1) - .parallelism(1) - .output_len(4) - .build() - .expect("minimal params are valid"); + let params = Argon2Params::new(8, 1, 1).expect("minimal params are valid"); let mut hash = [0u8; 4]; - Argon2id::hash(¶ms, b"correct", b"saltsalt", &mut hash).unwrap(); + Argon2id::derive(¶ms, b"correct", b"saltsalt", &mut hash).unwrap(); assert!(Argon2id::verify(¶ms, b"correct", b"saltsalt", &hash).is_ok()); assert!(Argon2id::verify(¶ms, b"wrong", b"saltsalt", &hash).is_err()); diff --git a/tests/argon2_parallel.rs b/tests/argon2_parallel.rs index bb33bda6..99eb5f2a 100644 --- a/tests/argon2_parallel.rs +++ b/tests/argon2_parallel.rs @@ -10,17 +10,10 @@ //! high-lane configs to exercise the real parallel path. #![cfg(all(feature = "argon2", feature = "parallel", not(miri)))] -use rscrypto::{Argon2Params, Argon2Version, Argon2d, Argon2i, Argon2id}; - -fn rs_params(m_kib: u32, t: u32, p: u32, out_len: u32) -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(m_kib) - .time_cost(t) - .parallelism(p) - .output_len(out_len) - .version(Argon2Version::V0x13) - .build() - .unwrap() +use rscrypto::{Argon2Params, Argon2d, Argon2i, Argon2id}; + +fn rs_params(m_kib: u32, t: u32, p: u32, _out_len: u32) -> Argon2Params { + Argon2Params::new(m_kib, t, p).unwrap() } fn oracle_hash( @@ -55,7 +48,7 @@ fn argon2id_p8_matches_oracle() { let params = rs_params(m, t, p, out_len as u32); let mut actual = vec![0u8; out_len]; - Argon2id::hash(¶ms, PASSWORD, SALT, &mut actual).unwrap(); + Argon2id::derive(¶ms, PASSWORD, SALT, &mut actual).unwrap(); let expected = oracle_hash(argon2::Algorithm::Argon2id, PASSWORD, SALT, m, t, p, out_len); assert_eq!(actual, expected, "argon2id p=8 mismatch vs RustCrypto oracle"); @@ -70,7 +63,7 @@ fn argon2d_p8_matches_oracle() { let params = rs_params(m, t, p, out_len as u32); let mut actual = vec![0u8; out_len]; - Argon2d::hash(¶ms, PASSWORD, SALT, &mut actual).unwrap(); + Argon2d::derive(¶ms, PASSWORD, SALT, &mut actual).unwrap(); let expected = oracle_hash(argon2::Algorithm::Argon2d, PASSWORD, SALT, m, t, p, out_len); assert_eq!(actual, expected, "argon2d p=8 mismatch vs RustCrypto oracle"); @@ -85,7 +78,7 @@ fn argon2i_p8_matches_oracle() { let params = rs_params(m, t, p, out_len as u32); let mut actual = vec![0u8; out_len]; - Argon2i::hash(¶ms, PASSWORD, SALT, &mut actual).unwrap(); + Argon2i::derive(¶ms, PASSWORD, SALT, &mut actual).unwrap(); let expected = oracle_hash(argon2::Algorithm::Argon2i, PASSWORD, SALT, m, t, p, out_len); assert_eq!(actual, expected, "argon2i p=8 mismatch vs RustCrypto oracle"); @@ -100,7 +93,7 @@ fn argon2id_p16_matches_oracle() { let params = rs_params(m, t, p, out_len as u32); let mut actual = vec![0u8; out_len]; - Argon2id::hash(¶ms, PASSWORD, SALT, &mut actual).unwrap(); + Argon2id::derive(¶ms, PASSWORD, SALT, &mut actual).unwrap(); let expected = oracle_hash(argon2::Algorithm::Argon2id, PASSWORD, SALT, m, t, p, out_len); assert_eq!(actual, expected, "argon2id p=16 mismatch vs RustCrypto oracle"); @@ -115,11 +108,11 @@ fn argon2id_parallel_is_deterministic() { let params = rs_params(1024, 2, 8, 32); let mut reference = [0u8; 32]; - Argon2id::hash(¶ms, PASSWORD, SALT, &mut reference).unwrap(); + Argon2id::derive(¶ms, PASSWORD, SALT, &mut reference).unwrap(); for trial in 0..16 { let mut out = [0u8; 32]; - Argon2id::hash(¶ms, PASSWORD, SALT, &mut out).unwrap(); + Argon2id::derive(¶ms, PASSWORD, SALT, &mut out).unwrap(); assert_eq!(out, reference, "non-deterministic output on trial {trial}"); } } @@ -129,11 +122,11 @@ fn argon2d_parallel_is_deterministic() { let params = rs_params(1024, 2, 8, 32); let mut reference = [0u8; 32]; - Argon2d::hash(¶ms, PASSWORD, SALT, &mut reference).unwrap(); + Argon2d::derive(¶ms, PASSWORD, SALT, &mut reference).unwrap(); for trial in 0..16 { let mut out = [0u8; 32]; - Argon2d::hash(¶ms, PASSWORD, SALT, &mut out).unwrap(); + Argon2d::derive(¶ms, PASSWORD, SALT, &mut out).unwrap(); assert_eq!(out, reference, "non-deterministic output on trial {trial}"); } } @@ -151,7 +144,7 @@ fn argon2id_p1_fast_path_matches_oracle() { let params = rs_params(m, t, p, out_len as u32); let mut actual = vec![0u8; out_len]; - Argon2id::hash(¶ms, PASSWORD, SALT, &mut actual).unwrap(); + Argon2id::derive(¶ms, PASSWORD, SALT, &mut actual).unwrap(); let expected = oracle_hash(argon2::Algorithm::Argon2id, PASSWORD, SALT, m, t, p, out_len); assert_eq!(actual, expected, "argon2id p=1 fast-path mismatch vs oracle"); @@ -161,7 +154,7 @@ fn argon2id_p1_fast_path_matches_oracle() { fn argon2id_parallel_verify_round_trip() { let params = rs_params(256, 2, 8, 32); let mut hash = [0u8; 32]; - Argon2id::hash(¶ms, PASSWORD, SALT, &mut hash).unwrap(); + Argon2id::derive(¶ms, PASSWORD, SALT, &mut hash).unwrap(); assert!(Argon2id::verify(¶ms, PASSWORD, SALT, &hash).is_ok()); assert!(Argon2id::verify(¶ms, b"wrong-password-zzzzzzzzzzzzzzzzzz", SALT, &hash).is_err()); diff --git a/tests/argon2_vectors.rs b/tests/argon2_vectors.rs index 17a206aa..e7fafc3f 100644 --- a/tests/argon2_vectors.rs +++ b/tests/argon2_vectors.rs @@ -6,19 +6,14 @@ #![cfg(all(feature = "argon2", not(miri)))] -use rscrypto::{Argon2Params, Argon2Version, Argon2d, Argon2i, Argon2id}; +use rscrypto::{Argon2Context, Argon2Params, Argon2d, Argon2i, Argon2id}; fn canonical_params() -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(32) - .time_cost(3) - .parallelism(4) - .output_len(32) - .version(Argon2Version::V0x13) - .secret(&[0x03u8; 8]) - .associated_data(&[0x04u8; 12]) - .build() - .expect("RFC 9106 canonical parameters are valid") + Argon2Params::new(32, 3, 4).expect("RFC 9106 canonical parameters are valid") +} + +fn canonical_context() -> Argon2Context<'static> { + Argon2Context::new(&[0x03; 8], &[0x04; 12]) } const RFC_PASSWORD: &[u8] = &[0x01u8; 32]; @@ -31,7 +26,14 @@ fn rfc9106_appendix_a1_argon2d() { 0xbe, 0x39, 0x84, 0xf3, 0xc1, 0xa1, 0x3a, 0x4d, 0xb9, 0xfa, 0xbe, 0x4a, 0xcb, ]; let mut out = [0u8; 32]; - Argon2d::hash(&canonical_params(), RFC_PASSWORD, RFC_SALT, &mut out).unwrap(); + Argon2d::derive_with_context( + &canonical_params(), + canonical_context(), + RFC_PASSWORD, + RFC_SALT, + &mut out, + ) + .unwrap(); assert_eq!(out, expected); } @@ -42,7 +44,14 @@ fn rfc9106_appendix_a2_argon2i() { 0x01, 0x6d, 0xd3, 0x88, 0xd2, 0x99, 0x52, 0xa4, 0xc4, 0x67, 0x2b, 0x6c, 0xe8, ]; let mut out = [0u8; 32]; - Argon2i::hash(&canonical_params(), RFC_PASSWORD, RFC_SALT, &mut out).unwrap(); + Argon2i::derive_with_context( + &canonical_params(), + canonical_context(), + RFC_PASSWORD, + RFC_SALT, + &mut out, + ) + .unwrap(); assert_eq!(out, expected); } @@ -53,7 +62,14 @@ fn rfc9106_appendix_a3_argon2id() { 0x45, 0x2d, 0x75, 0xb6, 0x5e, 0xb5, 0x25, 0x20, 0xe9, 0x6b, 0x01, 0xe6, 0x59, ]; let mut out = [0u8; 32]; - Argon2id::hash(&canonical_params(), RFC_PASSWORD, RFC_SALT, &mut out).unwrap(); + Argon2id::derive_with_context( + &canonical_params(), + canonical_context(), + RFC_PASSWORD, + RFC_SALT, + &mut out, + ) + .unwrap(); assert_eq!(out, expected); } @@ -63,9 +79,12 @@ fn all_three_variants_produce_distinct_output() { // must produce three different 32-byte tags (reference-indexing modes // diverge). let params = canonical_params(); - let d = Argon2d::hash_array::<32>(¶ms, RFC_PASSWORD, RFC_SALT).unwrap(); - let i = Argon2i::hash_array::<32>(¶ms, RFC_PASSWORD, RFC_SALT).unwrap(); - let id = Argon2id::hash_array::<32>(¶ms, RFC_PASSWORD, RFC_SALT).unwrap(); + let mut d = [0u8; 32]; + let mut i = [0u8; 32]; + let mut id = [0u8; 32]; + Argon2d::derive_with_context(¶ms, canonical_context(), RFC_PASSWORD, RFC_SALT, &mut d).unwrap(); + Argon2i::derive_with_context(¶ms, canonical_context(), RFC_PASSWORD, RFC_SALT, &mut i).unwrap(); + Argon2id::derive_with_context(¶ms, canonical_context(), RFC_PASSWORD, RFC_SALT, &mut id).unwrap(); assert_ne!(d, i); assert_ne!(d, id); assert_ne!(i, id); diff --git a/tests/phc_roundtrip.rs b/tests/phc_roundtrip.rs index aa6e5757..115f1384 100644 --- a/tests/phc_roundtrip.rs +++ b/tests/phc_roundtrip.rs @@ -1,309 +1,198 @@ -//! PHC string-format roundtrip and rejection coverage. -//! -//! Two-table strategy: -//! -//! - `valid` cases: produce a PHC string via `hash_string_with_salt`, decode it with -//! `decode_string`, re-encode the decoded components, and assert byte equality. Catches -//! non-canonical formatting drift (e.g. leading zeros, unstable param ordering). -//! - `invalid` cases: feed crafted strings to `verify_string` / `decode_string` and assert the -//! exact `PhcError` variant. Catches parser regressions where a malformed input is silently -//! accepted. -//! -//! Direct PHC parser unit tests live alongside the encoder/decoder in -//! `src/auth/phc.rs`; this file focuses on the round-trip property and the -//! cross-cutting interaction between `phc::parse`, the per-algorithm -//! `decode_string` helpers, and `verify_string`. +//! Public password-record behavior and hostile-PHC regression tests. -#![cfg(all(feature = "phc-strings", any(feature = "argon2", feature = "scrypt"), not(miri)))] +#![cfg(all( + feature = "argon2", + feature = "scrypt", + feature = "phc-strings", + feature = "getrandom" +))] -use rscrypto::auth::phc::PhcError; +use core::{ + alloc::{GlobalAlloc, Layout}, + cell::Cell, +}; +use std::alloc::System; -// ─── Argon2 round-trip and rejection coverage ────────────────────────────── +use rscrypto::{Argon2Params, Argon2idPassword, PasswordStatus, ScryptParams, ScryptPassword}; -#[cfg(feature = "argon2")] -mod argon2 { - use rscrypto::{Argon2Params, Argon2Version, Argon2d, Argon2i, Argon2id}; +struct TrackingAllocator; - use super::*; - - fn cheap_params() -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(8) - .time_cost(1) - .parallelism(1) - .output_len(16) - .version(Argon2Version::V0x13) - .build() - .expect("cheap argon2 params are valid") - } - - /// Roundtrip: hash → encode → decode → re-encode produces the same string. - #[test] - fn argon2id_roundtrip_canonical() { - let params = cheap_params(); - let salt = [0x07u8; 16]; - let encoded = Argon2id::hash_string_with_salt(¶ms, b"password", &salt).expect("hash"); - - let (decoded_params, decoded_salt, decoded_hash) = Argon2id::decode_string(&encoded).expect("decode"); - let re_encoded = argon2_re_encode("argon2id", &decoded_params, &decoded_salt, &decoded_hash).expect("re-encode"); - - assert_eq!(encoded, re_encoded, "PHC encoding is not canonical"); - assert!(Argon2id::verify_string(b"password", &encoded).is_ok()); - } - - #[test] - fn argon2d_roundtrip_canonical() { - let params = cheap_params(); - let salt = [0xA5u8; 16]; - let encoded = Argon2d::hash_string_with_salt(¶ms, b"pw", &salt).expect("hash"); - - let (decoded_params, decoded_salt, decoded_hash) = Argon2d::decode_string(&encoded).expect("decode"); - let re_encoded = argon2_re_encode("argon2d", &decoded_params, &decoded_salt, &decoded_hash).expect("re-encode"); - - assert_eq!(encoded, re_encoded); - } - - #[test] - fn argon2i_roundtrip_canonical() { - let params = cheap_params(); - let salt = [0x5Au8; 16]; - let encoded = Argon2i::hash_string_with_salt(¶ms, b"pw", &salt).expect("hash"); - - let (decoded_params, decoded_salt, decoded_hash) = Argon2i::decode_string(&encoded).expect("decode"); - let re_encoded = argon2_re_encode("argon2i", &decoded_params, &decoded_salt, &decoded_hash).expect("re-encode"); - - assert_eq!(encoded, re_encoded); - } - - /// Encode a freshly-decoded `(params, salt, hash)` back to PHC. Mirrors the - /// internal `phc_integration::encode_string` semantics — but goes through - /// the public surface (`hash_string_with_salt` returns the canonical form). - fn argon2_re_encode( - algorithm: &str, - params: &Argon2Params, - salt: &[u8], - hash: &[u8], - ) -> Result { - // We can't re-run the hash (different password would diverge). Instead - // we construct the canonical string manually using the same conventions - // as `phc_integration::encode_string`: standard base64 (no padding), - // params in `m=…,t=…,p=…` order, version preceding params. - let mut out = String::new(); - out.push('$'); - out.push_str(algorithm); - out.push_str("$v="); - let version_u32 = match params.get_version() { - rscrypto::Argon2Version::V0x10 => 0x10u32, - rscrypto::Argon2Version::V0x13 => 0x13u32, - // `Argon2Version` is `#[non_exhaustive]`; new variants are an error here. - _ => return Err("unknown Argon2Version"), - }; - out.push_str(&version_u32.to_string()); - out.push_str("$m="); - out.push_str(¶ms.get_memory_cost_kib().to_string()); - out.push_str(",t="); - out.push_str(¶ms.get_time_cost().to_string()); - out.push_str(",p="); - out.push_str(¶ms.get_parallelism().to_string()); - out.push('$'); - out.push_str(&phc_b64_no_pad(salt)); - out.push('$'); - out.push_str(&phc_b64_no_pad(hash)); - Ok(out) - } +thread_local! { + static TRACK_ALLOCATIONS: Cell = const { Cell::new(false) }; + static ALLOCATION_COUNT: Cell = const { Cell::new(0) }; +} - /// Strict PHC variant of base64 (RFC 4648 standard alphabet, no padding). - fn phc_b64_no_pad(bytes: &[u8]) -> String { - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(bytes.len() * 4 / 3 + 4); - let triples = bytes.len() / 3; - for i in 0..triples { - let off = i * 3; - let b0 = bytes[off] as u32; - let b1 = bytes[off + 1] as u32; - let b2 = bytes[off + 2] as u32; - let w = (b0 << 16) | (b1 << 8) | b2; - s.push(TABLE[((w >> 18) & 0x3F) as usize] as char); - s.push(TABLE[((w >> 12) & 0x3F) as usize] as char); - s.push(TABLE[((w >> 6) & 0x3F) as usize] as char); - s.push(TABLE[(w & 0x3F) as usize] as char); - } - let off = triples * 3; - match bytes.len() - off { - 0 => {} - 1 => { - let b0 = bytes[off] as u32; - s.push(TABLE[((b0 >> 2) & 0x3F) as usize] as char); - s.push(TABLE[((b0 << 4) & 0x3F) as usize] as char); - } - 2 => { - let b0 = bytes[off] as u32; - let b1 = bytes[off + 1] as u32; - let w = (b0 << 8) | b1; - s.push(TABLE[((w >> 10) & 0x3F) as usize] as char); - s.push(TABLE[((w >> 4) & 0x3F) as usize] as char); - s.push(TABLE[((w << 2) & 0x3F) as usize] as char); - } - _ => unreachable!(), +fn record_allocation() { + TRACK_ALLOCATIONS.with(|tracking| { + if tracking.get() { + ALLOCATION_COUNT.with(|count| count.set(count.get().strict_add(1))); } - s - } - - /// Cross-variant rejection: an Argon2id-encoded string must not verify - /// against Argon2d / Argon2i. - #[test] - fn cross_variant_rejection() { - let params = cheap_params(); - let encoded_id = Argon2id::hash_string_with_salt(¶ms, b"x", &[0u8; 16]).unwrap(); - - assert!(Argon2d::verify_string(b"x", &encoded_id).is_err()); - assert!(Argon2i::verify_string(b"x", &encoded_id).is_err()); + }); +} - assert_eq!( - Argon2d::decode_string(&encoded_id).unwrap_err(), - PhcError::AlgorithmMismatch - ); - assert_eq!( - Argon2i::decode_string(&encoded_id).unwrap_err(), - PhcError::AlgorithmMismatch - ); +// SAFETY: TrackingAllocator delegates every operation to System with the +// exact pointer and layout supplied by the caller. It adds only thread-local +// accounting after successful allocation and never changes allocator results. +unsafe impl GlobalAlloc for TrackingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: + // 1. layout is supplied by the GlobalAlloc caller. + // 2. System is the backing allocator for every pointer returned here. + // 3. The returned pointer is forwarded unchanged. + let pointer = unsafe { System.alloc(layout) }; + if !pointer.is_null() { + record_allocation(); + } + pointer } - /// Decoder rejection table — exact `PhcError` variant per malformed input. - #[test] - fn invalid_inputs_yield_specific_errors() { - let cases: &[(&str, PhcError)] = &[ - ("", PhcError::MalformedInput), - ("argon2id$v=19$m=8,t=1,p=1$YWE$aGFzaA", PhcError::MalformedInput), - ("$argon2xx$v=19$m=8,t=1,p=1$YWE$aGFzaA", PhcError::AlgorithmMismatch), - // Missing required `m` parameter. - ("$argon2id$v=19$t=1,p=1$YWE$aGFzaA", PhcError::MissingParam), - // Duplicate `m`. - ("$argon2id$v=19$m=8,m=16,t=1,p=1$YWE$aGFzaA", PhcError::DuplicateParam), - // Unknown parameter. - ("$argon2id$v=19$m=8,t=1,p=1,x=1$YWE$aGFzaA", PhcError::UnknownParam), - // Leading zero on numeric value. - ("$argon2id$v=19$m=08,t=1,p=1$YWE$aGFzaA", PhcError::MalformedParams), - // Unsupported version. - ("$argon2id$v=99$m=8,t=1,p=1$YWE$aGFzaA", PhcError::UnsupportedVersion), - // Empty salt segment. - ("$argon2id$v=19$m=8,t=1,p=1$$aGFzaA", PhcError::EmptySegment), - // Salt too short for Argon2 (decoded < 8 bytes). - ("$argon2id$v=19$m=8,t=1,p=1$YQ$aGFzaGhhc2g", PhcError::InvalidLength), - // Bad base64 character in salt. - ("$argon2id$v=19$m=8,t=1,p=1$YWE!YWE$aGFzaA", PhcError::InvalidBase64), - // Padding is not part of PHC base64. - ("$argon2id$v=19$m=8,t=1,p=1$YWE=$aGFzaA", PhcError::InvalidBase64), - // One-character base64 tails are structurally impossible. - ("$argon2id$v=19$m=8,t=1,p=1$A$aGFzaA", PhcError::InvalidBase64), - // Extra segments must not be ignored. - ( - "$argon2id$v=19$m=8,t=1,p=1$YWFhYWFhYWE$aGFzaA$ignored", - PhcError::MalformedInput, - ), - ]; - - for (input, expected) in cases { - match Argon2id::decode_string(input) { - Ok(_) => panic!("expected {expected:?} for {input:?}, got Ok(_)"), - Err(actual) => assert_eq!( - actual, *expected, - "wrong error for {input:?}: expected {expected:?}, got {actual:?}" - ), - } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + // SAFETY: + // 1. layout is supplied by the GlobalAlloc caller. + // 2. System is the backing allocator for every pointer returned here. + // 3. The returned pointer is forwarded unchanged. + let pointer = unsafe { System.alloc_zeroed(layout) }; + if !pointer.is_null() { + record_allocation(); } + pointer } - /// Tampering by flipping one byte in the encoded hash must reject. - #[test] - fn single_bit_tamper_rejected() { - let params = cheap_params(); - let encoded = Argon2id::hash_string_with_salt(¶ms, b"pw", &[0x77u8; 16]).unwrap(); - - let mut bytes: Vec = encoded.into_bytes(); - let last = bytes.len() - 1; - // Flip the final base64 char to a different valid char. - bytes[last] = match bytes[last] { - b'A' => b'B', - b'/' => b'+', - _ => b'A', - }; - let tampered = String::from_utf8(bytes).unwrap(); - assert!(Argon2id::verify_string(b"pw", &tampered).is_err()); + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + // SAFETY: + // 1. Every pointer returned by this allocator comes from System. + // 2. The caller must provide the original allocation layout. + // 3. Neither pointer nor layout is changed before delegation. + unsafe { System.dealloc(pointer, layout) }; } - /// Excess length must be rejected with a stable error (not a panic, not OK). - #[test] - fn oversize_input_rejected() { - let big = "$argon2id$v=19$m=8,t=1,p=1$YWE$".to_string() + &"A".repeat(8192); - assert_eq!(Argon2id::decode_string(&big).unwrap_err(), PhcError::InputTooLong); + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + // SAFETY: + // 1. Every pointer returned by this allocator comes from System. + // 2. The caller supplies its current layout and requested new size. + // 3. All arguments and the returned pointer are forwarded unchanged. + let replacement = unsafe { System.realloc(pointer, layout, new_size) }; + if !replacement.is_null() { + record_allocation(); + } + replacement } } -// ─── scrypt round-trip and rejection coverage ────────────────────────────── - -#[cfg(feature = "scrypt")] -mod scrypt { - use rscrypto::{Scrypt, ScryptParams}; - - use super::*; +#[global_allocator] +static ALLOCATOR: TrackingAllocator = TrackingAllocator; + +fn allocations_during(operation: impl FnOnce() -> T) -> (T, usize) { + TRACK_ALLOCATIONS.with(|tracking| tracking.set(false)); + ALLOCATION_COUNT.with(|count| count.set(0)); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(true)); + let result = operation(); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(false)); + let allocations = ALLOCATION_COUNT.with(Cell::get); + (result, allocations) +} - fn cheap_params() -> ScryptParams { - ScryptParams::new() - .log_n(8) - .r(1) - .p(1) - .output_len(16) - .build() - .expect("cheap scrypt params are valid") - } +#[test] +fn generated_password_records_are_canonical_and_self_verifying() { + let argon2 = Argon2idPassword::new(Argon2Params::new(32, 2, 1).unwrap()).unwrap(); + let argon2_record = argon2.hash_password(b"correct horse battery staple").unwrap(); + assert!(argon2_record.starts_with("$argon2id$v=19$m=32,t=2,p=1$")); + assert_eq!( + argon2.verify_password(b"correct horse battery staple", &argon2_record), + Ok(PasswordStatus::Current) + ); + assert!(argon2.verify_password(b"wrong", &argon2_record).is_err()); + + let scrypt = ScryptPassword::new(ScryptParams::new(4, 1, 1).unwrap()).unwrap(); + let scrypt_record = scrypt.hash_password(b"correct horse battery staple").unwrap(); + assert!(scrypt_record.starts_with("$scrypt$ln=4,r=1,p=1$")); + assert_eq!( + scrypt.verify_password(b"correct horse battery staple", &scrypt_record), + Ok(PasswordStatus::Current) + ); + assert!(scrypt.verify_password(b"wrong", &scrypt_record).is_err()); +} - #[test] - fn scrypt_roundtrip_canonical() { - let params = cheap_params(); - let salt = [0x33u8; 16]; - let encoded = Scrypt::hash_string_with_salt(¶ms, b"password", &salt).expect("hash"); +#[test] +fn verifier_reports_accepted_stale_profiles() { + let old_argon2 = Argon2idPassword::new(Argon2Params::new(32, 2, 1).unwrap()).unwrap(); + let argon2_record = old_argon2.hash_password(b"password").unwrap(); + let current_argon2 = Argon2idPassword::new(Argon2Params::new(40, 2, 1).unwrap()).unwrap(); + assert_eq!( + current_argon2.verify_password(b"password", &argon2_record), + Ok(PasswordStatus::NeedsRehash) + ); + + let old_scrypt = ScryptPassword::new(ScryptParams::new(4, 1, 1).unwrap()).unwrap(); + let scrypt_record = old_scrypt.hash_password(b"password").unwrap(); + let current_scrypt = ScryptPassword::new(ScryptParams::new(5, 1, 1).unwrap()).unwrap(); + assert_eq!( + current_scrypt.verify_password(b"password", &scrypt_record), + Ok(PasswordStatus::NeedsRehash) + ); +} - // Just verify decode succeeds and verify_string accepts the result; the - // canonical-form invariant is exercised by the Argon2 path (same encoder). - let (_decoded_params, _decoded_salt, _decoded_hash) = Scrypt::decode_string(&encoded).expect("decode"); - assert!(Scrypt::verify_string(b"password", &encoded).is_ok()); - assert!(Scrypt::verify_string(b"wrong", &encoded).is_err()); +#[test] +fn every_rejected_phc_class_allocates_nothing() { + let argon2 = Argon2idPassword::new(Argon2Params::new(32, 2, 1).unwrap()).unwrap(); + let oversized = "x".repeat(1_025); + let argon2_rejections = [ + oversized.as_str(), + "not-a-phc-record", + "$scrypt$ln=4,r=1,p=1$*$*", + "$argon2id$v=19$t=2,m=32,p=1$*$*", + "$argon2id$v=19$m=32,m=2,p=1$*$*", + "$argon2id$v=19$m=42949672960,t=2,p=1$*$*", + "$argon2id$v=19$m=40,t=2,p=1$*$*", + "$argon2id$v=19$m=32,t=2,p=1$**********************$*******************************************", + ]; + for encoded in argon2_rejections { + let (result, allocations) = allocations_during(|| argon2.verify_password(b"password", encoded)); + assert!(result.is_err(), "rejected Argon2 PHC: {encoded}"); + assert_eq!(allocations, 0, "rejected Argon2 PHC allocated: {encoded}"); } - #[test] - fn scrypt_invalid_inputs_yield_specific_errors() { - let cases: &[(&str, PhcError)] = &[ - ("", PhcError::MalformedInput), - // Wrong algorithm. - ("$bcrypt$ln=8,r=1,p=1$YWE$aGFzaA", PhcError::AlgorithmMismatch), - // Missing `ln` (PHC scrypt mandates ln, r, p). - ("$scrypt$r=1,p=1$YWE$aGFzaA", PhcError::MissingParam), - // Duplicate r. - ("$scrypt$ln=8,r=1,r=2,p=1$YWE$aGFzaA", PhcError::DuplicateParam), - // Unknown parameter. - ("$scrypt$ln=8,r=1,p=1,extra=1$YWE$aGFzaA", PhcError::UnknownParam), - // Bad base64 in hash. - ("$scrypt$ln=8,r=1,p=1$YWE$aGFz#A", PhcError::InvalidBase64), - // Non-canonical numeric params are rejected, not normalized. - ("$scrypt$ln=08,r=1,p=1$YWE$aGFzaA", PhcError::MalformedParams), - // Padding is not part of PHC base64. - ("$scrypt$ln=8,r=1,p=1$YWE=$aGFzaA", PhcError::InvalidBase64), - // Extra segments must not be ignored. - ("$scrypt$ln=8,r=1,p=1$YWE$aGFzaA$ignored", PhcError::MalformedInput), - ]; - - for (input, expected) in cases { - match Scrypt::decode_string(input) { - Ok(_) => panic!("expected {expected:?} for {input:?}, got Ok(_)"), - Err(actual) => assert_eq!(actual, *expected, "wrong error for {input:?}"), - } - } + let scrypt = ScryptPassword::new(ScryptParams::new(4, 1, 1).unwrap()).unwrap(); + let scrypt_rejections = [ + oversized.as_str(), + "not-a-phc-record", + "$argon2id$v=19$m=32,t=2,p=1$*$*", + "$scrypt$r=1,ln=4,p=1$*$*", + "$scrypt$ln=4,ln=1,p=1$*$*", + "$scrypt$ln=42949672960,r=1,p=1$*$*", + "$scrypt$ln=5,r=1,p=1$*$*", + "$scrypt$ln=4,r=1,p=1$**********************$*******************************************", + ]; + for encoded in scrypt_rejections { + let (result, allocations) = allocations_during(|| scrypt.verify_password(b"password", encoded)); + assert!(result.is_err(), "rejected scrypt PHC: {encoded}"); + assert_eq!(allocations, 0, "rejected scrypt PHC allocated: {encoded}"); } +} - #[test] - fn scrypt_oversize_input_rejected() { - let big = "$scrypt$ln=8,r=1,p=1$YWE$".to_string() + &"A".repeat(8192); - assert_eq!(Scrypt::decode_string(&big).unwrap_err(), PhcError::InputTooLong); - } +#[test] +fn public_verifiers_reject_noncanonical_and_cross_algorithm_records() { + let argon2 = Argon2idPassword::new(Argon2Params::new(32, 2, 1).unwrap()).unwrap(); + let scrypt = ScryptPassword::new(ScryptParams::new(4, 1, 1).unwrap()).unwrap(); + let valid_argon2 = argon2.hash_password(b"password").unwrap(); + let valid_scrypt = scrypt.hash_password(b"password").unwrap(); + + assert!(argon2.verify_password(b"password", &valid_scrypt).is_err()); + assert!(scrypt.verify_password(b"password", &valid_argon2).is_err()); + assert!( + argon2 + .verify_password( + b"password", + "$argon2id$v=19$t=2,m=32,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ) + .is_err() + ); + assert!( + scrypt + .verify_password( + b"password", + "$scrypt$r=1,ln=4,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ) + .is_err() + ); } diff --git a/tests/scrypt_differential.rs b/tests/scrypt_differential.rs index 84793641..f02a4001 100644 --- a/tests/scrypt_differential.rs +++ b/tests/scrypt_differential.rs @@ -18,15 +18,9 @@ fn oracle_scrypt(password: &[u8], salt: &[u8], log_n: u8, r: u32, p: u32, out_le } fn rs_hash(password: &[u8], salt: &[u8], log_n: u8, r: u32, p: u32, out_len: usize) -> Vec { - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(out_len as u32) - .build() - .unwrap(); + let params = ScryptParams::new(log_n, r, p).unwrap(); let mut out = vec![0u8; out_len]; - Scrypt::hash(¶ms, password, salt, &mut out).unwrap(); + Scrypt::derive(¶ms, password, salt, &mut out).unwrap(); out } @@ -59,15 +53,10 @@ proptest! { r in 1u32..=2, p in 1u32..=2, ) { - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(32) - .build() + let params = ScryptParams::new(log_n, r, p) .unwrap(); let mut hash = [0u8; 32]; - Scrypt::hash(¶ms, &password, &salt, &mut hash).unwrap(); + Scrypt::derive(¶ms, &password, &salt, &mut hash).unwrap(); prop_assert!(Scrypt::verify(¶ms, &password, &salt, &hash).is_ok()); } } @@ -91,37 +80,25 @@ fn scrypt_minimal_log_n_matches_oracle() { fn scrypt_short_dklen_is_prefix_of_wide() { // PBKDF2-HMAC-SHA256's output is a concatenation of blocks; asking for // `N` bytes yields the first `N` bytes of the block stream. Therefore - // `Scrypt::hash(.., out_len=1)` must equal the first byte of - // `Scrypt::hash(.., out_len=64)` for the same inputs. Exercises the + // `Scrypt::derive(.., out_len=1)` must equal the first byte of + // `Scrypt::derive(.., out_len=64)` for the same inputs. Exercises the // `out_len=1` PBKDF2 single-byte tail that the oracle rejects. - let params_short = rscrypto::ScryptParams::new() - .log_n(6) - .r(2) - .p(1) - .output_len(1) - .build() - .unwrap(); - let params_wide = rscrypto::ScryptParams::new() - .log_n(6) - .r(2) - .p(1) - .output_len(64) - .build() - .unwrap(); + let params_short = rscrypto::ScryptParams::new(6, 2, 1).unwrap(); + let params_wide = rscrypto::ScryptParams::new(6, 2, 1).unwrap(); let mut short_out = [0u8; 1]; let mut wide_out = [0u8; 64]; - Scrypt::hash(¶ms_short, b"pw", b"salty-salty-salt", &mut short_out).unwrap(); - Scrypt::hash(¶ms_wide, b"pw", b"salty-salty-salt", &mut wide_out).unwrap(); + Scrypt::derive(¶ms_short, b"pw", b"salty-salty-salt", &mut short_out).unwrap(); + Scrypt::derive(¶ms_wide, b"pw", b"salty-salty-salt", &mut wide_out).unwrap(); assert_eq!(short_out[0], wide_out[0]); } #[test] fn scrypt_verify_rejects_byte_flip_at_every_position() { - let params = ScryptParams::new().log_n(6).r(2).p(1).output_len(32).build().unwrap(); + let params = ScryptParams::new(6, 2, 1).unwrap(); let password = b"correct horse battery staple"; let salt = b"random-salt-1234"; let mut hash = [0u8; 32]; - Scrypt::hash(¶ms, password, salt, &mut hash).unwrap(); + Scrypt::derive(¶ms, password, salt, &mut hash).unwrap(); for pos in 0..hash.len() { let mut tampered = hash; @@ -132,32 +109,3 @@ fn scrypt_verify_rejects_byte_flip_at_every_position() { ); } } - -#[cfg(feature = "phc-strings")] -mod phc_roundtrip { - use super::*; - - proptest! { - #![proptest_config(ProptestConfig::with_cases(8))] - - #[test] - fn scrypt_phc_roundtrip_verifies( - password in proptest::collection::vec(any::(), 1..48), - salt in proptest::collection::vec(any::(), 8..32), - log_n in 4u8..=6, - r in 1u32..=2, - p in 1u32..=2, - ) { - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(32) - .build() - .unwrap(); - let encoded = Scrypt::hash_string_with_salt(¶ms, &password, &salt).unwrap(); - // This property intentionally varies policy-controlled PHC costs. - prop_assert!(Scrypt::verify_string_unbounded(&password, &encoded).is_ok()); - } - } -} diff --git a/tests/scrypt_vectors.rs b/tests/scrypt_vectors.rs index a5e29006..00834c83 100644 --- a/tests/scrypt_vectors.rs +++ b/tests/scrypt_vectors.rs @@ -11,15 +11,9 @@ use rscrypto::{Scrypt, ScryptParams}; fn hash_64(password: &[u8], salt: &[u8], log_n: u8, r: u32, p: u32) -> [u8; 64] { - let params = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(64) - .build() - .expect("RFC 7914 parameters validate"); + let params = ScryptParams::new(log_n, r, p).expect("RFC 7914 parameters validate"); let mut out = [0u8; 64]; - Scrypt::hash(¶ms, password, salt, &mut out).expect("scrypt hash"); + Scrypt::derive(¶ms, password, salt, &mut out).expect("scrypt hash"); out } diff --git a/tools/ct-dudect/src/main.rs b/tools/ct-dudect/src/main.rs index 7c38b70c..4dbf5968 100644 --- a/tools/ct-dudect/src/main.rs +++ b/tools/ct-dudect/src/main.rs @@ -197,22 +197,12 @@ fn rsa_blinding_pair(key: &RsaPrivateKey) -> (Vec, Vec) { } fn argon2i_params() -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(32) - .time_cost(1) - .parallelism(1) - .output_len(16) - .build() + Argon2Params::new(32, 1, 1) .unwrap() } fn argon2i_parallel_params() -> Argon2Params { - Argon2Params::new() - .memory_cost_kib(512) - .time_cost(1) - .parallelism(4) - .output_len(16) - .build() + Argon2Params::new(512, 1, 4) .unwrap() } @@ -1759,7 +1749,7 @@ fn argon2i_fixed_vs_random_password(runner: &mut CtRunner, rng: &mut BenchRng) { for (class, password) in inputs { runner.run_one(class, || { let mut out = [0u8; 16]; - Argon2i::hash(¶ms, &password, &salt, &mut out).is_ok() + Argon2i::derive(¶ms, &password, &salt, &mut out).is_ok() }); } } @@ -1781,7 +1771,7 @@ fn argon2i_parallel_fixed_vs_random_password(runner: &mut CtRunner, rng: &mut Be for (class, password) in inputs { runner.run_one(class, || { let mut out = [0u8; 16]; - Argon2i::hash(¶ms, &password, &salt, &mut out).is_ok() + Argon2i::derive(¶ms, &password, &salt, &mut out).is_ok() }); } } diff --git a/tools/ct-harness/src/lib.rs b/tools/ct-harness/src/lib.rs index 822e667e..3d95b4fe 100644 --- a/tools/ct-harness/src/lib.rs +++ b/tools/ct-harness/src/lib.rs @@ -1120,16 +1120,11 @@ macro_rules! argon2_entry { let Some(out) = (unsafe { output_slice(out, out_len) }) else { return STATUS_ERR; }; - let Ok(params) = Argon2Params::new() - .memory_cost_kib(memory_cost_kib) - .time_cost(time_cost) - .output_len(out_len as u32) - .build() - else { + let Ok(params) = Argon2Params::new(memory_cost_kib, time_cost, 1) else { return STATUS_ERR; }; - <$ty>::hash(¶ms, password, salt, out) + <$ty>::derive(¶ms, password, salt, out) .map(|()| STATUS_OK) .unwrap_or(STATUS_ERR) } @@ -1157,12 +1152,7 @@ macro_rules! argon2_entry { let Some(expected) = (unsafe { input_slice(expected, expected_len) }) else { return STATUS_ERR; }; - let Ok(params) = Argon2Params::new() - .memory_cost_kib(memory_cost_kib) - .time_cost(time_cost) - .output_len(expected_len as u32) - .build() - else { + let Ok(params) = Argon2Params::new(memory_cost_kib, time_cost, 1) else { return STATUS_ERR; }; @@ -1201,12 +1191,7 @@ pub extern "C" fn ct_entry_scrypt_verify( let Some(expected) = (unsafe { input_slice(expected, expected_len) }) else { return STATUS_ERR; }; - let Ok(params) = ScryptParams::new() - .log_n(log_n) - .r(r) - .p(p) - .output_len(expected_len as u32) - .build() + let Ok(params) = ScryptParams::new(log_n, r, p) else { return STATUS_ERR; };