matter-crypto 0.3.0

Matter protocol session establishment: PASE (SPAKE2+) and CASE (SIGMA).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
//! matter.js byte-parity tests for CASE.
//!
//! Each test loads a captured handshake JSON fixture from
//! `test-vectors/case/` and replays it through our state machines,
//! asserting byte-identical message output at every step.
//!
//! Fixtures produced by `cargo xtask capture-case` (M4.3 Task 3).
//! Tests will fail with "fixture not found" until Task 3 lands.

#![allow(clippy::expect_used)]
#![allow(clippy::unwrap_used)]
#![allow(clippy::doc_markdown)] // test module docs need not link every identifier
#![allow(clippy::struct_field_names)] // fixture field names must match JSON keys verbatim

use std::fs;
use std::path::PathBuf;

use matter_cert::{MatterCertificate, MatterTime, TrustAnchor, TrustedRoots};
use matter_crypto::{
    test_support::{
        case_initiator_with_eph_key, case_initiator_with_resumption_eph_key,
        case_responder_with_eph_key_and_resumption_id,
    },
    CaseCredentials, PeerInfo, ResumptionId, ResumptionRecord, RingSigner, Sigma1Outcome,
};
use serde::Deserialize;

// =============================================================================
// Fixture types — field names match the JSON produced by the capture script
// =============================================================================

#[derive(Debug, Deserialize)]
struct Fixture {
    inputs: FixtureInputs,
    messages: FixtureMessages,
}

#[derive(Debug, Deserialize)]
struct FixtureInputs {
    fabric_id: u64,
    initiator_node_id: u64,
    responder_node_id: u64,
    /// Hex-encoded 16-byte Identity Protection Key.
    ipk: String,
    /// Hex-encoded RCAC certificate TLV bytes.
    rcac_noc: String,
    /// Hex-encoded 65-byte SEC1-uncompressed RCAC public key.
    rcac_public_key: String,
    /// Hex-encoded initiator NOC TLV bytes.
    initiator_noc: String,
    /// Hex-encoded PKCS#8 DER for the initiator's NOC private key.
    initiator_pkcs8: String,
    /// Hex-encoded responder NOC TLV bytes.
    responder_noc: String,
    /// Hex-encoded PKCS#8 DER for the responder's NOC private key.
    responder_pkcs8: String,
    /// Hex-encoded ICAC certificate TLV bytes (optional; present when CA uses 3-tier PKI).
    #[serde(default)]
    icac_noc: Option<String>,
    /// Hex-encoded 32-byte initiator ephemeral private key scalar.
    initiator_eph_priv: String,
    /// Hex-encoded 32-byte initiator random.
    initiator_random: String,
    /// Hex-encoded 32-byte responder ephemeral private key scalar.
    responder_eph_priv: String,
    /// Hex-encoded 32-byte responder random.
    responder_random: String,
    // Resumption-only fields (optional in new-session fixtures):
    #[serde(default)]
    resumption_id: Option<String>,
    #[serde(default)]
    resumption_shared_secret: Option<String>,
    /// The fresh resumption id the capture script's responder embeds in
    /// TBEData2 (new-session / declined paths) or `Sigma2_Resume` (accepted
    /// path). Injected into our responder via
    /// `case_responder_with_eph_key_and_resumption_id` so the encrypted
    /// output is byte-comparable.
    #[serde(default)]
    new_resumption_id: Option<String>,
}

#[derive(Debug, Deserialize)]
struct FixtureMessages {
    /// Hex-encoded Sigma1 bytes.
    sigma1: String,
    /// Hex-encoded Sigma2 bytes (new-session path and resumption-declined path).
    #[serde(default)]
    sigma2: Option<String>,
    /// Hex-encoded Sigma2_Resume bytes (resumption-accepted path).
    #[serde(default)]
    sigma2_resume: Option<String>,
    /// Hex-encoded Sigma3 bytes (new-session path and resumption-declined path).
    #[serde(default)]
    sigma3: Option<String>,
}

// =============================================================================
// Helpers
// =============================================================================

fn load_fixture(scenario: &str) -> Fixture {
    let path = PathBuf::from("../../test-vectors/case").join(format!("{scenario}.json"));
    let bytes = fs::read_to_string(&path).unwrap_or_else(|_| {
        panic!(
            "fixture {} not found — run `cargo xtask capture-case` to regenerate",
            path.display()
        )
    });
    serde_json::from_str(&bytes)
        .unwrap_or_else(|e| panic!("malformed fixture {}: {}", path.display(), e))
}

fn hex_to_array<const N: usize>(s: &str) -> [u8; N] {
    let bytes = hex::decode(s).expect("valid hex");
    assert_eq!(bytes.len(), N, "expected {N} bytes, got {}", bytes.len());
    bytes.try_into().unwrap()
}

fn build_credentials_from_fixture(
    noc_hex: &str,
    pkcs8_hex: &str,
    icac_hex: Option<&str>,
    fabric_id: u64,
    node_id: u64,
    ipk: [u8; 16],
    rcac_public_key: [u8; 65],
) -> CaseCredentials {
    let noc = MatterCertificate::from_tlv(&hex::decode(noc_hex).unwrap()).expect("parse NOC");
    let signer = RingSigner::from_pkcs8(&hex::decode(pkcs8_hex).unwrap()).expect("load signer");
    let icac = icac_hex
        .map(|h| MatterCertificate::from_tlv(&hex::decode(h).unwrap()).expect("parse ICAC"));
    CaseCredentials {
        noc,
        icac,
        signer: Box::new(signer),
        fabric_id,
        node_id,
        ipk,
        rcac_public_key,
    }
}

fn build_trusted_roots(rcac_noc_hex: &str) -> TrustedRoots {
    let rcac =
        MatterCertificate::from_tlv(&hex::decode(rcac_noc_hex).unwrap()).expect("parse RCAC");
    let mut roots = TrustedRoots::new();
    roots.add(TrustAnchor::from_root_cert(&rcac));
    roots
}

// =============================================================================
// Diagnostic: verify fixture NOC roundtrips byte-for-byte through matter-cert.
// If this fails, MatterCertificate::to_tlv changes the encoding and we need
// to fix the Rust encoder to be byte-identical with matter.js output.
// =============================================================================

/// Verify the ECDSA signature that Rust produces over the known TBSData2 bytes.
///
/// This test computes TBSData2 bytes by hand (replicating `encode_tbs_data`
/// using the public `TlvWriter`), signs with `RingSigner`, and compares the
/// signature hex to the known-good value produced by the JS capture script.
///
/// This diagnostic isolates whether the signature mismatch is in:
///   (a) the TBSData2 bytes being different, or
///   (b) the signature algorithm / key format
#[test]
fn debug_tbs_data2_and_signature() {
    use matter_codec::{Tag, TlvWriter};
    use matter_crypto::CaseSigner;
    use p256::elliptic_curve::sec1::ToEncodedPoint;
    use p256::{NonZeroScalar, SecretKey};

    let fx = load_fixture("handshake-new-session");

    // Re-encode NOC (same as what the responder does)
    let noc_bytes = hex::decode(&fx.inputs.responder_noc).unwrap();
    let cert = MatterCertificate::from_tlv(&noc_bytes).expect("parse responder NOC");
    let re_encoded_noc = cert.to_tlv().expect("re-encode NOC");

    // Derive ephemeral public keys from the fixed scalars (same as the test)
    let resp_eph_priv_bytes = hex_to_array::<32>(&fx.inputs.responder_eph_priv);
    let init_eph_priv_bytes = hex_to_array::<32>(&fx.inputs.initiator_eph_priv);

    let resp_eph_scalar = NonZeroScalar::from_repr(resp_eph_priv_bytes.into()).unwrap();
    let resp_eph_sk = SecretKey::new(resp_eph_scalar.into());
    let resp_eph_pub_encoded = resp_eph_sk.public_key().to_encoded_point(false);
    let mut resp_eph_pub = [0u8; 65];
    resp_eph_pub.copy_from_slice(resp_eph_pub_encoded.as_bytes());

    let init_eph_scalar = NonZeroScalar::from_repr(init_eph_priv_bytes.into()).unwrap();
    let init_eph_sk = SecretKey::new(init_eph_scalar.into());
    let init_eph_pub_encoded = init_eph_sk.public_key().to_encoded_point(false);
    let mut init_eph_pub = [0u8; 65];
    init_eph_pub.copy_from_slice(init_eph_pub_encoded.as_bytes());

    // Re-encode ICAC when present (mirrors encode_tbs_data)
    let icac_re_encoded: Option<Vec<u8>> = fx.inputs.icac_noc.as_deref().map(|h| {
        let icac_bytes = hex::decode(h).unwrap();
        let icac_cert = MatterCertificate::from_tlv(&icac_bytes).expect("parse ICAC");
        icac_cert.to_tlv().expect("re-encode ICAC")
    });

    // Build TBSData2 manually using TlvWriter (mirrors encode_tbs_data)
    let mut tbs_data = Vec::new();
    {
        let mut w = TlvWriter::new(&mut tbs_data);
        w.start_structure(Tag::Anonymous).unwrap();
        w.put_bytes(Tag::Context(1), &re_encoded_noc).unwrap();
        if let Some(icac) = &icac_re_encoded {
            w.put_bytes(Tag::Context(2), icac).unwrap();
        }
        w.put_bytes(Tag::Context(3), &resp_eph_pub).unwrap();
        w.put_bytes(Tag::Context(4), &init_eph_pub).unwrap();
        w.end_container().unwrap();
    }

    eprintln!("TBSData2 (Rust): {}", hex::encode(&tbs_data));
    eprintln!("TBSData2 len (Rust): {}", tbs_data.len());

    // Sign with RingSigner
    let signer =
        matter_crypto::RingSigner::from_pkcs8(&hex::decode(&fx.inputs.responder_pkcs8).unwrap())
            .expect("load signer");
    let sig = signer.sign_p256_sha256(&tbs_data).expect("sign");
    eprintln!("Signature (Rust/ring): {}", hex::encode(sig));

    eprintln!(
        "signer public_key: {}",
        hex::encode(signer.public_key().as_bytes())
    );

    // The TBSData2 bytes and signature printed above can be compared against the
    // JS debug_tbs script output for diagnostic purposes. No hardcoded assertion
    // here — the matter_js_byte_parity_new_session test verifies end-to-end.
}

/// Verify the ECDSA signature that Rust produces over the known TBSData3 bytes.
///
/// Sigma3 TBSData3 is signed by the **initiator's** NOC key (the "responder" role in
/// TlvSignedData field names — see comment in initiator.rs). This test prints the
/// TBSData3 hex so it can be compared against JS output for diagnosis.
#[test]
fn debug_tbs_data3_and_signature() {
    use matter_codec::{Tag, TlvWriter};
    use matter_crypto::CaseSigner;
    use p256::elliptic_curve::sec1::ToEncodedPoint;
    use p256::{NonZeroScalar, SecretKey};

    let fx = load_fixture("handshake-new-session");

    // Re-encode initiator NOC (same as what the initiator does in process_sigma2)
    let noc_bytes = hex::decode(&fx.inputs.initiator_noc).unwrap();
    let cert = MatterCertificate::from_tlv(&noc_bytes).expect("parse initiator NOC");
    let re_encoded_noc = cert.to_tlv().expect("re-encode initiator NOC");
    eprintln!("initiator_noc raw:        {}", hex::encode(&noc_bytes));
    eprintln!("initiator_noc re-encoded: {}", hex::encode(&re_encoded_noc));
    eprintln!("roundtrip matches: {}", noc_bytes == re_encoded_noc);

    // Derive ephemeral public keys from the fixed scalars
    let resp_eph_priv_bytes = hex_to_array::<32>(&fx.inputs.responder_eph_priv);
    let init_eph_priv_bytes = hex_to_array::<32>(&fx.inputs.initiator_eph_priv);

    let resp_eph_scalar = NonZeroScalar::from_repr(resp_eph_priv_bytes.into()).unwrap();
    let resp_eph_sk = SecretKey::new(resp_eph_scalar.into());
    let resp_eph_pub_encoded = resp_eph_sk.public_key().to_encoded_point(false);
    let mut resp_eph_pub = [0u8; 65];
    resp_eph_pub.copy_from_slice(resp_eph_pub_encoded.as_bytes());

    let init_eph_scalar = NonZeroScalar::from_repr(init_eph_priv_bytes.into()).unwrap();
    let init_eph_sk = SecretKey::new(init_eph_scalar.into());
    let init_eph_pub_encoded = init_eph_sk.public_key().to_encoded_point(false);
    let mut init_eph_pub = [0u8; 65];
    init_eph_pub.copy_from_slice(init_eph_pub_encoded.as_bytes());

    // Re-encode ICAC when present
    let icac_re_encoded: Option<Vec<u8>> = fx.inputs.icac_noc.as_deref().map(|h| {
        let icac_bytes = hex::decode(h).unwrap();
        let icac_cert = MatterCertificate::from_tlv(&icac_bytes).expect("parse ICAC");
        icac_cert.to_tlv().expect("re-encode ICAC")
    });

    // Build TBSData3:
    // In Sigma3, initiator plays "responder" role in TlvSignedData:
    //   tag1 (responderNoc) = initiator's NOC
    //   tag2 (responderIcac) = initiator's ICAC (optional)
    //   tag3 (responderPublicKey) = initiator's eph pub
    //   tag4 (initiatorPublicKey) = responder's eph pub
    let mut tbs_data = Vec::new();
    {
        let mut w = TlvWriter::new(&mut tbs_data);
        w.start_structure(Tag::Anonymous).unwrap();
        w.put_bytes(Tag::Context(1), &re_encoded_noc).unwrap();
        if let Some(icac) = &icac_re_encoded {
            w.put_bytes(Tag::Context(2), icac).unwrap();
        }
        w.put_bytes(Tag::Context(3), &init_eph_pub).unwrap(); // initiator's eph pub
        w.put_bytes(Tag::Context(4), &resp_eph_pub).unwrap(); // responder's eph pub
        w.end_container().unwrap();
    }

    eprintln!("TBSData3 (Rust): {}", hex::encode(&tbs_data));
    eprintln!("TBSData3 len (Rust): {}", tbs_data.len());

    // Sign with initiator's NOC key
    let signer =
        matter_crypto::RingSigner::from_pkcs8(&hex::decode(&fx.inputs.initiator_pkcs8).unwrap())
            .expect("load initiator signer");
    let sig = signer.sign_p256_sha256(&tbs_data).expect("sign");
    eprintln!("Sigma3 Signature (Rust): {}", hex::encode(sig));
    eprintln!(
        "initiator signer public_key: {}",
        hex::encode(signer.public_key().as_bytes())
    );
}

/// Verify that the NOC bytes from the fixture survive a from_tlv + to_tlv roundtrip.
///
/// If this test fails, `MatterCertificate::to_tlv` is not byte-identical with
/// the matter.js-produced TLV and the Sigma2/Sigma3 signature bytes will differ
/// between JS and Rust (both sign the re-encoded NOC, but the re-encoded form differs).
#[test]
fn fixture_noc_roundtrips_through_matter_cert() {
    let fx = load_fixture("handshake-new-session");
    // Check both NOCs.
    for (label, noc_hex) in [
        ("responder_noc", &fx.inputs.responder_noc),
        ("initiator_noc", &fx.inputs.initiator_noc),
    ] {
        let noc_bytes = hex::decode(noc_hex).unwrap();
        let cert = MatterCertificate::from_tlv(&noc_bytes).expect("parse NOC");
        let re_encoded = cert.to_tlv().expect("re-encode NOC");
        assert_eq!(
            noc_bytes, re_encoded,
            "{label}: MatterCertificate::to_tlv must produce byte-identical output"
        );
    }
}

// =============================================================================
// Test scenarios
// =============================================================================

/// New-session CASE handshake: Sigma1 → Sigma2 → Sigma3.
///
/// Loads `test-vectors/case/handshake-new-session.json`, replays the full
/// three-message exchange through our state machines, and asserts byte-identical
/// output at every step. Also verifies that both sides derive identical session
/// keys.
///
/// Fixtures produced by `cargo xtask capture-case` (M4.3 Task 3).
#[test]
fn matter_js_byte_parity_new_session() {
    let fx = load_fixture("handshake-new-session");
    let ipk = hex_to_array::<16>(&fx.inputs.ipk);
    let rcac_pub = hex_to_array::<65>(&fx.inputs.rcac_public_key);
    let roots = build_trusted_roots(&fx.inputs.rcac_noc);

    let icac_hex = fx.inputs.icac_noc.as_deref();
    let initiator_creds = build_credentials_from_fixture(
        &fx.inputs.initiator_noc,
        &fx.inputs.initiator_pkcs8,
        icac_hex,
        fx.inputs.fabric_id,
        fx.inputs.initiator_node_id,
        ipk,
        rcac_pub,
    );
    let responder_creds = build_credentials_from_fixture(
        &fx.inputs.responder_noc,
        &fx.inputs.responder_pkcs8,
        icac_hex,
        fx.inputs.fabric_id,
        fx.inputs.responder_node_id,
        ipk,
        rcac_pub,
    );

    let mut initiator = case_initiator_with_eph_key(
        initiator_creds,
        roots.clone(),
        fx.inputs.responder_node_id,
        fx.inputs.fabric_id,
        hex_to_array::<32>(&fx.inputs.initiator_eph_priv),
        hex_to_array::<32>(&fx.inputs.initiator_random),
        MatterTime::from_unix_secs(2_000_000_000),
    )
    .unwrap();
    // Inject the capture script's fixed fresh resumption id so the encrypted
    // TBEData2 in our Sigma2 is byte-comparable with the fixture.
    let mut responder = case_responder_with_eph_key_and_resumption_id(
        responder_creds,
        roots,
        hex_to_array::<32>(&fx.inputs.responder_eph_priv),
        hex_to_array::<32>(&fx.inputs.responder_random),
        hex_to_array::<16>(
            fx.inputs
                .new_resumption_id
                .as_deref()
                .expect("fixture has new_resumption_id"),
        ),
        MatterTime::from_unix_secs(2_000_000_000),
    )
    .unwrap();

    // ── Sigma1 ────────────────────────────────────────────────────────────
    let our_sigma1 = initiator.start().unwrap();
    assert_eq!(
        hex::encode(&our_sigma1),
        fx.messages.sigma1,
        "Sigma1 byte parity"
    );

    // ── Sigma2 ────────────────────────────────────────────────────────────
    let outcome = responder.handle_sigma1(&our_sigma1).unwrap();
    assert!(
        matches!(outcome, Sigma1Outcome::NewSession),
        "expected NewSession outcome"
    );
    let our_sigma2 = responder.next_message().unwrap();
    assert_eq!(
        hex::encode(&our_sigma2),
        fx.messages.sigma2.as_deref().expect("fixture has sigma2"),
        "Sigma2 byte parity"
    );

    // ── Sigma3 ────────────────────────────────────────────────────────────
    initiator.handle_sigma2(&our_sigma2).unwrap();
    let our_sigma3 = initiator.next_message().unwrap();
    assert_eq!(
        hex::encode(&our_sigma3),
        fx.messages.sigma3.as_deref().expect("fixture has sigma3"),
        "Sigma3 byte parity"
    );

    responder.handle_sigma3(&our_sigma3).unwrap();

    // ── Session key agreement ─────────────────────────────────────────────
    let init_out = initiator.finish().unwrap();
    let resp_out = responder.finish().unwrap();
    assert_eq!(
        init_out.keys.i2r_key, resp_out.keys.i2r_key,
        "i2r session keys must agree"
    );
    assert_eq!(
        init_out.keys.r2i_key, resp_out.keys.r2i_key,
        "r2i session keys must agree"
    );
    assert_eq!(init_out.peer.node_id, fx.inputs.responder_node_id);
    assert_eq!(resp_out.peer.node_id, fx.inputs.initiator_node_id);
}

/// Resumption-accepted path: Sigma1 (with resumption fields) → Sigma2_Resume.
///
/// Loads `test-vectors/case/handshake-resumption-accepted.json`, replays the
/// two-message exchange, and asserts byte-identical output. The shared_secret
/// and resumption_id in the fixture are used to seed the `ResumptionRecord`.
///
/// Requires fixtures from `cargo xtask capture-case` (M4.3 Task 3).
///
/// The responder is built via
/// `case_responder_with_eph_key_and_resumption_id` so the fresh
/// `resumption_id` it puts in `Sigma2_Resume` matches the capture script's
/// patched-RNG value (all-zero bytes) instead of a live `SystemRandom` draw.
#[test]
#[allow(clippy::too_many_lines)] // long because it mirrors a multi-step protocol exchange
fn matter_js_byte_parity_resumption_accepted() {
    let fx = load_fixture("handshake-resumption-accepted");
    let ipk = hex_to_array::<16>(&fx.inputs.ipk);
    let rcac_pub = hex_to_array::<65>(&fx.inputs.rcac_public_key);
    let roots = build_trusted_roots(&fx.inputs.rcac_noc);

    let resumption_id = ResumptionId(hex_to_array::<16>(
        fx.inputs
            .resumption_id
            .as_deref()
            .expect("fixture has resumption_id"),
    ));
    let shared_secret = hex_to_array::<32>(
        fx.inputs
            .resumption_shared_secret
            .as_deref()
            .expect("fixture has resumption_shared_secret"),
    );

    let icac_hex = fx.inputs.icac_noc.as_deref();
    let initiator_creds = build_credentials_from_fixture(
        &fx.inputs.initiator_noc,
        &fx.inputs.initiator_pkcs8,
        icac_hex,
        fx.inputs.fabric_id,
        fx.inputs.initiator_node_id,
        ipk,
        rcac_pub,
    );
    let responder_creds = build_credentials_from_fixture(
        &fx.inputs.responder_noc,
        &fx.inputs.responder_pkcs8,
        icac_hex,
        fx.inputs.fabric_id,
        fx.inputs.responder_node_id,
        ipk,
        rcac_pub,
    );

    // Build a ResumptionRecord. The peer identity uses the responder's NOC
    // verbatim — on the resumption path this is what the record would have
    // stored from the original session.
    let responder_noc_for_peer =
        MatterCertificate::from_tlv(&hex::decode(&fx.inputs.responder_noc).unwrap()).unwrap();
    let record = ResumptionRecord {
        id: resumption_id,
        shared_secret,
        peer: PeerInfo {
            node_id: fx.inputs.responder_node_id,
            fabric_id: fx.inputs.fabric_id,
            noc: responder_noc_for_peer,
            session_id: 0,
        },
        expires_at: None,
    };

    let mut initiator = case_initiator_with_resumption_eph_key(
        initiator_creds,
        roots.clone(),
        fx.inputs.responder_node_id,
        fx.inputs.fabric_id,
        record.clone(),
        hex_to_array::<32>(&fx.inputs.initiator_eph_priv),
        hex_to_array::<32>(&fx.inputs.initiator_random),
        MatterTime::from_unix_secs(2_000_000_000),
    )
    .unwrap();
    // The capture script's patched RNG produced an all-zero fresh resumption
    // id (visible verbatim in the pinned sigma2_resume bytes) — inject it so
    // our responder's Sigma2_Resume is byte-comparable.
    let mut responder = case_responder_with_eph_key_and_resumption_id(
        responder_creds,
        roots,
        hex_to_array::<32>(&fx.inputs.responder_eph_priv),
        hex_to_array::<32>(&fx.inputs.responder_random),
        [0u8; 16],
        MatterTime::from_unix_secs(2_000_000_000),
    )
    .unwrap();

    // ── Sigma1 (with resumption fields) ──────────────────────────────────
    let our_sigma1 = initiator.start().unwrap();
    assert_eq!(
        hex::encode(&our_sigma1),
        fx.messages.sigma1,
        "Sigma1 (resumption) byte parity"
    );

    // ── Responder decides to accept resumption ────────────────────────────
    let outcome = responder.handle_sigma1(&our_sigma1).unwrap();
    let presented_id = match outcome {
        Sigma1Outcome::ResumptionRequested { id } => id,
        Sigma1Outcome::NewSession => panic!("expected ResumptionRequested"),
    };
    assert_eq!(
        presented_id, resumption_id,
        "presented resumption_id must match fixture"
    );

    responder.accept_resumption(record).unwrap();

    // ── Sigma2_Resume ─────────────────────────────────────────────────────
    let our_sigma2_resume = responder.next_message().unwrap();
    assert_eq!(
        hex::encode(&our_sigma2_resume),
        fx.messages
            .sigma2_resume
            .as_deref()
            .expect("fixture has sigma2_resume"),
        "Sigma2_Resume byte parity"
    );

    // ── Initiator processes Sigma2_Resume — no Sigma3 on resumption ───────
    initiator.handle_sigma2_resume(&our_sigma2_resume).unwrap();
    let init_out = initiator.finish().unwrap();
    let resp_out = responder.finish().unwrap();
    assert_eq!(
        init_out.keys.i2r_key, resp_out.keys.i2r_key,
        "resumed i2r keys must agree"
    );
    assert_eq!(
        init_out.keys.r2i_key, resp_out.keys.r2i_key,
        "resumed r2i keys must agree"
    );
}

/// Resumption-declined path: initiator presents a resumption ID the responder
/// doesn't recognise; responder falls back to the full new-session path.
///
/// Loads `test-vectors/case/handshake-resumption-declined.json` and replays
/// the full Sigma1 → Sigma2 → Sigma3 exchange that results when the responder
/// calls `reject_resumption()`.
///
/// Requires fixtures from `cargo xtask capture-case` (M4.3 Task 3).
///
/// (The old `#[ignore]` on this test blamed a `compute_sigma1_resume_mic`
/// composition mismatch — that was a misdiagnosis: the test fed a hardcoded
/// `[0xCC; 16]` record secret while the fixture MIC was computed from the
/// capture scenario's `resumption_shared_secret`. Feeding the fixture's
/// secret makes the Sigma1 — including its resume MIC — match byte-for-byte,
/// so the composition was correct all along.)
#[test]
#[allow(clippy::too_many_lines)] // long because it mirrors a multi-step protocol exchange
fn matter_js_byte_parity_resumption_declined() {
    let fx = load_fixture("handshake-resumption-declined");
    let ipk = hex_to_array::<16>(&fx.inputs.ipk);
    let rcac_pub = hex_to_array::<65>(&fx.inputs.rcac_public_key);
    let roots = build_trusted_roots(&fx.inputs.rcac_noc);

    let icac_hex = fx.inputs.icac_noc.as_deref();
    let initiator_creds = build_credentials_from_fixture(
        &fx.inputs.initiator_noc,
        &fx.inputs.initiator_pkcs8,
        icac_hex,
        fx.inputs.fabric_id,
        fx.inputs.initiator_node_id,
        ipk,
        rcac_pub,
    );
    let responder_creds = build_credentials_from_fixture(
        &fx.inputs.responder_noc,
        &fx.inputs.responder_pkcs8,
        icac_hex,
        fx.inputs.fabric_id,
        fx.inputs.responder_node_id,
        ipk,
        rcac_pub,
    );

    // Build the ResumptionRecord the initiator presents. The responder will
    // decline (the test drives `reject_resumption()`), but the record's
    // contents must match the fixture inputs exactly: the capture script
    // computed Sigma1's `initiator_resume_mic` from this id + secret, so the
    // Sigma1 byte-parity assertion depends on them.
    let bogus_id = ResumptionId(hex_to_array::<16>(
        fx.inputs
            .resumption_id
            .as_deref()
            .expect("bogus resumption_id in fixture"),
    ));
    let responder_noc_for_peer =
        MatterCertificate::from_tlv(&hex::decode(&fx.inputs.responder_noc).unwrap()).unwrap();
    let bogus_record = ResumptionRecord {
        id: bogus_id,
        shared_secret: hex_to_array::<32>(
            fx.inputs
                .resumption_shared_secret
                .as_deref()
                .expect("fixture has resumption_shared_secret"),
        ),
        peer: PeerInfo {
            node_id: fx.inputs.responder_node_id,
            fabric_id: fx.inputs.fabric_id,
            noc: responder_noc_for_peer,
            session_id: 0,
        },
        expires_at: None,
    };

    let mut initiator = case_initiator_with_resumption_eph_key(
        initiator_creds,
        roots.clone(),
        fx.inputs.responder_node_id,
        fx.inputs.fabric_id,
        bogus_record,
        hex_to_array::<32>(&fx.inputs.initiator_eph_priv),
        hex_to_array::<32>(&fx.inputs.initiator_random),
        MatterTime::from_unix_secs(2_000_000_000),
    )
    .unwrap();
    // Inject the capture script's fixed fresh resumption id so the encrypted
    // TBEData2 in our Sigma2 is byte-comparable with the fixture.
    let mut responder = case_responder_with_eph_key_and_resumption_id(
        responder_creds,
        roots,
        hex_to_array::<32>(&fx.inputs.responder_eph_priv),
        hex_to_array::<32>(&fx.inputs.responder_random),
        hex_to_array::<16>(
            fx.inputs
                .new_resumption_id
                .as_deref()
                .expect("fixture has new_resumption_id"),
        ),
        MatterTime::from_unix_secs(2_000_000_000),
    )
    .unwrap();

    // ── Sigma1 (with resumption fields) ──────────────────────────────────
    let our_sigma1 = initiator.start().unwrap();
    assert_eq!(
        hex::encode(&our_sigma1),
        fx.messages.sigma1,
        "Sigma1 (declined resumption) byte parity"
    );

    // ── Responder declines — falls back to new-session Sigma2 ─────────────
    responder.handle_sigma1(&our_sigma1).unwrap();
    responder.reject_resumption().unwrap();

    // ── Sigma2 (new-session fallback) ─────────────────────────────────────
    let our_sigma2 = responder.next_message().unwrap();
    assert_eq!(
        hex::encode(&our_sigma2),
        fx.messages
            .sigma2
            .as_deref()
            .expect("fixture has sigma2 (fallback path)"),
        "Sigma2 (fallback) byte parity"
    );

    // ── Sigma3 ────────────────────────────────────────────────────────────
    initiator.handle_sigma2(&our_sigma2).unwrap();
    let our_sigma3 = initiator.next_message().unwrap();
    assert_eq!(
        hex::encode(&our_sigma3),
        fx.messages
            .sigma3
            .as_deref()
            .expect("fixture has sigma3 (fallback path)"),
        "Sigma3 (fallback) byte parity"
    );

    responder.handle_sigma3(&our_sigma3).unwrap();

    // ── Session key agreement ─────────────────────────────────────────────
    let init_out = initiator.finish().unwrap();
    let resp_out = responder.finish().unwrap();
    assert_eq!(
        init_out.keys.i2r_key, resp_out.keys.i2r_key,
        "i2r session keys must agree on declined-resumption path"
    );
    assert_eq!(
        init_out.keys.r2i_key, resp_out.keys.r2i_key,
        "r2i session keys must agree on declined-resumption path"
    );
}