Skip to main content

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}