Skip to content

Commit 6ba1f38

Browse files
cecillerestyled-commits
authored andcommitted
Post certification checks script (project-chip#33256)
* Post certification checks script This test is used to evaluate that all the proper post-certification work has been done to make a Matter device production ready. This test ensure that: - DAC chain is valid and spec compliant, and chains up to a PAA that is registered in the main net DCL - CD is valid and, signed by one of the known CSA signing certs and is marked as a production CD - DCL entries for this device and vendor have all been registered - TestEventTriggers have been turned off This test is performed over PASE on a factory reset device. To run this test, first build and install the python chip wheel files, then add the extra dependencies. From the root: ./scripts/build_python.sh -i py source py/bin/activate pip install opencv-python requests click_option_group * Restyled by autopep8 * linter * Add a production CD check to DA-1.7 * report out results better * fix post cert check in 1.2 * Add a check for software versions NOTE: not done yet because as it turns out hex strings appear to be valid base64. I mean, they're garbage, but this test won't find that. Need additional tests to detect hex vs. base64 * fix accidental checkin * Proper checksum check on the downloaded OTA * Update credentials/fetch_paa_certs_from_dcl.py * Don't include CSA root as a PAA * Restyled by autopep8 * rename file to production_device_checks.py --------- Co-authored-by: Restyled.io <commits@restyled.io>
1 parent 188e07e commit 6ba1f38

File tree

6 files changed

+579
-27
lines changed

6 files changed

+579
-27
lines changed

credentials/fetch-paa-certs-from-dcl.py credentials/fetch_paa_certs_from_dcl.py

+56-13
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
PRODUCTION_NODE_URL_REST = "https://on.dcl.csa-iot.org"
3838
TEST_NODE_URL_REST = "https://on.test-net.dcl.csa-iot.org"
3939

40+
MATTER_CERT_CA_SUBJECT = "MFIxDDAKBgNVBAoMA0NTQTEsMCoGA1UEAwwjTWF0dGVyIENlcnRpZmljYXRpb24gYW5kIFRlc3RpbmcgQ0ExFDASBgorBgEEAYKifAIBDARDNUEw"
41+
MATTER_CERT_CA_SUBJECT_KEY_ID = "97:E4:69:D0:C5:04:14:C2:6F:C7:01:F7:7E:94:77:39:09:8D:F6:A5"
42+
4043

4144
def parse_paa_root_certs(cmdpipe, paa_list):
4245
"""
@@ -73,13 +76,14 @@ def parse_paa_root_certs(cmdpipe, paa_list):
7376
else:
7477
if b': ' in line:
7578
key, value = line.split(b': ')
76-
result[key.strip(b' -').decode("utf-8")] = value.strip().decode("utf-8")
79+
result[key.strip(b' -').decode("utf-8")
80+
] = value.strip().decode("utf-8")
7781
parse_paa_root_certs.counter += 1
7882
if parse_paa_root_certs.counter % 2 == 0:
7983
paa_list.append(copy.deepcopy(result))
8084

8185

82-
def write_paa_root_cert(certificate, subject):
86+
def write_cert(certificate, subject):
8387
filename = 'dcld_mirror_' + \
8488
re.sub('[^a-zA-Z0-9_-]', '', re.sub('[=, ]', '_', subject))
8589
with open(filename + '.pem', 'w+') as outfile:
@@ -93,7 +97,8 @@ def write_paa_root_cert(certificate, subject):
9397
serialization.Encoding.DER)
9498
outfile.write(der_certificate)
9599
except (IOError, ValueError) as e:
96-
print(f"ERROR: Failed to convert {filename + '.pem'}: {str(e)}. Skipping...")
100+
print(
101+
f"ERROR: Failed to convert {filename + '.pem'}: {str(e)}. Skipping...")
97102

98103

99104
def parse_paa_root_cert_from_dcld(cmdpipe):
@@ -133,7 +138,38 @@ def use_dcld(dcld, production, cmdlist):
133138
@optgroup.option('--paa-trust-store-path', default='paa-root-certs', type=str, metavar='PATH', help="PAA trust store path (default: paa-root-certs)")
134139
def main(use_main_net_dcld, use_test_net_dcld, use_main_net_http, use_test_net_http, paa_trust_store_path):
135140
"""DCL PAA mirroring tools"""
141+
fetch_paa_certs(use_main_net_dcld, use_test_net_dcld, use_main_net_http, use_test_net_http, paa_trust_store_path)
142+
143+
144+
def get_cert_from_rest(rest_node_url, subject, subject_key_id):
145+
response = requests.get(
146+
f"{rest_node_url}/dcl/pki/certificates/{subject}/{subject_key_id}").json()["approvedCertificates"]["certs"][0]
147+
certificate = response["pemCert"].rstrip("\n")
148+
subject = response["subjectAsText"]
149+
return certificate, subject
150+
151+
152+
def fetch_cd_signing_certs(store_path):
153+
''' Only supports using main net http currently.'''
154+
rest_node_url = PRODUCTION_NODE_URL_REST
155+
os.makedirs(store_path, exist_ok=True)
156+
original_dir = os.getcwd()
157+
os.chdir(store_path)
136158

159+
cd_signer_ids = requests.get(
160+
f"{rest_node_url}/dcl/pki/child-certificates/{MATTER_CERT_CA_SUBJECT}/{MATTER_CERT_CA_SUBJECT_KEY_ID}").json()['childCertificates']['certIds']
161+
for signer in cd_signer_ids:
162+
subject = signer['subject']
163+
subject_key_id = signer['subjectKeyId']
164+
certificate, subject = get_cert_from_rest(rest_node_url, subject, subject_key_id)
165+
166+
print(f"Downloaded CD signing cert with subject: {subject}")
167+
write_cert(certificate, subject)
168+
169+
os.chdir(original_dir)
170+
171+
172+
def fetch_paa_certs(use_main_net_dcld, use_test_net_dcld, use_main_net_http, use_test_net_http, paa_trust_store_path):
137173
production = False
138174
dcld = use_test_net_dcld
139175

@@ -148,36 +184,43 @@ def main(use_main_net_dcld, use_test_net_dcld, use_main_net_http, use_test_net_h
148184
rest_node_url = PRODUCTION_NODE_URL_REST if production else TEST_NODE_URL_REST
149185

150186
os.makedirs(paa_trust_store_path, exist_ok=True)
187+
original_dir = os.getcwd()
151188
os.chdir(paa_trust_store_path)
152189

153190
if use_rest:
154-
paa_list = requests.get(f"{rest_node_url}/dcl/pki/root-certificates").json()["approvedRootCertificates"]["certs"]
191+
paa_list = requests.get(
192+
f"{rest_node_url}/dcl/pki/root-certificates").json()["approvedRootCertificates"]["certs"]
155193
else:
156194
cmdlist = ['query', 'pki', 'all-x509-root-certs']
157195

158-
cmdpipe = subprocess.Popen(use_dcld(dcld, production, cmdlist), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
196+
cmdpipe = subprocess.Popen(use_dcld(
197+
dcld, production, cmdlist), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
159198

160199
paa_list = []
161200
parse_paa_root_certs.counter = 0
162201
parse_paa_root_certs(cmdpipe, paa_list)
163202

164203
for paa in paa_list:
204+
if paa['subject'] == MATTER_CERT_CA_SUBJECT and paa['subjectKeyId'] == MATTER_CERT_CA_SUBJECT_KEY_ID:
205+
# Don't include the CD signing cert as a PAA root.
206+
continue
165207
if use_rest:
166-
response = requests.get(
167-
f"{rest_node_url}/dcl/pki/certificates/{paa['subject']}/{paa['subjectKeyId']}").json()["approvedCertificates"]["certs"][0]
168-
certificate = response["pemCert"]
169-
subject = response["subjectAsText"]
208+
certificate, subject = get_cert_from_rest(rest_node_url, paa['subject'], paa['subjectKeyId'])
170209
else:
171-
cmdlist = ['query', 'pki', 'x509-cert', '-u', paa['subject'], '-k', paa['subjectKeyId']]
210+
cmdlist = ['query', 'pki', 'x509-cert', '-u',
211+
paa['subject'], '-k', paa['subjectKeyId']]
172212

173-
cmdpipe = subprocess.Popen(use_dcld(dcld, production, cmdlist), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
213+
cmdpipe = subprocess.Popen(use_dcld(
214+
dcld, production, cmdlist), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
174215

175216
(certificate, subject) = parse_paa_root_cert_from_dcld(cmdpipe)
176217

177218
certificate = certificate.rstrip('\n')
178219

179-
print(f"Downloaded certificate with subject: {subject}")
180-
write_paa_root_cert(certificate, subject)
220+
print(f"Downloaded PAA certificate with subject: {subject}")
221+
write_cert(certificate, subject)
222+
223+
os.chdir(original_dir)
181224

182225

183226
if __name__ == "__main__":

src/controller/python/chip/ChipDeviceCtrl.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -254,13 +254,18 @@ class DeviceProxyWrapper():
254254
that is not an issue that needs to be accounted for and it will become very apparent
255255
if that happens.
256256
'''
257+
class DeviceProxyType(enum.Enum):
258+
OPERATIONAL = enum.auto(),
259+
COMMISSIONEE = enum.auto(),
257260

258-
def __init__(self, deviceProxy: ctypes.c_void_p, dmLib=None):
261+
def __init__(self, deviceProxy: ctypes.c_void_p, proxyType, dmLib=None):
259262
self._deviceProxy = deviceProxy
260263
self._dmLib = dmLib
264+
self._proxyType = proxyType
261265

262266
def __del__(self):
263-
if (self._dmLib is not None and hasattr(builtins, 'chipStack') and builtins.chipStack is not None):
267+
# Commissionee device proxies are owned by the DeviceCommissioner. See #33031
268+
if (self._proxyType == self.DeviceProxyType.OPERATIONAL and self.self._dmLib is not None and hasattr(builtins, 'chipStack') and builtins.chipStack is not None):
264269
# This destructor is called from any threading context, including on the Matter threading context.
265270
# So, we cannot call chipStack.Call or chipStack.CallAsyncWithCompleteCallback which waits for the posted work to
266271
# actually be executed. Instead, we just post/schedule the work and move on.
@@ -861,7 +866,23 @@ def GetClusterHandler(self):
861866

862867
return self._Cluster
863868

864-
def GetConnectedDeviceSync(self, nodeid, allowPASE: bool = True, timeoutMs: int = None):
869+
def FindOrEstablishPASESession(self, setupCode: str, nodeid: int, timeoutMs: int = None) -> typing.Optional[DeviceProxyWrapper]:
870+
''' Returns CommissioneeDeviceProxy if we can find or establish a PASE connection to the specified device'''
871+
self.CheckIsActive()
872+
returnDevice = c_void_p(None)
873+
res = self._ChipStack.Call(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
874+
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
875+
if res.is_success:
876+
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.COMMISSIONEE, self._dmLib)
877+
878+
self.EstablishPASESession(setupCode, nodeid)
879+
880+
res = self._ChipStack.Call(lambda: self._dmLib.pychip_GetDeviceBeingCommissioned(
881+
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
882+
if res.is_success:
883+
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.COMMISSIONEE, self._dmLib)
884+
885+
def GetConnectedDeviceSync(self, nodeid, allowPASE=True, timeoutMs: int = None):
865886
''' Gets an OperationalDeviceProxy or CommissioneeDeviceProxy for the specified Node.
866887
867888
nodeId: Target's Node ID
@@ -882,7 +903,7 @@ def GetConnectedDeviceSync(self, nodeid, allowPASE: bool = True, timeoutMs: int
882903
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
883904
if res.is_success:
884905
logging.info('Using PASE connection')
885-
return DeviceProxyWrapper(returnDevice)
906+
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.COMMISSIONEE, self._dmLib)
886907

887908
class DeviceAvailableClosure():
888909
def deviceAvailable(self, device, err):
@@ -916,7 +937,7 @@ def deviceAvailable(self, device, err):
916937
if returnDevice.value is None:
917938
returnErr.raise_on_error()
918939

919-
return DeviceProxyWrapper(returnDevice, self._dmLib)
940+
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.OPERATIONAL, self._dmLib)
920941

921942
async def WaitForActive(self, nodeid, *, timeoutSeconds=30.0, stayActiveDurationMs=30000):
922943
''' Waits a LIT ICD device to become active. Will send a StayActive command to the device on active to allow human operations.
@@ -948,7 +969,7 @@ async def GetConnectedDevice(self, nodeid, allowPASE: bool = True, timeoutMs: in
948969
self.devCtrl, nodeid, byref(returnDevice)), timeoutMs)
949970
if res.is_success:
950971
logging.info('Using PASE connection')
951-
return DeviceProxyWrapper(returnDevice)
972+
return DeviceProxyWrapper(returnDevice, DeviceProxyWrapper.DeviceProxyType.COMMISSIONEE, self._dmLib)
952973

953974
eventLoop = asyncio.get_running_loop()
954975
future = eventLoop.create_future()
@@ -987,7 +1008,7 @@ def deviceAvailable(self, device, err):
9871008
else:
9881009
await future
9891010

990-
return DeviceProxyWrapper(future.result(), self._dmLib)
1011+
return DeviceProxyWrapper(future.result(), DeviceProxyWrapper.DeviceProxyType.OPERATIONAL, self._dmLib)
9911012

9921013
def ComputeRoundTripTimeout(self, nodeid, upperLayerProcessingTimeoutMs: int = 0):
9931014
''' Returns a computed timeout value based on the round-trip time it takes for the peer at the other end of the session to

src/python_testing/TC_DA_1_2.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import re
2121

2222
import chip.clusters as Clusters
23+
from basic_composition_support import BasicCompositionTests
2324
from chip.interaction_model import InteractionModelError, Status
2425
from chip.tlv import TLVReader
2526
from cryptography import x509
@@ -104,7 +105,7 @@ def parse_ids_from_certs(dac: x509.Certificate, pai: x509.Certificate) -> tuple(
104105
# default is 'credentials/development/cd-certs'.
105106

106107

107-
class TC_DA_1_2(MatterBaseTest):
108+
class TC_DA_1_2(MatterBaseTest, BasicCompositionTests):
108109
def desc_TC_DA_1_2(self):
109110
return "Device Attestation Request Validation [DUT - Commissionee]"
110111

@@ -164,6 +165,11 @@ def steps_TC_DA_1_2(self):
164165
async def test_TC_DA_1_2(self):
165166
is_ci = self.check_pics('PICS_SDK_CI_ONLY')
166167
cd_cert_dir = self.user_params.get("cd_cert_dir", 'credentials/development/cd-certs')
168+
post_cert_test = self.user_params.get("post_cert_test", False)
169+
170+
do_test_over_pase = self.user_params.get("use_pase_only", False)
171+
if do_test_over_pase:
172+
self.connect_over_pase(self.default_controller)
167173

168174
# Commissioning - done
169175
self.step(0)
@@ -308,7 +314,9 @@ async def test_TC_DA_1_2(self):
308314
self.step("6.8")
309315
asserts.assert_in(version_number, range(0, 65535), "Version number out of range")
310316
self.step("6.9")
311-
if is_ci:
317+
if post_cert_test:
318+
asserts.assert_equal(certification_type, 2, "Certification declaration is not marked as production.")
319+
elif is_ci:
312320
asserts.assert_in(certification_type, [0, 1, 2], "Certification type is out of range")
313321
else:
314322
asserts.assert_in(certification_type, [1, 2], "Certification type is out of range")
@@ -392,7 +400,7 @@ async def test_TC_DA_1_2(self):
392400
self.mark_current_step_skipped()
393401

394402
self.step(12)
395-
proxy = self.default_controller.GetConnectedDeviceSync(self.dut_node_id, False)
403+
proxy = self.default_controller.GetConnectedDeviceSync(self.dut_node_id, do_test_over_pase)
396404
asserts.assert_equal(len(proxy.attestationChallenge), 16, "Attestation challenge is the wrong length")
397405
attestation_tbs = elements + proxy.attestationChallenge
398406

src/python_testing/basic_composition_support.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ def ConvertValue(value) -> Any:
9898

9999

100100
class BasicCompositionTests:
101+
def connect_over_pase(self, dev_ctrl):
102+
setupCode = self.matter_test_config.qr_code_content if self.matter_test_config.qr_code_content is not None else self.matter_test_config.manual_code
103+
asserts.assert_true(setupCode, "Require either --qr-code or --manual-code.")
104+
dev_ctrl.FindOrEstablishPASESession(setupCode, self.dut_node_id)
105+
101106
def dump_wildcard(self, dump_device_composition_path: typing.Optional[str]):
102107
node_dump_dict = {endpoint_id: MatterTlvToJson(self.endpoints_tlv[endpoint_id]) for endpoint_id in self.endpoints_tlv}
103108
logging.debug(f"Raw TLV contents of Node: {json.dumps(node_dump_dict, indent=2)}")
@@ -116,10 +121,8 @@ async def setup_class_helper(self, default_to_pase: bool = True):
116121
dump_device_composition_path: Optional[str] = self.user_params.get("dump_device_composition_path", None)
117122

118123
if do_test_over_pase:
119-
setupCode = self.matter_test_config.qr_code_content if self.matter_test_config.qr_code_content is not None else self.matter_test_config.manual_code
120-
asserts.assert_true(setupCode, "Require either --qr-code or --manual-code.")
124+
self.connect_over_pase(dev_ctrl)
121125
node_id = self.dut_node_id
122-
dev_ctrl.EstablishPASESession(setupCode, node_id)
123126
else:
124127
# Using the already commissioned node
125128
node_id = self.dut_node_id

src/python_testing/matter_testing_support.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1670,7 +1670,7 @@ def run_tests_no_exit(test_class: MatterBaseTest, matter_test_config: MatterTest
16701670

16711671
if hooks:
16721672
# Right now, we only support running a single test class at once,
1673-
# but it's relatively easy to exapand that to make the test process faster
1673+
# but it's relatively easy to expand that to make the test process faster
16741674
# TODO: support a list of tests
16751675
hooks.start(count=1)
16761676
# Mobly gives the test run time in seconds, lets be a bit more precise

0 commit comments

Comments
 (0)