Skip to main content

matter_controller/
fabric.rs

1//! Fabric creation. Mints the fabric trust root (RCAC + IPK) and the
2//! controller's **stable** commissioner operational identity in one shot.
3//! The commissioner NOC is minted here exactly once and persisted; every
4//! later CASE handshake reuses it (retiring the earlier per-call minting).
5
6use std::sync::Arc;
7
8use matter_cert::MatterTime;
9use matter_commissioning::{issue_icac, issue_noc, FabricRecord, NocRng, VerifiedCsr};
10use matter_crypto::{RingSigner, Signer};
11
12use crate::error::Error;
13use crate::state::{CommissionerIdentity, FabricEntry, IcacIdentity};
14
15/// Inputs for creating a new fabric.
16///
17/// `#[non_exhaustive]`: future fabric-creation knobs (e.g. an explicit IPK or
18/// an ICAC tier) can be added without a semver break. Construct via
19/// [`FabricConfig::new`] from outside this crate.
20#[derive(Debug, Clone)]
21#[non_exhaustive]
22pub struct FabricConfig {
23    /// Matter fabric identifier (spec §6.2.1).
24    pub fabric_id: u64,
25    /// RCAC subject DN's `rcac-id` value.
26    pub rcac_id: u64,
27    /// The stable node ID the controller takes on this fabric.
28    pub commissioner_node_id: u64,
29    /// `(not_before, not_after)` validity for the RCAC and commissioner NOC.
30    ///
31    /// Pass a real wall-clock `not_before` — e.g.
32    /// `MatterTime::from_unix_secs(current_unix_time)`, typically backdated a
33    /// little (an hour is plenty) to tolerate device clock skew. Use
34    /// `MatterTime::NO_EXPIRY` for `not_after` if the fabric should not
35    /// expire.
36    ///
37    /// [`MatterController::create_fabric`](crate::MatterController::create_fabric)
38    /// validates this window and returns
39    /// [`crate::Error::InvalidFabricValidity`] rather than letting a bad one
40    /// reach a device. Four ways to get it wrong:
41    ///
42    /// - **`not_before` at the Matter epoch** (`MatterTime(0)`, equivalently
43    ///   `MatterTime::from_unix_secs(0)`) — the reporter's failure in issue
44    ///   #111. The cause is a signature mismatch, not a validity policy:
45    ///   chip's `ChipEpochToASN1Time`
46    ///   (`connectedhomeip/src/credentials/CHIPCert.cpp`) maps epoch 0 to the
47    ///   X.509 sentinel `99991231235959Z` for **both** `notBefore` and
48    ///   `notAfter`, so a device rebuilding the X.509 TBS from our TLV
49    ///   certificate hashes `99991231235959Z` where we signed
50    ///   `20000101000000Z` and the **signature check fails**. chip's own
51    ///   comment: such certificates "are not usable with this code" and
52    ///   "attempted installation of such certficates will fail during
53    ///   commissioning" — surfacing as an opaque `IM status 0x85` rejection of
54    ///   `AddTrustedRootCertificate` deep in commissioning.
55    /// - **`not_before` far in the future** — most often a *millisecond*
56    ///   timestamp handed to `MatterTime::from_unix_secs`, which saturates to
57    ///   `MatterTime(u32::MAX)` (≈ year 2136). This one is worse than a
58    ///   rejection: `ValidateChipRCAC` deliberately does not check RCAC
59    ///   validity times (`CHIPCert.cpp`), so `AddTrustedRootCertificate`
60    ///   *succeeds* and the fabric half-commissions, then every CASE session
61    ///   fails with `kNotYetValid`.
62    /// - **An already-expired window** — the symmetric twin of the one above,
63    ///   most often a `(not_before, not_after)` pair copied from an older
64    ///   document. The ordering check passes and `not_before` is in the past, so
65    ///   only a comparison against the clock catches it; the same
66    ///   `ValidateChipRCAC` exemption means the expired root *installs* and CASE
67    ///   then fails with `kExpired`.
68    /// - **A units mistake in `not_after`** — `from_unix_secs` clamps any
69    ///   pre-2000 Unix time to `MatterTime(0)`, and `MatterTime(0)` **is**
70    ///   `MatterTime::NO_EXPIRY`, so such a mistake silently yields "never
71    ///   expires" — the opposite of the intent — and cannot be rejected here.
72    pub validity: (MatterTime, MatterTime),
73    /// When `true`, `create_fabric` mints an intermediate CA (ICAC) under
74    /// the RCAC and signs the commissioner NOC (and, later, all NOCs
75    /// issued on this fabric) under the ICAC instead of directly under the
76    /// RCAC. Defaults to `false` (the flat RCAC->NOC path) via
77    /// [`FabricConfig::new`].
78    pub issue_icac: bool,
79}
80
81impl FabricConfig {
82    /// Construct a fabric configuration.
83    ///
84    /// This is the supported construction path now that [`FabricConfig`] is
85    /// `#[non_exhaustive]`; the public fields remain readable/writable in
86    /// place. See [`FabricConfig::validity`] for what to pass as `validity` —
87    /// in particular, a real wall-clock `not_before`, not the Matter epoch.
88    #[must_use]
89    pub fn new(
90        fabric_id: u64,
91        rcac_id: u64,
92        commissioner_node_id: u64,
93        validity: (MatterTime, MatterTime),
94    ) -> Self {
95        Self {
96            fabric_id,
97            rcac_id,
98            commissioner_node_id,
99            validity,
100            issue_icac: false,
101        }
102    }
103}
104
105/// Reject a `(not_before, not_after)` validity window devices would reject
106/// (issue #111), before any key generation runs.
107///
108/// - `not_before` must not be the Matter epoch (`MatterTime(0)`, i.e.
109///   2000-01-01T00:00:00Z) — the reporter's evidenced failure: a certificate
110///   with that `notBefore` round-trips through chip's `ChipEpochToASN1Time` as
111///   `99991231235959Z`, so the rebuilt X.509 TBS no longer matches what we
112///   signed and the device's **signature** check fails, surfacing as an opaque
113///   `IM status 0x85` on `AddTrustedRootCertificate` deep in commissioning.
114///   See [`FabricConfig::validity`] for the full citation.
115/// - `not_after` must be strictly after `not_before`, UNLESS `not_after` is
116///   `MatterTime::NO_EXPIRY` — that sentinel is a legitimate "does not
117///   expire" and is exempt from the ordering check.
118fn validate_validity(window: (MatterTime, MatterTime)) -> Result<(), Error> {
119    let (not_before, not_after) = window;
120    if not_before.0 == 0 {
121        return Err(Error::InvalidFabricValidity(format!(
122            "not_before is {not_before:?} (the Matter epoch, 2000-01-01T00:00:00Z) — pass a \
123             real wall-clock time, e.g. MatterTime::from_unix_secs(current_unix_time)"
124        )));
125    }
126    // Deliberate divergence from the C++ reference: chip's
127    // `GenerateChipX509Cert.cpp` accepts a zero-width window
128    // (`ValidityEnd >= ValidityStart`); we reject `not_after == not_before`
129    // because a certificate that is valid for zero seconds is never what a
130    // caller meant, and rejecting it here is cheaper than debugging a fabric
131    // that expires the instant it is created.
132    if not_after != MatterTime::NO_EXPIRY && not_after <= not_before {
133        return Err(Error::InvalidFabricValidity(format!(
134            "not_after ({not_after:?}) must be after not_before ({not_before:?}), or \
135             MatterTime::NO_EXPIRY for no expiry"
136        )));
137    }
138    Ok(())
139}
140
141/// How far ahead of the controller's own clock a `not_before` may sit before
142/// [`validate_validity_against_now`] refuses it.
143///
144/// Rationale: a legitimate `not_before` is "about now" — callers are told to
145/// *backdate* it for device clock skew, never to postdate it. Anything ahead of
146/// now is therefore only ever disagreement between the caller's time source and
147/// this host's clock, and a full day is far more slack than any real deployment
148/// needs (chip's own commissioning flows assume the two agree to within
149/// minutes). It is still tight enough to catch every plausible units mistake:
150/// a millisecond timestamp saturates `MatterTime::from_unix_secs` to
151/// `u32::MAX`, ≈ 110 years ahead.
152const MAX_NOT_BEFORE_AHEAD_SECS: u32 = 24 * 60 * 60;
153
154/// Render a [`MatterTime`] for an error message in both scales: its raw
155/// Matter-epoch seconds (what the type holds, so the reader can match it against
156/// the value they passed) and the Unix seconds it means (what they can compare
157/// against `date +%s`, which is the only one of the two anyone can read).
158fn describe(t: MatterTime) -> String {
159    format!("MatterTime({}) = unix {}", t.0, t.to_unix_secs())
160}
161
162/// Reject the halves of the validity window that can only be judged against a
163/// clock: a `not_before` implausibly far ahead of `now`, and a `not_after`
164/// already in the past.
165///
166/// Separate from [`validate_validity`] because it needs a clock reading, which
167/// keeps [`create_fabric`] itself pure — the caller (the actor, which already
168/// holds `current_matter_time()`) supplies `now`.
169///
170/// Both are the issue-#111 failure mode — a certificate a device installs but
171/// cannot use — in its *worse* form, where the device does not reject the
172/// certificate at install time and so nothing names the cause:
173///
174/// - **`not_before` too far ahead.** `ValidateChipRCAC`
175///   (`connectedhomeip/src/credentials/CHIPCert.cpp`) explicitly does not check
176///   RCAC `notBefore`/`notAfter`, so `AddTrustedRootCertificate` succeeds, the
177///   fabric half-commissions, and then every CASE session fails with
178///   `kNotYetValid`.
179/// - **`not_after` already past.** The exact symmetric twin — the same
180///   `ValidateChipRCAC` exemption lets an *expired* root install just as
181///   happily, and every CASE session afterwards fails with `kExpired` on the
182///   commissioner NOC. The plausible route here is a window copied from an older
183///   document: ordering passes, `not_before` is in the past so the upper bound
184///   passes, and we would otherwise mint and persist a fabric that expired
185///   months ago.
186pub(crate) fn validate_validity_against_now(
187    window: (MatterTime, MatterTime),
188    now: MatterTime,
189) -> Result<(), Error> {
190    let (not_before, not_after) = window;
191    let limit = now.0.saturating_add(MAX_NOT_BEFORE_AHEAD_SECS);
192    if not_before.0 > limit {
193        return Err(Error::InvalidFabricValidity(format!(
194            "not_before ({}) is more than {MAX_NOT_BEFORE_AHEAD_SECS}s ahead of this host's clock \
195             ({}) — the certificate would install but be not-yet-valid, failing every CASE session \
196             afterwards. A common cause is passing a MILLISECOND timestamp to \
197             MatterTime::from_unix_secs, which saturates to MatterTime(u32::MAX)",
198            describe(not_before),
199            describe(now),
200        )));
201    }
202    // `NO_EXPIRY` is exempt: it is numerically `MatterTime(0)` and so would
203    // otherwise read as "expired at the Matter epoch".
204    if not_after != MatterTime::NO_EXPIRY && not_after <= now {
205        return Err(Error::InvalidFabricValidity(format!(
206            "not_after ({}) is already in the past — this host's clock reads {}. The certificate \
207             would install (ValidateChipRCAC skips RCAC validity times) but every CASE session \
208             afterwards would fail with kExpired. Pass a future not_after, or \
209             MatterTime::NO_EXPIRY for no expiry",
210            describe(not_after),
211            describe(now),
212        )));
213    }
214    Ok(())
215}
216
217/// Create a fabric: generate the RCAC root key + self-signed RCAC, a fresh
218/// IPK, the commissioner operational keypair, and the commissioner NOC.
219///
220/// The returned [`FabricEntry`] is fully persistable (private keys captured
221/// as PKCS#8 DER) and has no devices yet.
222///
223/// # Errors
224///
225/// Returns [`Error::InvalidFabricValidity`] if `cfg.validity` names a window
226/// devices will reject (see [`FabricConfig::validity`], issue #111);
227/// [`Error::Signer`] if key generation fails; or [`Error::Noc`] if RCAC
228/// construction or NOC issuance fails.
229pub(crate) fn create_fabric(cfg: &FabricConfig, rng: &dyn NocRng) -> Result<FabricEntry, Error> {
230    // Validate the validity window FIRST — before any key generation — so a
231    // bad window is rejected for free instead of surfacing later as an
232    // opaque device-side rejection mid-commissioning (issue #111).
233    validate_validity(cfg.validity)?;
234
235    // 1. RCAC root key + self-signed root certificate.
236    let (root_signer, rcac_pkcs8) =
237        RingSigner::generate().map_err(|e| Error::Signer(e.to_string()))?;
238    let root_arc: Arc<dyn Signer> = Arc::new(root_signer);
239    let mut fabric_record = FabricRecord::new_root_only(
240        cfg.fabric_id,
241        root_arc,
242        cfg.validity.0,
243        cfg.validity.1,
244        cfg.rcac_id,
245        rng,
246    )?;
247
248    // 1b. Optionally mint an ICAC tier under the RCAC. This must happen
249    //     BEFORE the commissioner NOC is issued below, so the commissioner
250    //     NOC itself is signed under the ICAC (matching what a real
251    //     ICAC-tier fabric does for every NOC it issues).
252    let icac_identity = if cfg.issue_icac {
253        let (icac_signer_raw, icac_pkcs8) =
254            RingSigner::generate().map_err(|e| Error::Signer(e.to_string()))?;
255        let icac_public_key = icac_signer_raw.public_key().clone();
256        // Single-ICAC fabric: reuse `cfg.rcac_id` as the ICAC's `IcacId`
257        // DN value too. `RcacId` and `IcacId` are distinct DN attribute
258        // types (spec §6.5.5), so the shared numeric id is unambiguous —
259        // there is no collision between "RCAC id 7" and "ICAC id 7".
260        let icac_cert = issue_icac(
261            &fabric_record,
262            cfg.rcac_id,
263            &icac_public_key,
264            cfg.validity,
265            rng,
266        )
267        .map_err(Error::Noc)?;
268        fabric_record.icac_signer = Some(Arc::new(icac_signer_raw));
269        fabric_record.icac_cert = Some(icac_cert.clone());
270        Some(IcacIdentity {
271            cert: icac_cert,
272            pkcs8: icac_pkcs8,
273        })
274    } else {
275        None
276    };
277
278    // 2. Commissioner operational keypair.
279    let (comm_signer, comm_pkcs8) =
280        RingSigner::generate().map_err(|e| Error::Signer(e.to_string()))?;
281    let comm_public_key = comm_signer.public_key().clone();
282
283    // 3. Mint the commissioner NOC over our own key. We generated the key
284    //    ourselves, so there is no device CSR to verify — `VerifiedCsr`
285    //    here asserts "this public key is trusted for issuance", which is
286    //    sound for our own identity. When `fabric_record.icac_signer`/
287    //    `icac_cert` are `Some` (set just above), `issue_noc` signs this
288    //    under the ICAC instead of the RCAC.
289    let verified = VerifiedCsr {
290        public_key: comm_public_key,
291    };
292    let noc = issue_noc(
293        &fabric_record,
294        &verified,
295        cfg.commissioner_node_id,
296        &[], // no CASE Authenticated Tags for the controller identity
297        cfg.validity,
298        rng,
299    )?;
300
301    Ok(FabricEntry {
302        fabric_id: cfg.fabric_id,
303        ipk: fabric_record.identity_protection_key,
304        rcac_cert: fabric_record.root_cert.clone(),
305        rcac_pkcs8,
306        commissioner: CommissionerIdentity {
307            node_id: cfg.commissioner_node_id,
308            operational_pkcs8: comm_pkcs8,
309            noc,
310        },
311        devices: Vec::new(),
312        group_keys: Vec::new(),
313        outbound_group_counter: 0,
314        icd_clients: Vec::new(),
315        icac: icac_identity,
316    })
317}
318
319#[cfg(test)]
320#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md allows unwrap/expect with justification.
321mod tests {
322    use super::*;
323    use matter_commissioning::SystemNocRng;
324
325    fn sample_cfg() -> FabricConfig {
326        FabricConfig::new(
327            0xDEAD_BEEF_0000_0001,
328            1,
329            0x0000_0000_0000_0001,
330            (
331                MatterTime::from_unix_secs(1_700_000_000),
332                MatterTime::NO_EXPIRY,
333            ),
334        )
335    }
336
337    #[test]
338    fn new_constructor_sets_all_fields() {
339        // `FabricConfig` is `#[non_exhaustive]`; `new` is the supported
340        // construction path. Verify it populates every field.
341        let cfg = FabricConfig::new(
342            7,
343            9,
344            3,
345            (MatterTime::from_unix_secs(1), MatterTime::NO_EXPIRY),
346        );
347        assert_eq!(cfg.fabric_id, 7);
348        assert_eq!(cfg.rcac_id, 9);
349        assert_eq!(cfg.commissioner_node_id, 3);
350        assert_eq!(cfg.validity.0, MatterTime::from_unix_secs(1));
351    }
352
353    #[test]
354    fn creates_fabric_with_no_devices() {
355        let fabric = create_fabric(&sample_cfg(), &SystemNocRng).expect("create");
356        assert_eq!(fabric.fabric_id, 0xDEAD_BEEF_0000_0001);
357        assert_eq!(fabric.commissioner.node_id, 1);
358        assert!(fabric.devices.is_empty());
359        assert!(!fabric.rcac_pkcs8.is_empty());
360        assert!(!fabric.commissioner.operational_pkcs8.is_empty());
361    }
362
363    #[test]
364    fn commissioner_noc_is_signed_by_the_rcac() {
365        let fabric = create_fabric(&sample_cfg(), &SystemNocRng).expect("create");
366        let rcac_key = fabric.rcac_cert.public_key();
367        fabric
368            .commissioner
369            .noc
370            .verify_signed_by(rcac_key)
371            .expect("commissioner NOC must verify under the RCAC");
372    }
373
374    #[test]
375    fn default_path_has_no_icac_and_noc_issuer_is_rcac() {
376        // `issue_icac = false` (the `FabricConfig::new` default) must not
377        // mint an ICAC, and the commissioner NOC's issuer must be the RCAC
378        // subject (the flat RCAC->NOC path, byte-unchanged from Task 7).
379        let fabric = create_fabric(&sample_cfg(), &SystemNocRng).expect("create");
380        assert!(fabric.icac.is_none());
381        assert_eq!(fabric.commissioner.noc.issuer(), fabric.rcac_cert.subject());
382    }
383
384    #[test]
385    fn issue_icac_true_mints_chain_and_signs_commissioner_noc_under_icac() {
386        let mut cfg = sample_cfg();
387        cfg.issue_icac = true;
388        let entry = create_fabric(&cfg, &SystemNocRng).expect("create");
389
390        // The fabric entry carries a minted ICAC.
391        let icac = entry.icac.clone().expect("icac must be Some");
392
393        // Reconstructing the runtime FabricRecord restores both the ICAC
394        // signer and cert.
395        let rec = entry.to_fabric_record().expect("to_fabric_record");
396        assert!(rec.icac_signer.is_some());
397        assert!(rec.icac_cert.is_some());
398
399        // The commissioner NOC's issuer DN is the ICAC's subject, not the
400        // RCAC's.
401        assert_eq!(entry.commissioner.noc.issuer(), icac.cert.subject());
402        assert_ne!(entry.commissioner.noc.issuer(), entry.rcac_cert.subject());
403
404        // 3-tier chain linkage + signature verification:
405        // RCAC issued/signed the ICAC...
406        assert_eq!(icac.cert.issuer(), entry.rcac_cert.subject());
407        icac.cert
408            .verify_signed_by(entry.rcac_cert.public_key())
409            .expect("icac must verify under the rcac's public key");
410        // ...and the ICAC issued/signed the commissioner NOC.
411        assert_eq!(entry.commissioner.noc.issuer(), icac.cert.subject());
412        entry
413            .commissioner
414            .noc
415            .verify_signed_by(icac.cert.public_key())
416            .expect("commissioner noc must verify under the icac's public key");
417
418        // Snapshot round-trip preserves `icac` as `Some` with a matching
419        // cert.
420        let state = crate::state::ControllerState::new(vec![entry.clone()]);
421        let bytes = crate::snapshot::serialize(&state).expect("serialize");
422        let restored = crate::snapshot::deserialize(&bytes).expect("deserialize");
423        let restored_icac = restored.fabrics[0]
424            .icac
425            .clone()
426            .expect("icac must round-trip as Some");
427        assert_eq!(
428            restored_icac.cert.to_tlv().expect("tlv"),
429            icac.cert.to_tlv().expect("tlv"),
430            "restored icac cert must byte-match the original"
431        );
432    }
433
434    #[test]
435    fn rejects_not_before_at_matter_epoch_zero() {
436        // Issue #111's evidenced failure: `MatterTime(0)` as `not_before`
437        // (whether via the raw tuple or `from_unix_secs(0)`, which saturates
438        // to the same value) must be rejected here, not surface later as an
439        // opaque device-side `IM status 0x85`.
440        let mut cfg = sample_cfg();
441        cfg.validity = (MatterTime::from_unix_secs(0), MatterTime::NO_EXPIRY);
442        let err = create_fabric(&cfg, &SystemNocRng).expect_err("epoch-zero not_before");
443        assert!(
444            matches!(err, Error::InvalidFabricValidity(_)),
445            "expected InvalidFabricValidity, got {err:?}"
446        );
447        assert!(
448            err.to_string().contains("not_before"),
449            "error must name not_before: {err}"
450        );
451    }
452
453    #[test]
454    fn rejects_not_before_at_matter_epoch_zero_via_raw_constructor() {
455        let mut cfg = sample_cfg();
456        cfg.validity = (MatterTime(0), MatterTime::NO_EXPIRY);
457        let err = create_fabric(&cfg, &SystemNocRng).expect_err("epoch-zero not_before");
458        assert!(matches!(err, Error::InvalidFabricValidity(_)));
459    }
460
461    #[test]
462    fn rejects_inverted_validity_window() {
463        let mut cfg = sample_cfg();
464        cfg.validity = (
465            MatterTime::from_unix_secs(1_700_000_100),
466            MatterTime::from_unix_secs(1_700_000_000),
467        );
468        let err = create_fabric(&cfg, &SystemNocRng).expect_err("inverted window");
469        assert!(
470            matches!(err, Error::InvalidFabricValidity(_)),
471            "expected InvalidFabricValidity, got {err:?}"
472        );
473        assert!(
474            err.to_string().contains("not_after"),
475            "error must name not_after: {err}"
476        );
477    }
478
479    #[test]
480    fn rejects_empty_validity_window() {
481        // not_after == not_before (neither is NO_EXPIRY): a zero-width
482        // window can never be valid.
483        let mut cfg = sample_cfg();
484        let t = MatterTime::from_unix_secs(1_700_000_000);
485        cfg.validity = (t, t);
486        let err = create_fabric(&cfg, &SystemNocRng).expect_err("empty window");
487        assert!(matches!(err, Error::InvalidFabricValidity(_)));
488    }
489
490    #[test]
491    fn accepts_no_expiry_sentinel() {
492        // `MatterTime::NO_EXPIRY` for not_after is legitimate and exempt from
493        // the not_after > not_before ordering check, even though its
494        // underlying value is numerically <= a real not_before.
495        let cfg = sample_cfg(); // already (from_unix_secs(1_700_000_000), NO_EXPIRY)
496        create_fabric(&cfg, &SystemNocRng).expect("NO_EXPIRY must be accepted");
497    }
498
499    #[test]
500    fn rejects_not_before_far_in_the_future() {
501        // The seconds-vs-milliseconds mistake: a millisecond timestamp handed
502        // to `from_unix_secs` saturates to `MatterTime(u32::MAX)` (≈ 2136).
503        // With `not_after = NO_EXPIRY` the ordering check is exempted, so only
504        // this upper bound catches it — and the device would *accept* the RCAC
505        // (`ValidateChipRCAC` skips validity times) then fail every CASE
506        // session with `kNotYetValid`.
507        let now = MatterTime::from_unix_secs(1_700_000_000);
508        let window = (
509            MatterTime::from_unix_secs(1_700_000_000_000),
510            MatterTime::NO_EXPIRY,
511        );
512        assert_eq!(window.0, MatterTime(u32::MAX), "precondition: saturated");
513        let err = validate_validity_against_now(window, now)
514            .expect_err("millisecond not_before must be rejected");
515        assert!(
516            matches!(err, Error::InvalidFabricValidity(_)),
517            "expected InvalidFabricValidity, got {err:?}"
518        );
519        assert!(
520            err.to_string().contains("MILLISECOND"),
521            "error must name the likely cause: {err}"
522        );
523    }
524
525    #[test]
526    fn not_before_within_the_skew_window_is_accepted() {
527        // Just inside the allowance: exactly `now + MAX_NOT_BEFORE_AHEAD_SECS`
528        // is still fine (the check is strictly-greater-than).
529        let now = MatterTime::from_unix_secs(1_700_000_000);
530        let edge = MatterTime(now.0 + MAX_NOT_BEFORE_AHEAD_SECS);
531        validate_validity_against_now((edge, MatterTime::NO_EXPIRY), now)
532            .expect("not_before exactly at the skew limit must be accepted");
533    }
534
535    #[test]
536    fn not_before_one_second_past_the_skew_window_is_rejected() {
537        // The other side of the same boundary.
538        let now = MatterTime::from_unix_secs(1_700_000_000);
539        let over = MatterTime(now.0 + MAX_NOT_BEFORE_AHEAD_SECS + 1);
540        let err = validate_validity_against_now((over, MatterTime::NO_EXPIRY), now)
541            .expect_err("one second past the skew limit must be rejected");
542        assert!(matches!(err, Error::InvalidFabricValidity(_)));
543    }
544
545    #[test]
546    fn rejects_an_already_expired_window() {
547        // A window copied from an older document: Nov 2023 -> Nov 2024, with
548        // "now" well past both. Ordering passes and `not_before` is in the past
549        // so the upper bound passes — only the comparison against the clock
550        // catches it. Without it we would mint and persist a root the device
551        // installs happily (ValidateChipRCAC skips RCAC validity times) and a
552        // commissioner NOC every CASE session then rejects as kExpired.
553        let now = MatterTime::from_unix_secs(1_795_000_000);
554        let window = (
555            MatterTime::from_unix_secs(1_700_000_000),
556            MatterTime::from_unix_secs(1_731_536_000),
557        );
558        let err = validate_validity_against_now(window, now)
559            .expect_err("an expired not_after must be rejected");
560        assert!(
561            matches!(err, Error::InvalidFabricValidity(_)),
562            "expected InvalidFabricValidity, got {err:?}"
563        );
564        assert!(
565            err.to_string().contains("not_after"),
566            "error must name not_after: {err}"
567        );
568        assert!(
569            err.to_string().contains("unix 1731536000"),
570            "error must render not_after in readable unix seconds: {err}"
571        );
572    }
573
574    #[test]
575    fn not_after_exactly_at_now_is_rejected() {
576        // The boundary: expiring at this instant leaves zero usable lifetime.
577        let now = MatterTime::from_unix_secs(1_700_000_000);
578        let window = (MatterTime::from_unix_secs(1_699_000_000), now);
579        let err = validate_validity_against_now(window, now)
580            .expect_err("not_after == now must be rejected");
581        assert!(matches!(err, Error::InvalidFabricValidity(_)));
582    }
583
584    #[test]
585    fn not_after_one_second_past_now_is_accepted() {
586        // The other side of the same boundary. A one-second-long fabric is
587        // useless in practice, but it is the caller's call to make: this check
588        // only refuses windows that are *already* over.
589        let now = MatterTime::from_unix_secs(1_700_000_000);
590        let window = (
591            MatterTime::from_unix_secs(1_699_000_000),
592            MatterTime(now.0 + 1),
593        );
594        validate_validity_against_now(window, now)
595            .expect("a not_after one second ahead must be accepted");
596    }
597
598    #[test]
599    fn no_expiry_not_after_is_exempt_from_the_expiry_check() {
600        // `NO_EXPIRY` is numerically MatterTime(0), i.e. <= every real `now`,
601        // so it would read as "expired at the Matter epoch" without the
602        // sentinel exemption.
603        let now = MatterTime::from_unix_secs(1_700_000_000);
604        let window = (
605            MatterTime::from_unix_secs(1_699_000_000),
606            MatterTime::NO_EXPIRY,
607        );
608        validate_validity_against_now(window, now)
609            .expect("NO_EXPIRY must be exempt from the expiry check");
610    }
611
612    #[test]
613    fn backdated_not_before_is_always_accepted() {
614        // The documented recommendation (backdate an hour for device clock
615        // skew) must never trip the upper bound.
616        let now = MatterTime::from_unix_secs(1_700_000_000);
617        let backdated = MatterTime::from_unix_secs(1_700_000_000 - 3600);
618        validate_validity_against_now((backdated, MatterTime::NO_EXPIRY), now)
619            .expect("a backdated not_before must be accepted");
620    }
621
622    #[test]
623    fn commissioner_signer_matches_persisted_noc_key() {
624        // The persisted operational key must correspond to the NOC's
625        // public key — i.e. we can actually use the identity we minted.
626        let fabric = create_fabric(&sample_cfg(), &SystemNocRng).expect("create");
627        let signer = fabric.commissioner_signer().expect("reload signer");
628        assert_eq!(
629            signer.public_key().as_bytes(),
630            fabric.commissioner.noc.public_key().as_bytes(),
631            "persisted op key must match the NOC subject public key"
632        );
633    }
634}