blob: 100f58137789661d7b53add682bd037608873985 [file] [log] [blame]
#
# Copyright (c) 2023 Project CHIP Authors
# All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import os
import random
import chip.clusters as Clusters
from chip.interaction_model import InteractionModelError, Status
from chip.tlv import TLVReader
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat._oid import ExtensionOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec, utils
from ecdsa.curves import curve_by_name
from matter_testing_support import MatterBaseTest, TestStep, async_test_body, default_matter_test_main, hex_from_bytes, type_matches
from mobly import asserts
from pyasn1.codec.der.decoder import decode as der_decoder
from pyasn1.error import PyAsn1Error
from pyasn1.type import univ
from pyasn1_modules import rfc5652
def get_value_for_oid(oid_dotted_str: str, cert: x509.Certificate) -> str:
rdn = list(filter(lambda rdn: oid_dotted_str in rdn.oid.dotted_string, cert.subject))
if len(rdn) != 1:
return None
return rdn[0].value.strip()
def parse_ids_from_subject(cert: x509.Certificate) -> tuple([str, str]):
vid_str = get_value_for_oid('1.3.6.1.4.1.37244.2.1', cert)
pid_str = get_value_for_oid('1.3.6.1.4.1.37244.2.2', cert)
return vid_str, pid_str
def parse_single_vidpid_from_common_name(commonName: str, tag_str: str) -> str:
sp = commonName.split(tag_str)
if (len(sp)) != 2:
return None
s = sp[1][:4]
if not s.isupper() or len(s) != 4:
asserts.fail(f"Improperly encoded PID or VID when using fallback encoding {tag_str}:{s}")
return None
return s
def parse_ids_from_common_name(cert: x509.Certificate) -> tuple([str, str]):
common = get_value_for_oid('2.5.4.3', cert)
vid_str = parse_single_vidpid_from_common_name(common, 'Mvid:')
pid_str = parse_single_vidpid_from_common_name(common, 'Mpid:')
return vid_str, pid_str
def parse_ids_from_certs(dac: x509.Certificate, pai: x509.Certificate) -> tuple([int, int, int, int]):
dac_vid_str, dac_pid_str = parse_ids_from_subject(dac)
pai_vid_str, pai_pid_str = parse_ids_from_subject(pai)
# Fallback methods - parse from commonName
if dac_vid_str is None and dac_pid_str is None:
dac_vid_str, dac_pid_str = parse_ids_from_common_name(dac)
if pai_vid_str is None and pai_pid_str is None:
pai_vid_str, pai_pid_str = parse_ids_from_common_name(pai)
# PID is not required in the PAI
asserts.assert_true(dac_vid_str is not None, "VID must be present in the DAC")
asserts.assert_true(dac_pid_str is not None, "PID must be present in the DAC")
asserts.assert_true(pai_vid_str is not None, "VID must be present in the PAI")
dac_vid = int(dac_vid_str, 16)
dac_pid = int(dac_pid_str, 16)
pai_vid = int(pai_vid_str, 16)
if pai_pid_str:
pai_pid = int(pai_pid_str, 16)
else:
pai_pid = None
return dac_vid, dac_pid, pai_vid, pai_pid
# To set the directory for the CD certificates use
# --string-arg cd_cert_dir:'your_directory_name'
# ex. --string-arg cd_cert_dir:'credentials/development/cd-certs'
# default is 'credentials/development/cd-certs'.
class TC_DA_1_2(MatterBaseTest):
def desc_TC_DA_1_2(self):
return "Device Attestation Request Validation [DUT - Commissionee]"
def steps_TC_DA_1_2(self):
return [TestStep(0, "Commission DUT if not done", is_commissioning=True),
TestStep(1, "TH1 generates 32-byte AttestationNonce and saves as `nonce", ""),
TestStep(2, "TH1 sends AttestationRequest Command to the DUT with AttestationNonce set to `nonce`",
"Verify AttestationResponse is received"),
TestStep("3a", "TH1 sends CertificateChainRequest Command with CertificateType field set to DACCertificate (1) to DUT to obtain DAC",
"DUT responds with CertificateChainResponse the DAC certificate in X.509v3 format with size ⇐ 600 bytes"),
TestStep("3b", "TH1 sends CertificateChainRequest Command with CertificateType field set to PAICertificate (2) to DUT to obtain PAI",
"DUT responds with CertificateChainResponse the PAI certificate in X.509v3 format with size ⇐ 600 bytes"),
TestStep("4a", "TH1 Reads the VendorID attribute of the Basic Information cluster and saves it as `basic_info_vendor_id`"),
TestStep("4b", "TH1 Reads the ProductID attribute of the Basic Information cluster and saves it as `basic_info_product_id`"),
TestStep(5, "Extract the attestation_elements_message structure fields from the AttestationResponse"),
TestStep("5.1", "Verify AttestationElements field size",
"AttestationElements field size should not be greater than RESP_MAX(900 bytes)"),
TestStep("5.2", "Verify certification_declaration format",
"certification_declaration is present and is an octet string representation CMS-format certification declaration, as described in section 6.3.1"),
TestStep("6.1", "Verify CD format_version", "format_version = 1"),
TestStep("6.2", "Verify CD vendor_id",
"vendor_id field matches `basic_info_vendor_id` and is in the standard vendor ID range"),
TestStep("6.3", "Verify CD product_id_array", "product_id_array field contains `basic_info_product_id`"),
TestStep("6.4", "Verify CD device_type_id", "device_type_id has a value between 0 and (2^31 - 1)"),
TestStep("6.5", "Verify CD certificate_id", "certificate_id has a length of 19"),
TestStep("6.6", "Verify CD security level", "security level = 0"),
TestStep("6.7", "Verify CD security_information", "security_information = 0"),
TestStep("6.8", "Verify CD version_number", "version_number is an integer in range 0..65535"),
TestStep("6.9", "Verify CD certification_type", "certification_type has a value between 1..2"),
TestStep("7.0", "Extract the Vendor ID (VID) and Product ID (PID) from the DAC. Extract the VID from the PAI. Extract the PID from the PAI, if present",
"VID and PID are present and properly encoded in the DAC. VID is present and properly encoded in the PAI. If the PID is present in the PAI, it is properly encoded"),
TestStep("7.1", "", "If the dac_origin_vendor_id is present in the CD, confirm the dac_origin_product_id is also present. If the dac_origin_vendor_id is not present in the CD, confirm the dac_origin_product_id is also not present."),
TestStep("7.2", "If the Certification Declaration has both the dac_origin_vendor_id and the dac_origin_product_id fields, verify dac_origin fields",
("* The Vendor ID (VID) in the DAC subject and PAI subject are the same as the dac_origin_vendor_id field in the Certification Declaration.\n"
"* The Product ID (PID) in the DAC subject is same as the dac_origin_product_id field in the Certification Declaration.\n"
"* If it is present in the PAI certificate, the Product ID (PID) in the subject is same as the dac_origin_product_id field in the Certification Declaration.\n")),
TestStep("7.3", "If the Certification Declaration has neither the dac_origin_vendor_id nor the dac_origin_product_id fields, verify the vendor_id and product_id_array fields",
("* The Vendor ID (VID) in the DAC subject and PAI subject are the same as the vendor_id field in the Certification Declaration.\n"
"* The Product ID (PID) subject DN in the DAC is contained in the product_id_array field in the Certification Declaration.\n"
"* If it is present in the PAI certificate, the Product ID (PID) in the subject is contained in the product_id_array field in the Certification Declaration.\n")),
TestStep(8, "If the Certification Declaration has authorized_paa_list, check that the authority_key_id extension of the PAI matches one found in the authorized_paa_list",
"PAA from PAI authority_key_id extension matches one found in authorized_paa_list"),
TestStep(9, "Verify that the certification_declaration CMS enveloped can be verified with the well-known Certification Declaration public key used to originally sign the Certification Declaration", "Signature verification passes"),
TestStep(10, "Verify attestation_nonce", ("* attestation_nonce is present in the attestation_elements_message structure\n"
"* attestation_nonce value matches the AttestationNonce field value sent in the AttestationRequest Command sent by the commissioner\n"
"* attestation_nonce is a 32 byte-long octet string\n")),
TestStep(11, "If firmware_information is present, verify firmware information type",
"firmware_information is an octet string"),
TestStep(12, "Using Crypto_Verify cryptographic primitive, validate that the AttestationSignature from the AttestationResponse Command is valid if verified against a message constructed by concatenating AttestationElements with the attestation challenge associated with the secure session over which the AttestationResponse was obtained, using the subject public key found in the DAC.", "Signature is valid"),
TestStep(13, "TH1 sends AttestationRequest Command with Invalid AttestationNonce (size > 32 bytes) as the field to the DUT.",
"Verify DUT responds w/ status INVALID_COMMAND(0x85)"),
TestStep(14, "TH1 sends AttestationRequest Command with invalid AttestationNonce (size < 32 bytes) as the field to the DUT.",
"Verify that the DUT reports an INVALID_COMMAND error"),
]
@async_test_body
async def test_TC_DA_1_2(self):
is_ci = self.check_pics('PICS_SDK_CI_ONLY')
cd_cert_dir = self.user_params.get("cd_cert_dir", 'credentials/development/cd-certs')
# Commissioning - done
self.step(0)
opcreds = Clusters.Objects.OperationalCredentials
basic = Clusters.Objects.BasicInformation
self.step(1)
nonce = random.randbytes(32)
self.step(2)
attestation_resp = await self.send_single_cmd(cmd=opcreds.Commands.AttestationRequest(attestationNonce=nonce))
asserts.assert_true(type_matches(attestation_resp, opcreds.Commands.AttestationResponse),
"DUT returned invalid response to AttestationRequest")
self.step("3a")
type = opcreds.Enums.CertificateChainTypeEnum.kDACCertificate
dac_resp = await self.send_single_cmd(cmd=opcreds.Commands.CertificateChainRequest(certificateType=type))
asserts.assert_true(type_matches(dac_resp, opcreds.Commands.CertificateChainResponse),
"DUT returned invalid response to CertificateChainRequest")
der_dac = dac_resp.certificate
asserts.assert_less_equal(len(der_dac), 600, "Returned DAC is > 600 bytes")
# This throws an exception for a non-x509 cert
try:
parsed_dac = x509.load_der_x509_certificate(der_dac)
except ValueError:
asserts.assert_true(False, "Unable to parse certificate from CertificateChainResponse")
asserts.assert_equal(parsed_dac.version, x509.Version.v3, "DUT returned incorrect certificate type")
self.step("3b")
type = opcreds.Enums.CertificateChainTypeEnum.kPAICertificate
pai_resp = await self.send_single_cmd(cmd=opcreds.Commands.CertificateChainRequest(certificateType=type))
asserts.assert_true(type_matches(pai_resp, opcreds.Commands.CertificateChainResponse),
"DUT returned invalid response to CertificateChainRequest")
der_pai = pai_resp.certificate
asserts.assert_less_equal(len(der_pai), 600, "Returned PAI is > 600 bytes")
# This throws an exception for a non-x509 cert
try:
parsed_pai = x509.load_der_x509_certificate(der_pai)
except ValueError:
asserts.assert_true(False, "Unable to parse certificate from CertificateChainResponse")
asserts.assert_equal(parsed_pai.version, x509.Version.v3, "DUT returned incorrect certificate type")
self.step("4a")
basic_info_vendor_id = await self.read_single_attribute_check_success(basic, basic.Attributes.VendorID)
self.step("4b")
basic_info_product_id = await self.read_single_attribute_check_success(basic, basic.Attributes.ProductID)
self.step(5)
elements = attestation_resp.attestationElements
signature_attestation_raw = attestation_resp.attestationSignature
self.step("5.1")
asserts.assert_less_equal(len(elements), 900, "AttestationElements field is more than 900 bytes")
self.step("5.2")
decoded = TLVReader(elements).get()["Any"]
# Certification declaration is tag 1
asserts.assert_in(1, decoded.keys(), "CD is not present in the attestation elements")
cd_der = decoded[1]
try:
temp, _ = der_decoder(cd_der, asn1Spec=rfc5652.ContentInfo())
except PyAsn1Error:
asserts.fail("Unable to decode CD - improperly encoded DER")
# turn this into a dict so I don't have to keep parsing tuples
layer1 = dict(temp)
id_sha256 = univ.ObjectIdentifier('2.16.840.1.101.3.4.2.1')
asserts.assert_equal(layer1['contentType'], rfc5652.id_signedData, "Incorrect object type")
# uh, is this actually right? Doesn't the spec say enveloped data?
temp, _ = der_decoder(layer1['content'].asOctets(), asn1Spec=rfc5652.SignedData())
signed_data = dict(temp)
asserts.assert_equal(signed_data['version'], 3, "Signed data version is not 3")
asserts.assert_equal(len(signed_data['digestAlgorithms']), 1, "More than one digest algorithm listed")
# DigestAlgorithmIdentifier
algo_id = dict(signed_data['digestAlgorithms'][0])
asserts.assert_equal(algo_id['algorithm'], id_sha256, "Reported digest algorithm is not SHA256")
encap_content_info = dict(signed_data['encapContentInfo'])
id_pkcs7_data = univ.ObjectIdentifier('1.2.840.113549.1.7.1')
asserts.assert_equal(encap_content_info['eContentType'], id_pkcs7_data, "Incorrect encapsulated content type")
cd_tlv = bytes(encap_content_info['eContent'])
# Check the signer info
# There should be only one signer info
asserts.assert_equal(len(signed_data['signerInfos']), 1, "Too many signer infos provided")
# version should be 3
signer_info = dict(signed_data['signerInfos'][0])
asserts.assert_equal(signer_info['version'], 3, "Incorrect version on signer info")
# subject key identifier needs to match the connectivity standards aliance key
subject_key_identifier = bytes(dict(signer_info['sid'])['subjectKeyIdentifier'])
# digest algorithm is sha256, only one allowed
algo_id = dict(signer_info['digestAlgorithm'])
asserts.assert_equal(algo_id['algorithm'], id_sha256, "Incorrect digest algorithm for the signer info")
# signature algorithm is ecdsa-with-sha256
id_ecdsa_with_sha256 = univ.ObjectIdentifier('1.2.840.10045.4.3.2')
algo_id = dict(signer_info['signatureAlgorithm'])
asserts.assert_equal(algo_id['algorithm'], id_ecdsa_with_sha256, "Incorrect signature algorithm")
self.step("6.1")
# First, lets parse it
cd = TLVReader(cd_tlv).get()["Any"]
format_version = cd[0]
vendor_id = cd[1]
product_id_array = cd[2]
device_type_id = cd[3]
certificate_id = cd[4]
security_level = cd[5]
security_info = cd[6]
version_number = cd[7]
certification_type = cd[8]
asserts.assert_equal(format_version, 1, "Format version is incorrect")
self.step("6.2")
asserts.assert_equal(vendor_id, basic_info_vendor_id, "Vendor ID is incorrect")
if not is_ci:
asserts.assert_in(vendor_id, range(1, 0xfff0), "Vendor ID is out of range")
self.step("6.3")
asserts.assert_true(basic_info_product_id in product_id_array, "Product ID not found in CD product array")
self.step("6.4")
asserts.assert_in(device_type_id, range(0, (2**31)-1), "Device type ID is out of range")
self.step("6.5")
asserts.assert_equal(len(certificate_id), 19, "Certificate id is the incorrect length")
self.step("6.6")
asserts.assert_equal(security_level, 0, "Incorrect value for security level")
self.step("6.7")
asserts.assert_equal(security_info, 0, "Incorrect value for security information")
self.step("6.8")
asserts.assert_in(version_number, range(0, 65535), "Version number out of range")
self.step("6.9")
if is_ci:
asserts.assert_in(certification_type, [0, 1, 2], "Certification type is out of range")
else:
asserts.assert_in(certification_type, [1, 2], "Certification type is out of range")
self.step("7.0")
dac_vid, dac_pid, pai_vid, pai_pid = parse_ids_from_certs(parsed_dac, parsed_pai)
self.step("7.1")
has_origin_vid = 9 in cd.keys()
has_origin_pid = 10 in cd.keys()
if has_origin_pid != has_origin_vid:
asserts.fail("Found one of origin PID or VID in CD but not both")
self.step("7.2")
if has_origin_vid:
origin_vid = cd[9]
origin_pid = cd[10]
asserts.assert_equal(dac_vid, origin_vid, "Origin Vendor ID in the CD does not match the Vendor ID in the DAC")
asserts.assert_equal(pai_vid, origin_vid, "Origin Vendor ID in the CD does not match the Vendor ID in the PAI")
asserts.assert_equal(dac_pid, origin_pid, "Origin Product ID in the CD does not match the Product ID in the DAC")
if pai_pid:
asserts.assert_equal(pai_pid, origin_pid, "Origin Product ID in the CD does not match the Product ID in the PAI")
else:
self.mark_current_step_skipped()
self.step("7.3")
if not has_origin_vid:
asserts.assert_equal(dac_vid, vendor_id, "Vendor ID in the CD does not match the Vendor ID in the DAC")
asserts.assert_equal(pai_vid, vendor_id, "Vendor ID in the CD does not match the Vendor ID in the PAI")
asserts.assert_in(dac_pid, product_id_array, "Product ID from the DAC is not present in the PID list in the CD")
if pai_pid:
asserts.assert_in(pai_pid, product_id_array, "Product ID from the PAI is not present in the PID list in the CD")
else:
self.mark_current_step_skipped()
self.step(8)
has_paa_list = 11 in cd.keys()
if has_paa_list:
akids = [ext.value.key_identifier for ext in parsed_pai.extensions if ext.oid == ExtensionOID.AUTHORITY_KEY_IDENTIFIER]
asserts.assert_equal(len(akids), 1, "PAI requires exactly one AuthorityKeyIdentifier")
paa_authority_list = cd[11]
asserts.assert_in(akids[0], paa_authority_list, "PAI AKID not found in the authority list")
else:
self.mark_current_step_skipped()
self.step(9)
signature_cd = bytes(signer_info['signature'])
certs = {}
for filename in os.listdir(cd_cert_dir):
if '.der' not in filename:
continue
with open(os.path.join(cd_cert_dir, filename), 'rb') as f:
cert = x509.load_der_x509_certificate(f.read())
pub = cert.public_key()
ski = x509.SubjectKeyIdentifier.from_public_key(pub).digest
certs[ski] = pub
asserts.assert_true(subject_key_identifier in certs.keys(), "Subject key identifier not found in CD certs")
try:
certs[subject_key_identifier].verify(signature=signature_cd, data=cd_tlv,
signature_algorithm=ec.ECDSA(hashes.SHA256()))
except InvalidSignature:
asserts.fail("Failed to verify CD signature against known CD public key")
self.step(10)
asserts.assert_in(2, decoded.keys(), "Attestation nonce is not present in the attestation elements")
returned_nonce = decoded[2]
asserts.assert_equal(returned_nonce, nonce, "Returned attestation nonce does not match request nonce")
asserts.assert_equal(len(returned_nonce), 32, "Returned nonce is incorrect size")
self.step(11)
has_firmware_information = 4 in decoded.keys()
if has_firmware_information:
try:
int(decoded[4], 16)
except ValueError:
asserts.fail("Firmware is not an octet string")
else:
self.mark_current_step_skipped()
self.step(12)
proxy = self.default_controller.GetConnectedDeviceSync(self.dut_node_id, False)
asserts.assert_equal(len(proxy.attestationChallenge), 16, "Attestation challenge is the wrong length")
attestation_tbs = elements + proxy.attestationChallenge
# signature is a struct of r and s - see 3.5.3
# Actual curve is secp256r1 / NIST P-256 per 2.7
baselen = curve_by_name("NIST256p").baselen
signature_attestation_raw_r = int(hex_from_bytes(signature_attestation_raw[:baselen]), 16)
signature_attestation_raw_s = int(hex_from_bytes(signature_attestation_raw[baselen:]), 16)
signature_attestation = utils.encode_dss_signature(signature_attestation_raw_r, signature_attestation_raw_s)
parsed_dac.public_key().verify(signature=signature_attestation, data=attestation_tbs,
signature_algorithm=ec.ECDSA(hashes.SHA256()))
self.step(13)
nonce = random.randbytes(33)
try:
await self.send_single_cmd(cmd=opcreds.Commands.AttestationRequest(attestationNonce=nonce))
asserts.fail("Received Success response when an INVALID_COMMAND was expected")
except InteractionModelError as e:
asserts.assert_equal(e.status, Status.InvalidCommand, "Received incorrect error from AttestationRequest command")
self.step(14)
nonce = random.randbytes(31)
try:
await self.send_single_cmd(cmd=opcreds.Commands.AttestationRequest(attestationNonce=nonce))
asserts.fail("Received Success response when an INVALID_COMMAND was expected")
except InteractionModelError as e:
asserts.assert_equal(e.status, Status.InvalidCommand, "Received incorrect error from AttestationRequest command")
if __name__ == "__main__":
default_matter_test_main()