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}