Skip to content

fix(cert): NotBefore=0 collides with CHIP no-expiry sentinel#454

Closed
glennswest wants to merge 1 commit into
project-chip:mainfrom
glennswest:fix/cert-notbefore-zero-sentinel
Closed

fix(cert): NotBefore=0 collides with CHIP no-expiry sentinel#454
glennswest wants to merge 1 commit into
project-chip:mainfrom
glennswest:fix/cert-notbefore-zero-sentinel

Conversation

@glennswest
Copy link
Copy Markdown

Summary

Matter operational certificates produced by NocGenerator with NotBefore = 0 (Matter epoch start, 2000-01-01 UTC) are rejected by the CHIP reference SDK on AddTrustedRootCertificate with Invalid signature (CHIP Error 0x14). The CHIP source (CHIPCert.cpp:1076-1106) explicitly warns that epochTime == 0 is used as the X.509 no-well-defined-expiration sentinel and is always re-emitted as GeneralizedTime "99991231235959Z" — regardless of whether it lands in NotBefore or NotAfter. On round-trip, CHIP reconstructs a different TBS DER than what rs-matter signed; the SHA-256 mismatches; ECDSA-verify fails.

rs-matter's own self-verify passes because the conversion is symmetric within rs-matter. The failure only surfaces on interop with the CHIP SDK — which is exactly the production case.

Fix

  1. NocGenerator: shift NotBefore from 0 → 1 (2000-01-01 00:00:01) in the three places that build RCAC, ICAC and NOC. Cert remains effectively unbounded on the lower end, but clears CHIP's sentinel collision.
  2. cert::builder BasicConstraints: drop pathLenConstraint from RCAC output. Matter Spec §6.5.11.1.2 mandates "pathLenConstraint SHALL NOT be present" for a Root CA cert; rs-matter was emitting INTEGER 1. Both ends carried the field through TLV→ASN.1, so signatures still verified, but strict validators (and the spec) reject it.

New regression test

test_rcac_self_verify builds an RCAC via RcacBuilder and re-parses it via CertRef::verify_chain_start().finalise(). Confirms the TLV→ASN.1→hash→verify pipeline is internally consistent. None of the existing builder tests verified signature — they only checked length bounds.

Test plan

  • cargo test -p rs-matter --lib cert::builder::tests --features=os — all 16 tests pass, including the new self-verify.
  • End-to-end commissioning against chip-tool's all-clusters-app (built from project-chip/connectedhomeip): PASE → ArmFailSafe → CSRRequest → AddTrustedRootCertificateAddNOC → CommissioningComplete. Responder log: OpCreds: successfully created fabric index 0x1 via AddNOC.

A Matter operational certificate (RCAC/ICAC/NOC) emitted by
rs-matter's NocGenerator with NotBefore = 0 (CHIP epoch start,
2000-01-01 00:00:00 UTC) cannot be verified by the CHIP reference
SDK: AddTrustedRootCertificate fails with 'Invalid signature'
(CHIP Error 0x14) on the responder side.

Root cause
----------
rs-matter signs the TBS over its own TLV-to-ASN.1 conversion, which
encodes NotBefore = 0 as UTCTime '000101000000Z' (the CHIP epoch
itself). CHIP's symmetric ChipEpochToASN1Time
(src/credentials/CHIPCert.cpp:1076-1106) treats any epochTime == 0
as the X.509 no-well-defined-expiration sentinel and re-emits it as
GeneralizedTime '99991231235959Z' irrespective of whether it
appears in NotBefore or NotAfter. The CHIP source comment is
explicit about this trap and states such certificates 'are not
usable with this code'.

End result: CHIP reconstructs a different TBS DER, computes a
different SHA-256, ECDSA-verify returns false. Self-verification
inside rs-matter passes (rs-matter is symmetric with itself), so
the failure only manifests on CHIP interop.

Fix
---
1. NocGenerator: shift NotBefore from 0 -> 1 (2000-01-01 00:00:01)
   in the three sites that build RCAC, ICAC and NOC. Keeps the cert
   effectively unbounded on the lower end while clearing CHIP's
   sentinel collision.

2. Cert builder BasicConstraints: drop pathLenConstraint from RCAC
   output. Matter Spec section 6.5.11.1.2 mandates 'pathLenConstraint
   SHALL NOT be present' for a Root CA certificate; rs-matter was
   emitting INTEGER 1. Both ends preserved the malformed field
   through TLV-to-ASN.1 conversion so signatures still matched, but
   strict validators (and the spec) reject it.

Regression test
---------------
test_rcac_self_verify builds an RCAC via RcacBuilder and re-parses
it via CertRef::verify_chain_start. Confirms the TLV-to-ASN.1-to-
hash-to-verify pipeline is internally consistent and catches future
sign/verify drift.

Diagnosis credit: confirmed end-to-end against chip-tool's
all-clusters-app (built from project-chip/connectedhomeip). After
the fix, commissioning completes: AddTrustedRootCertificate ->
AddNOC -> fabric_index assigned -> CommissioningComplete.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented May 18, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the certificate builder to comply with Matter Spec §6.5.11.1 by removing the pathLenConstraint from Root Certificate Authority Certificates (RCAC). It also addresses an interoperability issue where a not_before value of 0 caused signature mismatches in external implementations by incrementing it to 1. A new RCAC self-verification test was added. Review feedback recommends using the updated not_before value in the new test for consistency and refactoring duplicated Validity initializations into a module-level constant to improve maintainability.

ca_id: Some(0x1122_3344_5566_7788),
};
let validity = Validity {
not_before: 0,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To align with the main fix in this PR and avoid using a value known to cause interoperability issues, it's better to use 1 for not_before here instead of 0. This ensures the test validates the corrected behavior and serves as a better example for future use.

Suggested change
not_before: 0,
not_before: 1,

Comment on lines +126 to 142
// NotBefore MUST NOT be 0 (Matter epoch start, 2000-01-01).
// CHIP's ChipEpochToASN1Time treats epoch=0 as the "no
// well-defined expiration date" sentinel and re-emits it as
// GeneralizedTime "99991231235959Z" regardless of which field
// it appears in (see CHIPCert.cpp:1076-1106 and the
// explanatory comment about CHIP epoch 0 NotBefore producing
// an invalid TBS signature on round-trip).
//
// We sign over UTCTime "000101000000Z" (Matter epoch); CHIP
// would reconstruct GeneralizedTime "99991231235959Z" and the
// hash would mismatch. Using 1 second past the Matter epoch
// avoids the sentinel collision while keeping the cert
// effectively unbounded on the lower end.
let validity = crate::cert::builder::Validity {
not_before: 0, // epoch start
not_after: 0, // no expiry
not_before: 1, // 2000-01-01 00:00:01 — past CHIP's epoch=0 sentinel
not_after: 0, // no expiry (NotAfter sentinel is legitimate)
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code (comment and validity initialization) is duplicated in three places within this file (new, generate_icac, and generate_noc). To improve maintainability and reduce duplication, consider defining a module-level constant for the Validity struct.

For example, you could add this before impl NocGenerator:

// NotBefore MUST NOT be 0 (Matter epoch start, 2000-01-01).
// CHIP's ChipEpochToASN1Time treats epoch=0 as the "no
// well-defined expiration date" sentinel and re-emits it as
// GeneralizedTime "99991231235959Z" regardless of which field
// it appears in (see CHIPCert.cpp:1076-1106 and the
// explanatory comment about CHIP epoch 0 NotBefore producing
// an invalid TBS signature on round-trip).
//
// We sign over UTCTime "000101000000Z" (Matter epoch); CHIP
// would reconstruct GeneralizedTime "99991231235959Z" and the
// hash would mismatch.  Using 1 second past the Matter epoch
// avoids the sentinel collision while keeping the cert
// effectively unbounded on the lower end.
const DEFAULT_VALIDITY: crate::cert::builder::Validity = crate::cert::builder::Validity {
    not_before: 1, // 2000-01-01 00:00:01 — past CHIP's epoch=0 sentinel
    not_after: 0,  // no expiry (NotAfter sentinel is legitimate)
};

Then, you can replace this block and the other two occurrences with a single line.

Suggested change
// NotBefore MUST NOT be 0 (Matter epoch start, 2000-01-01).
// CHIP's ChipEpochToASN1Time treats epoch=0 as the "no
// well-defined expiration date" sentinel and re-emits it as
// GeneralizedTime "99991231235959Z" regardless of which field
// it appears in (see CHIPCert.cpp:1076-1106 and the
// explanatory comment about CHIP epoch 0 NotBefore producing
// an invalid TBS signature on round-trip).
//
// We sign over UTCTime "000101000000Z" (Matter epoch); CHIP
// would reconstruct GeneralizedTime "99991231235959Z" and the
// hash would mismatch. Using 1 second past the Matter epoch
// avoids the sentinel collision while keeping the cert
// effectively unbounded on the lower end.
let validity = crate::cert::builder::Validity {
not_before: 0, // epoch start
not_after: 0, // no expiry
not_before: 1, // 2000-01-01 00:00:01 — past CHIP's epoch=0 sentinel
not_after: 0, // no expiry (NotAfter sentinel is legitimate)
};
let validity = DEFAULT_VALIDITY;

@ivmarkov
Copy link
Copy Markdown
Contributor

The rs-matter-as-commissioner code-path is not exercised a lot (if at all) so far, but I think the timing to invest some effort there is exactly right, given that the support for rs-matter-as-a-client and in general for "client" clusters landed just a couple of days ago.

So if all tests pass, I'll merge this PR.

But with that said:

Test plan
End-to-end commissioning against chip-tool's all-clusters-app (built from project-chip/connectedhomeip): PASE → ArmFailSafe → CSRRequest → AddTrustedRootCertificate → AddNOC → CommissioningComplete. Responder log: OpCreds: successfully created fabric index 0x1 via AddNOC.

I don't think we have that at all. We have the beginnings of it in the commissioning test module, but it is (a) incomplete and (b) "rs-matter-against-rs-matter".

So if you have that full flow, how about you contribute it? That is, if you don't mind and if you believe (like me) that it is already commoditized so it best to belong to rs-matter itself.

Could be a separate PR of course, but wanted to open the topic.

@ivmarkov
Copy link
Copy Markdown
Contributor

@glennswest Forgot to mention - you also need to sign the CLA.

@github-actions
Copy link
Copy Markdown

PR #454: Size comparison from da2d79e to 357ac70

Full report (8 builds for (core), dimmable-light, onoff-light, onoff-light-bt, speaker)
platform target config section da2d79e 357ac70 change % change
(core) riscv32imac-unknown-none-elf infodefmt-optz-ltofat FLASH 436352 436352 0 0.0
RAM 70944 70944 0 0.0
thumbv6m-none-eabi infodefmt-optz-ltofat FLASH 354420 354420 0 0.0
RAM 66660 66660 0 0.0
thumbv7em-none-eabi infodefmt-optz-ltofat FLASH 332688 332688 0 0.0
RAM 66428 66428 0 0.0
x86_64-unknown-linux-gnu infologs-optz-ltofat FLASH 859667 859667 0 0.0
RAM 71258 71258 0 0.0
dimmable-light x86_64-unknown-linux-gnu infologs-optz-ltofat FLASH 1987104 1987104 0 0.0
RAM 60616 60616 0 0.0
onoff-light x86_64-unknown-linux-gnu infologs-optz-ltofat FLASH 1913920 1913920 0 0.0
RAM 59776 59776 0 0.0
onoff-light-bt x86_64-unknown-linux-gnu infologs-optz-ltofat FLASH 3355296 3355296 0 0.0
RAM 5776 5776 0 0.0
speaker x86_64-unknown-linux-gnu infologs-optz-ltofat FLASH 1950360 1950360 0 0.0
RAM 5472 5472 0 0.0

@ivmarkov
Copy link
Copy Markdown
Contributor

@glennswest Also please assess the code review feedback from Gemini. All code review feedback must be addressed with fixes, or else with justification why the feedback does not make sense.

glennswest added a commit to glennswest/rs-matter that referenced this pull request May 18, 2026
Introduces a new `controller` module that provides the IM-invoke and
orchestration primitives a controller needs to drive a Matter accessory
from a freshly-established PASE session through to
CommissioningComplete.

The accessory role is already well-covered in this crate. This module
is the inverse role — the thing that *commissions* accessories — and
adds a deliberately small, validated surface:

Public API (controller::commissioner):
- arm_fail_safe(matter, expiry_seconds, breadcrumb)
    GeneralCommissioning::ArmFailSafe over PASE, decodes
    ArmFailSafeResponse.errorCode → maps non-OK to FailSafeExpired.
- csr_request(matter, &csr_nonce) -> CsrPayload
    OperationalCredentials::CSRRequest with full CSRResponse decode.
    Returns NOCSRElements (octstr<400>) + AttestationSignature (64 B).
- decode_nocsr_elements(blob) -> DecodedNocsr<'_>
    TLV decoder for the NOCSRElements struct (§11.18.6.5.2) — pulls
    the PKCS#10 CSR DER and the device's nonce-echo so the controller
    can verify nonce freshness before issuing a NOC.
- add_noc(matter, noc, icac, ipk, admin_subject, admin_vendor_id)
    OperationalCredentials::AddNOC with NOCResponse status +
    FabricIndex decode. Non-OK status → AddNocRejected.
- commission_pase(matter, crypto, fabric_creds, admin_subject,
                  admin_vendor_id, fail_safe_secs) -> PaseCommissionResult
    Orchestrator that chains the above plus the two unexposed steps
    (AddTrustedRootCertificate, CommissioningComplete) and calls
    FabricCredentials::generate_device_credentials to mint the NOC.
    Returns the device-assigned FabricIndex + NodeID + cert chain.

Public API (controller::setup_code):
- Manual pairing-code + QR-code (MT:...) parser per Matter spec §5.1.4,
  with version / vendor-id / product-id / discriminator / passcode /
  discovery-capabilities-bitmask decoding.

Supporting additions to existing files:
- commissioner::FabricCredentials gains `root_secret_key()`,
  `rcac_id()`, and `from_persisted(...)` so a controller can
  persist its CA material and reload it on next boot.
- commissioner::NocGenerator gains the matching `root_secret_key()`
  accessor.

What is intentionally *not* in this PR (planned follow-ups):
- BLE central + BTP framing (the bootstrap transport).
- DCL fetch + Device Attestation chain verification.
- NetworkCommissioning cluster (Thread / Wi-Fi credential delivery).
- Operational discovery (_matter._tcp mDNS-SD client) + CASE.
- A higher-level Commissioner state machine wrapping all of the above.

Each of those is its own design conversation and they don't belong in
a single 3000-line PR.

Validation
----------
End-to-end test: examples/src/bin/pase_smoke_test.rs. With this PR
plus project-chip#454 (cert: NotBefore=0 sentinel fix — required for CHIP interop)
applied, the smoke test commissions chip-tool's all-clusters-app
end-to-end:

  ✓ PASE handshake
  ✓ ArmFailSafe(60s)
  ✓ CSRRequest → 243B NOCSRElements + 64B AttestationSignature
  ✓ NOCSRElements decoded; nonce verified
  ✓ AddTrustedRootCertificate
  ✓ AddNOC → device returns fabric_index=1
  ✓ CommissioningComplete

Responder log (chip-tool all-clusters-app):
  OpCreds: successfully created fabric index 0x1 via AddNOC

`cargo test -p rs-matter --lib --features=os,zbus` passes (532
existing tests; no behavior change to existing crates).

Without project-chip#454 the smoke test will still complete PASE/ArmFailSafe/
CSRRequest but AddTrustedRootCertificate is rejected by CHIP as
'Invalid signature' — see project-chip#454 for the root cause.
glennswest added a commit to glennswest/rs-matter that referenced this pull request May 18, 2026
Introduces a new `controller` module that provides the IM-invoke and
orchestration primitives a controller needs to drive a Matter accessory
from a freshly-established PASE session through to
CommissioningComplete.

The accessory role is already well-covered in this crate. This module
is the inverse role — the thing that *commissions* accessories — and
adds a deliberately small, validated surface:

Public API (controller::commissioner):
- arm_fail_safe(matter, expiry_seconds, breadcrumb)
    GeneralCommissioning::ArmFailSafe over PASE, decodes
    ArmFailSafeResponse.errorCode → maps non-OK to FailSafeExpired.
- csr_request(matter, &csr_nonce) -> CsrPayload
    OperationalCredentials::CSRRequest with full CSRResponse decode.
    Returns NOCSRElements (octstr<400>) + AttestationSignature (64 B).
- decode_nocsr_elements(blob) -> DecodedNocsr<'_>
    TLV decoder for the NOCSRElements struct (§11.18.6.5.2) — pulls
    the PKCS#10 CSR DER and the device's nonce-echo so the controller
    can verify nonce freshness before issuing a NOC.
- add_noc(matter, noc, icac, ipk, admin_subject, admin_vendor_id)
    OperationalCredentials::AddNOC with NOCResponse status +
    FabricIndex decode. Non-OK status → AddNocRejected.
- commission_pase(matter, crypto, fabric_creds, admin_subject,
                  admin_vendor_id, fail_safe_secs) -> PaseCommissionResult
    Orchestrator that chains the above plus the two unexposed steps
    (AddTrustedRootCertificate, CommissioningComplete) and calls
    FabricCredentials::generate_device_credentials to mint the NOC.
    Returns the device-assigned FabricIndex + NodeID + cert chain.

Public API (controller::setup_code):
- Manual pairing-code + QR-code (MT:...) parser per Matter spec §5.1.4,
  with version / vendor-id / product-id / discriminator / passcode /
  discovery-capabilities-bitmask decoding.

Supporting additions to existing files:
- commissioner::FabricCredentials gains `root_secret_key()`,
  `rcac_id()`, and `from_persisted(...)` so a controller can
  persist its CA material and reload it on next boot.
- commissioner::NocGenerator gains the matching `root_secret_key()`
  accessor.

What is intentionally *not* in this PR (planned follow-ups):
- BLE central + BTP framing (the bootstrap transport).
- DCL fetch + Device Attestation chain verification.
- NetworkCommissioning cluster (Thread / Wi-Fi credential delivery).
- Operational discovery (_matter._tcp mDNS-SD client) + CASE.
- A higher-level Commissioner state machine wrapping all of the above.

Each of those is its own design conversation and they don't belong in
a single 3000-line PR.

Validation
----------
End-to-end test: examples/src/bin/pase_smoke_test.rs. With this PR
plus project-chip#454 (cert: NotBefore=0 sentinel fix — required for CHIP interop)
applied, the smoke test commissions chip-tool's all-clusters-app
end-to-end:

  ✓ PASE handshake
  ✓ ArmFailSafe(60s)
  ✓ CSRRequest → 243B NOCSRElements + 64B AttestationSignature
  ✓ NOCSRElements decoded; nonce verified
  ✓ AddTrustedRootCertificate
  ✓ AddNOC → device returns fabric_index=1
  ✓ CommissioningComplete

Responder log (chip-tool all-clusters-app):
  OpCreds: successfully created fabric index 0x1 via AddNOC

`cargo test -p rs-matter --lib --features=os,zbus` passes (532
existing tests; no behavior change to existing crates).

Without project-chip#454 the smoke test will still complete PASE/ArmFailSafe/
CSRRequest but AddTrustedRootCertificate is rejected by CHIP as
'Invalid signature' — see project-chip#454 for the root cause.
glennswest added a commit to glennswest/rs-matter that referenced this pull request May 18, 2026
Introduces a new `controller` module that provides the IM-invoke and
orchestration primitives a controller needs to drive a Matter accessory
from a freshly-established PASE session through to
CommissioningComplete.

The accessory role is already well-covered in this crate. This module
is the inverse role — the thing that *commissions* accessories — and
adds a deliberately small, validated surface:

Public API (controller::commissioner):
- arm_fail_safe(matter, expiry_seconds, breadcrumb)
    GeneralCommissioning::ArmFailSafe over PASE, decodes
    ArmFailSafeResponse.errorCode → maps non-OK to FailSafeExpired.
- csr_request(matter, &csr_nonce) -> CsrPayload
    OperationalCredentials::CSRRequest with full CSRResponse decode.
    Returns NOCSRElements (octstr<400>) + AttestationSignature (64 B).
- decode_nocsr_elements(blob) -> DecodedNocsr<'_>
    TLV decoder for the NOCSRElements struct (§11.18.6.5.2) — pulls
    the PKCS#10 CSR DER and the device's nonce-echo so the controller
    can verify nonce freshness before issuing a NOC.
- add_noc(matter, noc, icac, ipk, admin_subject, admin_vendor_id)
    OperationalCredentials::AddNOC with NOCResponse status +
    FabricIndex decode. Non-OK status → AddNocRejected.
- commission_pase(matter, crypto, fabric_creds, admin_subject,
                  admin_vendor_id, fail_safe_secs) -> PaseCommissionResult
    Orchestrator that chains the above plus the two unexposed steps
    (AddTrustedRootCertificate, CommissioningComplete) and calls
    FabricCredentials::generate_device_credentials to mint the NOC.
    Returns the device-assigned FabricIndex + NodeID + cert chain.

Public API (controller::setup_code):
- Manual pairing-code + QR-code (MT:...) parser per Matter spec §5.1.4,
  with version / vendor-id / product-id / discriminator / passcode /
  discovery-capabilities-bitmask decoding.

Supporting additions to existing files:
- commissioner::FabricCredentials gains `root_secret_key()`,
  `rcac_id()`, and `from_persisted(...)` so a controller can
  persist its CA material and reload it on next boot.
- commissioner::NocGenerator gains the matching `root_secret_key()`
  accessor.

What is intentionally *not* in this PR (planned follow-ups):
- BLE central + BTP framing (the bootstrap transport).
- DCL fetch + Device Attestation chain verification.
- NetworkCommissioning cluster (Thread / Wi-Fi credential delivery).
- Operational discovery (_matter._tcp mDNS-SD client) + CASE.
- A higher-level Commissioner state machine wrapping all of the above.

Each of those is its own design conversation and they don't belong in
a single 3000-line PR.

Validation
----------
End-to-end test: examples/src/bin/pase_smoke_test.rs. With this PR
plus project-chip#454 (cert: NotBefore=0 sentinel fix — required for CHIP interop)
applied, the smoke test commissions chip-tool's all-clusters-app
end-to-end:

  ✓ PASE handshake
  ✓ ArmFailSafe(60s)
  ✓ CSRRequest → 243B NOCSRElements + 64B AttestationSignature
  ✓ NOCSRElements decoded; nonce verified
  ✓ AddTrustedRootCertificate
  ✓ AddNOC → device returns fabric_index=1
  ✓ CommissioningComplete

Responder log (chip-tool all-clusters-app):
  OpCreds: successfully created fabric index 0x1 via AddNOC

`cargo test -p rs-matter --lib --features=os,zbus` passes (532
existing tests; no behavior change to existing crates).

Without project-chip#454 the smoke test will still complete PASE/ArmFailSafe/
CSRRequest but AddTrustedRootCertificate is rejected by CHIP as
'Invalid signature' — see project-chip#454 for the root cause.
@ivmarkov
Copy link
Copy Markdown
Contributor

See #458

@ivmarkov ivmarkov closed this May 22, 2026
ivmarkov added a commit that referenced this pull request May 22, 2026
This is #454 except with
all Gemini code review comments addressed (and future comments to be
addressed too).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants