matter_crypto/pase/kdf.rs
1//! Key-derivation primitives for PASE.
2//!
3//! - [`validate_params`]: enforce Matter spec §3.10.3 bounds on
4//! iteration count and salt length.
5//! - [`derive_w0_w1`]: PBKDF2-HMAC-SHA256 to derive 80 bytes from the
6//! setup PIN, split into 40-byte w0 and 40-byte w1, then reduced mod
7//! the P-256 curve order q.
8//! - [`derive_l`]: `L = w1 · P`, where `P` is the P-256 generator.
9//! - [`hkdf_expand`]: thin wrapper around ring's HKDF-Expand used by
10//! `spake2plus.rs` for confirmation keys and session keys.
11//!
12//! # matter.js cross-reference
13//!
14//! All design choices below were verified against
15//! `@matter/general/src/crypto/Spake2p.ts` (`computeW0W1` / `computeW0L`):
16//!
17//! - PIN serialisation: **4 little-endian bytes** (`pinWriter.writeUInt32(pin)`).
18//! - PBKDF2 output length: **80 bytes** (`CRYPTO_W_SIZE_BYTES * 2` where
19//! `CRYPTO_W_SIZE_BYTES = CRYPTO_GROUP_SIZE_BYTES + 8 = 32 + 8 = 40`).
20//! - w0 / w1 split: bytes `[0..40]` and `[40..80]`.
21//! - Reduction: `mod(bytesToNumberBE(slice), curve.n)` — big-endian
22//! interpretation, modular reduction mod the P-256 group order n.
23//! - `L = Point.BASE.multiply(w1).toBytes(false)` — uncompressed SEC1.
24//! - HKDF info strings (for Task 4 / `spake2plus.rs`):
25//! - `"ConfirmationKeys"` → 32-byte `KcAB`, split `KcA`/`KcB`.
26//! - `"SessionKeys"` → 48-byte keys, split into decrypt/encrypt/attestation
27//! (from `@matter/protocol/src/session/NodeSession.ts`).
28//!
29//! Matter Core Spec §3.10.2, §3.10.3, §A.4.
30
31// All items in this module are consumed by `spake2plus.rs` (Task 4).
32// Until that file is populated the compiler sees them as dead code;
33// this allow will be removed once Task 4 lands.
34#![allow(dead_code)]
35
36use std::num::NonZeroU32;
37
38use p256::elliptic_curve::sec1::ToEncodedPoint;
39use p256::{ProjectivePoint, Scalar};
40use ring::hkdf;
41use ring::pbkdf2;
42
43use crate::error::{Error, Result};
44
45/// Length of the PBKDF output before reduction.
46///
47/// Matter Core Spec §3.10.2: `CRYPTO_W_SIZE_BYTES * 2 = 40 * 2 = 80`.
48/// `CRYPTO_W_SIZE_BYTES = CRYPTO_GROUP_SIZE_BYTES + 8 = 32 + 8 = 40`.
49const PBKDF_OUTPUT_LEN: usize = 80;
50
51/// Each w0/w1 half is 40 bytes before mod-q reduction.
52const W_HALF_LEN: usize = 40;
53
54/// Minimum PBKDF2 iterations per Matter spec §3.10.3.
55const PBKDF_MIN_ITERATIONS: u32 = 1_000;
56
57/// Maximum PBKDF2 iterations we accept from a (peer-supplied) responder.
58///
59/// The iteration count arrives in the device's `PBKDFParamResponse` and is
60/// the only unbounded peer-controlled cost multiplier on the PIN-derivation
61/// path: it maps 1:1 to HMAC-SHA256 passes inside [`derive_w0_w1`]. Without a
62/// ceiling, a spoofed responder advertising `u32::MAX` (~4.29e9) would pin a
63/// commissioner CPU core for minutes-to-hours per handshake — a commissioning
64/// CPU-DoS. We cap it at the Matter spec's published maximum.
65///
66/// `100_000` matches the upper bound of the Matter Core Spec §3.10.3
67/// `PBKDFParameter` `iterations` range (1000–100000), and the maximum used by
68/// the reference SPAKE2+ parameter generation (project-chip SPAKE2+ tooling
69/// and matter.js generate iteration counts within 1000–100000). Legitimate
70/// devices stay well within this range, so the cap never rejects a compliant
71/// responder while bounding the worst-case derivation cost.
72const PBKDF_MAX_ITERATIONS: u32 = 100_000;
73
74/// Salt length bounds per Matter spec §3.10.3.
75const PBKDF_SALT_MIN: usize = 16;
76const PBKDF_SALT_MAX: usize = 32;
77
78/// Validate PBKDF parameters per Matter spec §3.10.3.
79///
80/// Returns [`Error::PbkdfIterationsTooLow`], [`Error::PbkdfIterationsTooHigh`],
81/// or [`Error::PbkdfSaltLengthInvalid`] if the constraints are violated.
82///
83/// The upper iteration bound ([`PBKDF_MAX_ITERATIONS`]) is enforced here,
84/// *before* any PBKDF2 derivation runs, to prevent a peer-supplied iteration
85/// count from inflicting a commissioner CPU denial-of-service.
86pub(crate) fn validate_params(iterations: u32, salt: &[u8]) -> Result<()> {
87 if iterations < PBKDF_MIN_ITERATIONS {
88 return Err(Error::PbkdfIterationsTooLow(iterations));
89 }
90 if iterations > PBKDF_MAX_ITERATIONS {
91 return Err(Error::PbkdfIterationsTooHigh {
92 iterations,
93 max: PBKDF_MAX_ITERATIONS,
94 });
95 }
96 if salt.len() < PBKDF_SALT_MIN || salt.len() > PBKDF_SALT_MAX {
97 return Err(Error::PbkdfSaltLengthInvalid(salt.len()));
98 }
99 Ok(())
100}
101
102/// Derive the SPAKE2+ verifier scalars from the setup PIN.
103///
104/// Per Matter spec §3.10.2 (cross-verified against matter.js `computeW0W1`):
105///
106/// ```text
107/// w0 || w1 = PBKDF2-HMAC-SHA256(
108/// password = PIN encoded as 4 little-endian bytes,
109/// salt = salt,
110/// iter = iterations,
111/// out_len = 80 bytes,
112/// )
113/// w0 := bytes[0..40] interpreted as big-endian integer, then mod q
114/// w1 := bytes[40..80] interpreted as big-endian integer, then mod q
115/// ```
116///
117/// Each returned scalar is reduced mod the P-256 curve order q.
118pub(crate) fn derive_w0_w1(pin: u32, salt: &[u8], iterations: u32) -> Result<(Scalar, Scalar)> {
119 validate_params(iterations, salt)?;
120
121 // matter.js: `pinWriter.writeUInt32(pin)` with `Endian.Little`.
122 let pin_bytes = pin.to_le_bytes();
123
124 let mut out = [0u8; PBKDF_OUTPUT_LEN];
125 // iterations was already validated >= 1000 above, so NonZeroU32::new is safe.
126 let iter_nz = NonZeroU32::new(iterations).ok_or(Error::PinDerivationFailed)?;
127 pbkdf2::derive(
128 pbkdf2::PBKDF2_HMAC_SHA256,
129 iter_nz,
130 salt,
131 &pin_bytes,
132 &mut out,
133 );
134
135 let w0 = reduce_40_bytes_mod_q(&out[..W_HALF_LEN])?;
136 let w1 = reduce_40_bytes_mod_q(&out[W_HALF_LEN..])?;
137 Ok((w0, w1))
138}
139
140/// Reduce a 40-byte big-endian buffer to a P-256 scalar mod q.
141///
142/// Matter spec / matter.js use: `mod(bytesToNumberBE(slice_40), curve.n)`.
143/// A 40-byte (320-bit) value is slightly larger than the 256-bit field, so
144/// we need 320-bit big-integer arithmetic to do the reduction.
145///
146/// Strategy:
147/// 1. Parse 40 bytes as a [`crypto_bigint::U320`] (big-endian).
148/// 2. Pad the P-256 order `n` (32 bytes, 256 bits) to 40 bytes to match width.
149/// 3. Compute `rem = input mod n` using crypto-bigint's constant-time division.
150/// 4. Extract the low 32 bytes of the 40-byte result (upper 8 bytes are zero
151/// because the remainder is < n < 2^256).
152/// 5. Wrap in a `Scalar` via [`Reduce<U256>::reduce`], which handles the final
153/// Barrett reduction for uniformity.
154fn reduce_40_bytes_mod_q(input: &[u8]) -> Result<Scalar> {
155 use p256::elliptic_curve::bigint::{Encoding, NonZero, U256};
156 use p256::elliptic_curve::ops::Reduce;
157 use p256::elliptic_curve::{bigint::ArrayEncoding, Curve};
158
159 // Internal type alias: U320 is available via the crypto-bigint re-export
160 // inside elliptic_curve::bigint (which p256 re-exports as
161 // `p256::elliptic_curve::bigint`).
162 // 40 bytes = 320 bits = 5 × 64-bit limbs.
163 type U320 = p256::elliptic_curve::bigint::Uint<5>;
164
165 if input.len() != W_HALF_LEN {
166 return Err(Error::PinDerivationFailed);
167 }
168
169 // Step 1: parse 40 bytes as big-endian U320.
170 let n320 = U320::from_be_slice(input);
171
172 // Step 2: embed the P-256 order (32 bytes, 256 bits) into a U320 by
173 // zero-padding the upper 8 bytes.
174 // `NistP256::ORDER` is a U256; `Curve` trait brings it into scope.
175 let order_u256: U256 = p256::NistP256::ORDER;
176 let order_be = order_u256.to_be_byte_array(); // 32-byte generic array
177 let mut order_buf = [0u8; 40];
178 order_buf[8..].copy_from_slice(&order_be); // top 8 bytes remain 0
179 let order_u320 = U320::from_be_slice(&order_buf);
180
181 // Step 3: constant-time modular reduction.
182 // NonZero::new returns CtOption; the P-256 order is never zero.
183 let order_nz: NonZero<U320> = NonZero::new(order_u320)
184 .into_option()
185 .ok_or(Error::PinDerivationFailed)?;
186 let rem_u320 = n320.rem(&order_nz);
187
188 // Step 4: extract the low 32 bytes from the 40-byte remainder.
189 // The result is < n < 2^256, so the upper 8 bytes are always zero.
190 // `Encoding::to_be_bytes` is brought into scope above.
191 let rem_be: [u8; 40] = rem_u320.to_be_bytes();
192 // rem_be[0..8] is always zero; the scalar occupies rem_be[8..40].
193 let mut scalar_bytes = [0u8; 32];
194 scalar_bytes.copy_from_slice(&rem_be[8..]);
195
196 // Step 5: wrap via `Reduce<U256>` for a well-formed scalar.
197 // `reduce()` performs Barrett reduction; since the value is already < n,
198 // this is effectively a no-op that gives us a typed Scalar.
199 let scalar = <Scalar as Reduce<U256>>::reduce(U256::from_be_slice(&scalar_bytes));
200 Ok(scalar)
201}
202
203/// Compute `L = w1 · P`, where `P` is the P-256 generator.
204///
205/// `L` is the device's stored verifier point (SEC1 uncompressed, 65 bytes).
206/// In production a device computes this once at provisioning and persists it;
207/// the PIN is never stored after provisioning.
208///
209/// matter.js: `Point.BASE.multiply(w1).toBytes(false)`.
210pub(crate) fn derive_l(w1: &Scalar) -> [u8; 65] {
211 let l_point = ProjectivePoint::GENERATOR * w1;
212 let encoded = l_point.to_affine().to_encoded_point(false);
213 let mut out = [0u8; 65];
214 out.copy_from_slice(encoded.as_bytes());
215 out
216}
217
218/// Serialize the SPAKE2+ PAKE passcode verifier `w0 ‖ L` (97 bytes) for a
219/// setup `passcode`, as carried by the `AdministratorCommissioning`
220/// `OpenCommissioningWindow` command's `PAKEPasscodeVerifier` field
221/// (Matter Core Spec §3.10 / Cluster spec §11.18).
222///
223/// `w0` is the 32-byte big-endian PBKDF2-derived scalar; `L = w1·P` is the
224/// 65-byte SEC1-uncompressed P-256 point. This is a pure function of
225/// `(passcode, salt, iterations)`; it does **not** run a PASE session.
226///
227/// # Errors
228/// Returns [`Error::PbkdfIterationsTooLow`]/`…TooHigh`/`PbkdfSaltLengthInvalid`
229/// if `iterations`/`salt` are out of the spec range, or a derivation error.
230pub fn pake_passcode_verifier(passcode: u32, salt: &[u8], iterations: u32) -> Result<[u8; 97]> {
231 validate_params(iterations, salt)?;
232 let (w0, w1) = derive_w0_w1(passcode, salt, iterations)?;
233 let l = derive_l(&w1);
234 let w0_be: p256::FieldBytes = w0.to_bytes();
235 let mut out = [0u8; 97];
236 out[..32].copy_from_slice(&w0_be);
237 out[32..].copy_from_slice(&l);
238 Ok(out)
239}
240
241/// Thin wrapper around ring's HKDF-Expand for use by `spake2plus.rs`.
242///
243/// `prk` is the pseudo-random key (typically derived from the SPAKE2+
244/// transcript hash); `info` is the spec-defined info string (e.g.
245/// `b"ConfirmationKeys"` or `b"SessionKeys"`); `out` is the destination
246/// buffer whose length determines how many bytes are produced.
247///
248/// Uses HKDF-SHA256. The salt for `extract` is left empty because `prk` is
249/// already a proper pseudo-random key in the SPAKE2+ context.
250pub(crate) fn hkdf_expand(prk: &[u8], info: &[u8], out: &mut [u8]) -> Result<()> {
251 // ring's HKDF design: Salt::new().extract(ikm) produces a Prk.
252 // We pass the prk bytes as the "ikm" with an empty salt so ring treats
253 // them as the PRK directly (HKDF-Extract of prk with empty salt is a
254 // no-op from a security perspective when prk is already well-distributed).
255 let salt = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]);
256 let prk_obj = salt.extract(prk);
257 // `ring::hkdf::Prk::expand` borrows the info slice for the lifetime of
258 // the returned Okm, so we must keep it in a named binding.
259 let info_arr = [info];
260 let okm = prk_obj
261 .expand(&info_arr, OutLen(out.len()))
262 .map_err(|_| Error::PinDerivationFailed)?;
263 okm.fill(out).map_err(|_| Error::PinDerivationFailed)?;
264 Ok(())
265}
266
267/// `KeyType` adapter so we can pass a runtime-determined output length to
268/// `ring::hkdf::Prk::expand`. ring requires a type-level bound but accepts
269/// any type implementing `KeyType`.
270struct OutLen(usize);
271
272impl hkdf::KeyType for OutLen {
273 fn len(&self) -> usize {
274 self.0
275 }
276}
277
278#[cfg(test)]
279#[allow(clippy::unwrap_used)] // Test-code carve-out: see CLAUDE.md.
280mod tests {
281 use super::*;
282
283 // ─── validate_params ──────────────────────────────────────────────────────
284
285 #[test]
286 fn validate_rejects_iter_below_min() {
287 assert!(matches!(
288 validate_params(999, &[0u8; 16]),
289 Err(Error::PbkdfIterationsTooLow(999))
290 ));
291 }
292
293 #[test]
294 fn validate_rejects_iter_zero() {
295 assert!(matches!(
296 validate_params(0, &[0u8; 16]),
297 Err(Error::PbkdfIterationsTooLow(0))
298 ));
299 }
300
301 #[test]
302 fn validate_rejects_salt_too_short() {
303 assert!(matches!(
304 validate_params(1_000, &[0u8; 15]),
305 Err(Error::PbkdfSaltLengthInvalid(15))
306 ));
307 }
308
309 #[test]
310 fn validate_rejects_salt_too_long() {
311 assert!(matches!(
312 validate_params(1_000, &[0u8; 33]),
313 Err(Error::PbkdfSaltLengthInvalid(33))
314 ));
315 }
316
317 #[test]
318 fn validate_accepts_boundary_values() {
319 // Exact minimum values must be accepted.
320 validate_params(1_000, &[0u8; 16]).unwrap();
321 // Larger values must be accepted.
322 validate_params(10_000, &[0u8; 32]).unwrap();
323 }
324
325 #[test]
326 fn validate_rejects_iter_above_max() {
327 // A peer advertising u32::MAX iterations must be rejected before any
328 // PBKDF2 derivation runs (commissioner CPU-DoS guard).
329 assert!(matches!(
330 validate_params(u32::MAX, &[0u8; 16]),
331 Err(Error::PbkdfIterationsTooHigh {
332 iterations: u32::MAX,
333 max: PBKDF_MAX_ITERATIONS,
334 })
335 ));
336 // One above the ceiling is also rejected.
337 let over = PBKDF_MAX_ITERATIONS + 1;
338 assert!(matches!(
339 validate_params(over, &[0u8; 16]),
340 Err(Error::PbkdfIterationsTooHigh {
341 iterations,
342 max,
343 }) if iterations == over && max == PBKDF_MAX_ITERATIONS
344 ));
345 }
346
347 #[test]
348 fn validate_accepts_max_boundary() {
349 // The ceiling itself is a legitimate spec value and must be accepted.
350 validate_params(PBKDF_MAX_ITERATIONS, &[0u8; 16]).unwrap();
351 }
352
353 // ─── derive_w0_w1 ────────────────────────────────────────────────────────
354
355 #[test]
356 // w0 / w0b / w1 / w1b are the SPAKE2+ PBKDF output binding names
357 // from Matter Core Spec §3.10 (RFC 9383 §3.3). The `b` suffix denotes
358 // the verifier-side mirror used in cross-check tests. Renaming would
359 // impair verification against the spec.
360 #[allow(clippy::similar_names)]
361 fn derive_w0_w1_is_deterministic() {
362 let salt = [0x42u8; 16];
363 let (w0a, w1a) = derive_w0_w1(20_202_021, &salt, 1_000).unwrap();
364 let (w0b, w1b) = derive_w0_w1(20_202_021, &salt, 1_000).unwrap();
365 assert_eq!(w0a.to_bytes(), w0b.to_bytes());
366 assert_eq!(w1a.to_bytes(), w1b.to_bytes());
367 }
368
369 #[test]
370 // w0 / w0b are the SPAKE2+ PBKDF output binding names from Matter
371 // Core Spec §3.10 (RFC 9383 §3.3); the `b` suffix denotes the
372 // verifier-side mirror used in cross-check tests.
373 #[allow(clippy::similar_names)]
374 fn derive_w0_w1_changes_with_pin() {
375 let salt = [0x42u8; 16];
376 let (w0a, _) = derive_w0_w1(20_202_021, &salt, 1_000).unwrap();
377 let (w0b, _) = derive_w0_w1(20_202_022, &salt, 1_000).unwrap();
378 assert_ne!(w0a.to_bytes(), w0b.to_bytes());
379 }
380
381 #[test]
382 // w0 / w0b are the SPAKE2+ PBKDF output binding names from Matter
383 // Core Spec §3.10 (RFC 9383 §3.3); the `b` suffix denotes the
384 // verifier-side mirror used in cross-check tests.
385 #[allow(clippy::similar_names)]
386 fn derive_w0_w1_changes_with_salt() {
387 let (w0a, _) = derive_w0_w1(20_202_021, &[0x42u8; 16], 1_000).unwrap();
388 let (w0b, _) = derive_w0_w1(20_202_021, &[0x43u8; 16], 1_000).unwrap();
389 assert_ne!(w0a.to_bytes(), w0b.to_bytes());
390 }
391
392 #[test]
393 // w0 / w0b are the SPAKE2+ PBKDF output binding names from Matter
394 // Core Spec §3.10 (RFC 9383 §3.3); the `b` suffix denotes the
395 // verifier-side mirror used in cross-check tests.
396 #[allow(clippy::similar_names)]
397 fn derive_w0_w1_changes_with_iterations() {
398 let salt = [0x42u8; 16];
399 let (w0a, _) = derive_w0_w1(20_202_021, &salt, 1_000).unwrap();
400 let (w0b, _) = derive_w0_w1(20_202_021, &salt, 2_000).unwrap();
401 assert_ne!(w0a.to_bytes(), w0b.to_bytes());
402 }
403
404 #[test]
405 fn derive_w0_w1_rejects_bad_params() {
406 // iterations too low
407 assert!(derive_w0_w1(20_202_021, &[0u8; 16], 999).is_err());
408 // salt too short
409 assert!(derive_w0_w1(20_202_021, &[0u8; 15], 1_000).is_err());
410 }
411
412 // ─── derive_l ────────────────────────────────────────────────────────────
413
414 #[test]
415 fn derive_l_produces_uncompressed_p256_point() {
416 let salt = [0x42u8; 16];
417 let (_, w1) = derive_w0_w1(20_202_021, &salt, 1_000).unwrap();
418 let l = derive_l(&w1);
419 // SEC1 uncompressed points always start with 0x04.
420 assert_eq!(l[0], 0x04, "SEC1 uncompressed prefix");
421 assert_eq!(l.len(), 65);
422 }
423
424 #[test]
425 fn derive_l_is_deterministic() {
426 let salt = [0x42u8; 16];
427 let (_, w1) = derive_w0_w1(20_202_021, &salt, 1_000).unwrap();
428 let l1 = derive_l(&w1);
429 let l2 = derive_l(&w1);
430 assert_eq!(l1, l2);
431 }
432
433 #[test]
434 // w1 / w1b are the SPAKE2+ PBKDF output binding names from Matter
435 // Core Spec §3.10 (RFC 9383 §3.3); the `b` suffix denotes the
436 // verifier-side mirror used in cross-check tests.
437 #[allow(clippy::similar_names)]
438 fn derive_l_changes_with_w1() {
439 let (_, w1a) = derive_w0_w1(20_202_021, &[0x42u8; 16], 1_000).unwrap();
440 let (_, w1b) = derive_w0_w1(20_202_022, &[0x42u8; 16], 1_000).unwrap();
441 let la = derive_l(&w1a);
442 let lb = derive_l(&w1b);
443 assert_ne!(la, lb);
444 }
445
446 // ─── hkdf_expand ─────────────────────────────────────────────────────────
447
448 #[test]
449 fn hkdf_expand_is_deterministic() {
450 let prk = [0x11u8; 32];
451 let info = b"ConfirmationKeys";
452 let mut out_a = [0u8; 32];
453 let mut out_b = [0u8; 32];
454 hkdf_expand(&prk, info, &mut out_a).unwrap();
455 hkdf_expand(&prk, info, &mut out_b).unwrap();
456 assert_eq!(out_a, out_b);
457 }
458
459 #[test]
460 fn hkdf_expand_differs_with_info() {
461 let prk = [0x11u8; 32];
462 let mut out_a = [0u8; 32];
463 let mut out_b = [0u8; 32];
464 hkdf_expand(&prk, b"ConfirmationKeys", &mut out_a).unwrap();
465 hkdf_expand(&prk, b"SessionKeys", &mut out_b).unwrap();
466 assert_ne!(out_a, out_b);
467 }
468
469 #[test]
470 fn hkdf_expand_differs_with_prk() {
471 let mut out_a = [0u8; 32];
472 let mut out_b = [0u8; 32];
473 hkdf_expand(&[0x11u8; 32], b"ConfirmationKeys", &mut out_a).unwrap();
474 hkdf_expand(&[0x22u8; 32], b"ConfirmationKeys", &mut out_b).unwrap();
475 assert_ne!(out_a, out_b);
476 }
477
478 #[test]
479 fn hkdf_expand_variable_output_length() {
480 let prk = [0x11u8; 32];
481 let mut out_16 = [0u8; 16];
482 let mut out_48 = [0u8; 48];
483 hkdf_expand(&prk, b"SessionKeys", &mut out_16).unwrap();
484 hkdf_expand(&prk, b"SessionKeys", &mut out_48).unwrap();
485 // The first 16 bytes of a 48-byte expand must equal a standalone 16-byte expand.
486 assert_eq!(&out_48[..16], &out_16[..]);
487 }
488
489 // ─── pake_passcode_verifier ───────────────────────────────────────────────
490
491 #[test]
492 #[allow(clippy::unreadable_literal)] // 123456 is the raw Matter test-vector passcode; separating it would obscure the match.
493 fn pake_passcode_verifier_matches_known_params_vector() {
494 // From test-vectors/pase/handshake-known-params.json
495 let salt = hex_to_vec("abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd");
496 let w0_hex = "d8e14a916650ff651dcd0c34d15fc1ed9b8232d550827be4816cc8e0fbd31bfa";
497 let l_hex = "04cd104598fca43b1a17bcc78d51ad2ec542bdd3f8541ecfbe5b66c7ea714f505d7e4626053087b2980c37876053c431600662fe09af442d6ca49525334dbf59e5";
498 let mut expected = hex_to_vec(w0_hex);
499 expected.extend_from_slice(&hex_to_vec(l_hex));
500
501 let got = super::pake_passcode_verifier(123456, &salt, 2000).unwrap();
502 assert_eq!(got.len(), 97);
503 assert_eq!(&got[..], &expected[..]);
504 }
505
506 // Minimal local hex decoder for the test (no new dep).
507 fn hex_to_vec(s: &str) -> Vec<u8> {
508 (0..s.len())
509 .step_by(2)
510 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
511 .collect()
512 }
513}