blob: 51b124537c3651d02acf2f00e808c64fe18b4630 [file]
#!/usr/bin/env python3
#
# Copyright (c) 2023-2024 Project CHIP Authors
#
# 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.
#
# Note: py cryptography supports indirect CRLs trom version 44.0.0 and above.
# You may need to update your cryptography version.
#
# Generates a basic RevocationSet from TestNet
# Usage:
# python ./credentials/generate-revocation-set.py --help
import base64
import dataclasses
import json
import logging
import os
import subprocess
import sys
import unittest
from enum import Enum
import click
import requests
from click_option_group import AllOptionGroup, RequiredMutuallyExclusiveOptionGroup, optgroup
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.extensions import ExtensionNotFound
from cryptography.x509.oid import NameOID
log = logging.getLogger(__name__)
# Supported log levels, mapping string values required for argument
# parsing into logging constants
__LOG_LEVELS__ = {
'debug': logging.DEBUG,
'info': logging.INFO,
'warn': logging.WARNING,
'fatal': logging.FATAL,
}
class RevocationType(Enum):
CRL = 1
class CertVerificationResult(Enum):
SUCCESS = 1
SKID_NOT_FOUND = 2
AKID_NOT_FOUND = 3
SIGNATURE_VERIFICATION_FAILED = 4
ISSUER_MISMATCH = 5
AKID_MISMATCH = 6
@dataclasses.dataclass
class RevocationPoint:
vid: int
label: str
issuerSubjectKeyID: str
pid: int
isPAA: bool
crlSignerCertificate: str
dataURL: str
dataFileSize: str
dataDigest: str
dataDigestType: int
revocationType: int
schemaVersion: int
crlSignerDelegator: str
@dataclasses.dataclass
class RevocationSet:
type: str
issuer_subject_key_id: str
issuer_name: str
revoked_serial_numbers: [str]
crl_signer_cert: str
crl_signer_delegator: str = None
def asDict(self):
return dataclasses.asdict(self)
OID_VENDOR_ID = x509.ObjectIdentifier("1.3.6.1.4.1.37244.2.1")
OID_PRODUCT_ID = x509.ObjectIdentifier("1.3.6.1.4.1.37244.2.2")
PRODUCTION_NODE_URL = "https://on.dcl.csa-iot.org:26657"
PRODUCTION_NODE_URL_REST = "https://on.dcl.csa-iot.org"
TEST_NODE_URL_REST = "https://on.test-net.dcl.csa-iot.org"
def extract_single_integer_attribute(subject, oid):
attribute_list = subject.get_attributes_for_oid(oid)
if len(attribute_list) == 1:
return int(attribute_list[0].value, 16)
return None
def extract_fallback_tag_from_common_name(cn, marker):
val_len = 4
start_idx = cn.find(marker)
if start_idx != -1:
val_start_idx = start_idx + len(marker)
val = cn[val_start_idx:val_start_idx + val_len]
return int(val, 16) if len(val) == 4 else None
return None
def parse_vid_pid_from_distinguished_name(distinguished_name):
# VID/PID encoded using Matter specific RDNs
vid = extract_single_integer_attribute(distinguished_name, OID_VENDOR_ID)
pid = extract_single_integer_attribute(distinguished_name, OID_PRODUCT_ID)
# Fallback method to get the VID/PID, encoded in CN as "Mvid:FFFF Mpid:1234"
if vid is None and pid is None:
cn = distinguished_name.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
vid = extract_fallback_tag_from_common_name(cn, 'Mvid:')
pid = extract_fallback_tag_from_common_name(cn, 'Mpid:')
return vid, pid
def get_akid(cert: x509.Certificate) -> str:
return cert.extensions.get_extension_for_oid(x509.OID_AUTHORITY_KEY_IDENTIFIER).value.key_identifier.hex().upper()
def get_skid(cert: x509.Certificate) -> str:
return cert.extensions.get_extension_for_oid(x509.OID_SUBJECT_KEY_IDENTIFIER).value.key_identifier.hex().upper()
def verify_cert(cert: x509.Certificate, root: x509.Certificate) -> CertVerificationResult:
'''
Verifies if the cert is signed by root.
'''
try:
cert_akid = get_akid(cert)
except ExtensionNotFound:
return CertVerificationResult.AKID_NOT_FOUND
try:
root_skid = get_skid(root)
except ExtensionNotFound:
return CertVerificationResult.SKID_NOT_FOUND
if cert_akid != root_skid:
return CertVerificationResult.AKID_MISMATCH
if cert.issuer != root.subject:
return CertVerificationResult.ISSUER_MISMATCH
# public_key().verify() do not return anything if signature is valid,
# will raise an exception if signature is invalid
try:
root.public_key().verify(cert.signature, cert.tbs_certificate_bytes, ec.ECDSA(cert.signature_hash_algorithm))
except Exception:
return CertVerificationResult.SIGNATURE_VERIFICATION_FAILED
return CertVerificationResult.SUCCESS
def is_self_signed_certificate(cert: x509.Certificate) -> bool:
result = verify_cert(cert, cert)
if result == CertVerificationResult.SUCCESS:
return True
log.debug("Certificate with subject: %s is not a valid self-signed certificate. Result: %s",
cert.subject.rfc4514_string(), result.name)
return False
# delegator is optional so can be None, but crl_signer and paa has to be present
def validate_cert_chain(crl_signer: x509.Certificate, crl_signer_delegator: x509.Certificate, paa: x509.Certificate):
'''
There could be four scenarios:
1. CRL Signer is PAA itself, hence its self-signed certificate
2. CRL Signer is PAI certificate, and we can validate (crl_signer -> paa) chain
3. CRL Signer delegator is PAA, and we can validate (crl_signer -> crl_signer_delegator(paa) -> paa) chain
4. CRL Signer delegator is PAI, and we can validate (crl_signer -> crl_signer_delegator -> paa) chain
'''
if crl_signer_delegator:
result_signer = verify_cert(crl_signer, crl_signer_delegator)
if result_signer != CertVerificationResult.SUCCESS:
log.debug("Cannot verify certificate subject: %s issued by certificate subject: %s. Result: %s",
crl_signer.subject.rfc4514_string(), crl_signer_delegator.subject.rfc4514_string(), result_signer.name)
return False
result_delegator = verify_cert(crl_signer_delegator, paa)
if result_delegator != CertVerificationResult.SUCCESS:
log.debug("Cannot verify certificate subject: %s issued by certificate subject: %s. Result: %s",
crl_signer_delegator.subject.rfc4514_string(), paa.subject.rfc4514_string(), result_delegator.name)
return False
return True
result = verify_cert(crl_signer, paa)
if result != CertVerificationResult.SUCCESS:
log.debug("Cannot verify certificate subject: %s issued by certificate subject: %s. Result: %s",
crl_signer.subject.rfc4514_string(), paa.subject.rfc4514_string(), result.name)
return False
return True
def validate_vid_pid(revocation_point: RevocationPoint, crl_signer_certificate: x509.Certificate, crl_signer_delegator_certificate: x509.Certificate) -> bool:
crl_signer_vid, crl_signer_pid = parse_vid_pid_from_distinguished_name(crl_signer_certificate.subject)
log.debug("vid: %s)", revocation_point.vid)
log.debug("crl_signer_vid: %s)", crl_signer_vid)
if revocation_point.isPAA:
if crl_signer_vid is not None:
if revocation_point.vid != crl_signer_vid:
log.warning("VID in CRL Signer Certificate does not match with VID in revocation point, continue...")
return False
else:
vid_to_match = crl_signer_vid
pid_to_match = crl_signer_pid
# if the CRL Signer is delegated then match the VID and PID of the CRL Signer Delegator
if crl_signer_delegator_certificate:
vid_to_match, pid_to_match = parse_vid_pid_from_distinguished_name(crl_signer_delegator_certificate.subject)
log.debug("vid_to_match: %s)", vid_to_match)
log.debug("pid_to_match: %s)", pid_to_match)
if vid_to_match is None or revocation_point.vid != vid_to_match:
log.warning("VID in CRL Signer Certificate does not match with VID in revocation point, continue...")
return False
if pid_to_match is not None:
if revocation_point.pid != pid_to_match:
log.warning("PID in CRL Signer Certificate does not match with PID in revocation point, continue...")
return False
return True
def generate_revocation_set_from_crl(crl_file: x509.CertificateRevocationList,
crl_signer_certificate: x509.Certificate,
certificate_authority_name: x509.Name,
certificate_akid_hex: str,
crl_signer_delegator_cert: x509.Certificate) -> RevocationSet:
"""Generate a revocation set from a CRL file.
Args:
crl_file: The CRL object containing revoked certificates
crl_signer_certificate: The certificate object used to sign the CRL
certificate_authority_name: x509.Name of the issuer
certificate_akid_hex: Hex encoded Authority Key Identifier
crl_signer_delegator_cert: crl signer delegator certificate object
Returns:
RevocationSet containing the revocation set data with fields:
- type: "revocation_set"
- issuer_subject_key_id: Authority Key Identifier (hex)
- issuer_name: Issuer name (base64)
- revoked_serial_numbers: List of revoked serial numbers
- crl_signer_cert: CRL signer certificate (base64 DER)
- crl_signer_delegator: Optional delegator certificate (base64 DER)
"""
serialnumber_list = []
for revoked_cert in crl_file:
try:
cert_issuer_entry_ext = revoked_cert.extensions.get_extension_for_oid(x509.CRLEntryExtensionOID.CERTIFICATE_ISSUER)
revoked_cert_issuer = cert_issuer_entry_ext.value.get_values_for_type(x509.DirectoryName)[0]
if revoked_cert_issuer is not None:
# check if this really are the same thing
if revoked_cert_issuer != x509.DirectoryName(certificate_authority_name).value:
log.warning("CRL entry issuer is not CRL File Issuer, continue...")
continue
except Exception:
pass
# Ensure the serial number is always a 2-byte aligned hex string.
# TestDACRevocationDelegateImpl encodes the serial number as an even-length hex string
# using BytesToHex in src/lib/support/BytesToHex.cpp.
# As the primary consumer of this data, we should use the same here.
serialnumber = '{:02X}'.format(revoked_cert.serial_number)
serialnumber = serialnumber if len(serialnumber) % 2 == 0 else '0' + serialnumber
serialnumber_list.append(serialnumber)
entry = RevocationSet(
type='revocation_set',
issuer_subject_key_id=certificate_akid_hex,
issuer_name=get_b64_name(certificate_authority_name),
revoked_serial_numbers=serialnumber_list,
crl_signer_cert=base64.b64encode(crl_signer_certificate.public_bytes(serialization.Encoding.DER)).decode('utf-8'),
)
if crl_signer_delegator_cert:
entry.crl_signer_delegator = base64.b64encode(
crl_signer_delegator_cert.public_bytes(serialization.Encoding.DER)).decode('utf-8')
return entry
# This is implemented as per point (9) in 6.2.4.1. Conceptual algorithm for revocation set construction
def get_certificate_authority_details(crl_signer_certificate: x509.Certificate,
crl_signer_delegator_cert: x509.Certificate,
paa_certificate_object: x509.Certificate,
is_paa: bool) -> tuple[x509.Name, str]:
"""Get certificate authority name and AKID based on certificate hierarchy.
Args:
crl_signer_certificate: The CRL signer certificate
crl_signer_delegator_cert: Optional delegator certificate
paa_certificate_object: Optional PAA certificate
is_paa: Whether this is a PAA certificate
Returns:
tuple[str, str]: (certificate_authority_name, certificate_akid_hex)
"""
if is_paa and not is_self_signed_certificate(crl_signer_certificate):
cert_for_details = paa_certificate_object
log.debug("Using PAA certificate for details")
elif crl_signer_delegator_cert:
cert_for_details = crl_signer_delegator_cert
log.debug("Using CRL Signer Delegator certificate for details")
else:
cert_for_details = crl_signer_certificate
log.debug("Using CRL Signer certificate for details")
certificate_authority_name = cert_for_details.subject
try:
certificate_akid = get_skid(cert_for_details)
log.debug("Certificate Authority Name: %s", certificate_authority_name)
log.debug("Certificate AKID: %s", certificate_akid)
return certificate_authority_name, certificate_akid
except ExtensionNotFound:
log.warning("Certificate SKID not found in authoarity certificate.")
def get_b64_name(name: x509.name.Name) -> str:
'''
Get base64 encoded name
'''
return base64.b64encode(name.public_bytes()).decode('utf-8')
def fetch_crl_from_url(url: str, timeout: int) -> x509.CertificateRevocationList:
log.debug("Fetching CRL from %s", url)
try:
r = requests.get(url, timeout=timeout)
log.debug("Fetched CRL: %s", r.content)
return x509.load_der_x509_crl(r.content)
except Exception as e:
log.error("Failed to fetch a valid CRL: %s", e)
class DclClientInterface:
'''
An interface for interacting with DCLD.
'''
def send_get_request(self, url: str) -> dict:
'''
Send a GET request for a json object.
'''
try:
return requests.get(url).json()
except Exception as e:
log.error("Failed to fetch %s: %s", url, e)
return None
def get_revocation_points(self) -> list[RevocationPoint]:
'''
Get revocation points from DCL
Returns
-------
list[RevocationPoint]
List of revocation points
'''
raise NotImplementedError
def get_revocation_points_by_skid(self, issuer_subject_key_id) -> list[RevocationPoint]:
'''
Get revocation points by subject key ID
Parameters
----------
issuer_subject_key_id: str
Subject key ID
Returns
-------
list[RevocationPoint]
List of revocation points
'''
raise NotImplementedError
def get_approved_certificate(self, subject_name: x509.name.Name, skid_hex: str) -> tuple[bool, x509.Certificate]:
'''
Get certificate from DCL.
'''
raise NotImplementedError
def get_only_approved_certificate(self, response: dict, skid_hex: str) -> tuple[bool, x509.Certificate | None]:
'''
Get only approved certificate from DCL resposne.
'''
if response is None or not response.get("approvedCertificates", {}).get("certs", []):
raise requests.exception.NotFound(f"No certificate found for {skid_hex}")
if len(response["approvedCertificates"]["certs"]) > 1:
raise ValueError(f"Multiple certificates found for {skid_hex}")
issuer_certificate = x509.load_pem_x509_certificate(bytes(response["approvedCertificates"]["certs"][0]["pemCert"], "utf-8"))
return response["approvedCertificates"]["certs"][0]["isRoot"], issuer_certificate
def get_paa_cert(self, initial_cert: x509.Certificate) -> x509.Certificate | None:
'''
Get the PAA certificate for the CRL Signer Certificate.
'''
issuer_name = initial_cert.issuer
try:
akid = get_akid(initial_cert)
except ExtensionNotFound:
log.warning('Certificate AKID not found.')
return None
paa_certificate = None
while not paa_certificate:
try:
is_root, issuer_certificate = self.get_approved_certificate(issuer_name, akid)
if is_root:
paa_certificate = issuer_certificate
break
except Exception as e:
log.error("Failed to get PAA certificate: %s", e)
return None
log.debug("issuer_name: %s", issuer_certificate.subject.rfc4514_string())
issuer_name = issuer_certificate.issuer
try:
akid = get_akid(issuer_certificate)
except ExtensionNotFound:
log.warning('Issuer Certificate AKID not found.')
log.debug("akid: %s", akid)
if paa_certificate is None:
log.warning("PAA Certificate not found, continue...")
return paa_certificate
def get_crl_file(self,
revocation_point: RevocationPoint,
crl_signer_certificate: x509.Certificate) -> x509.CertificateRevocationList:
"""Obtain the CRL."""
try:
r = requests.get(revocation_point.dataURL, timeout=5)
log.debug("Fetched CRL: %s", r.content)
return x509.load_der_x509_crl(r.content)
except Exception:
log.warning("Failed to fetch a valid CRL for': %s", crl_signer_certificate.subject.rfc4514_string())
def get_formatted_hex_skid(self, skid_hex: str) -> str:
return ':'.join([skid_hex[i:i+2] for i in range(0, len(skid_hex), 2)])
class NodeDclClient(DclClientInterface):
'''
A client for interacting with DCLD using command line interface (CLI).
'''
def __init__(self, dcld_exe: str, use_test_net: bool):
'''
Initialize the client.
dcld_exe: str
Path to `dcld` executable.
use_test_net: bool
Indicates if the client should use TestNet or MainNet URL with dcld executable.
'''
self.dcld_exe = dcld_exe
self.use_test_net = use_test_net
def build_dcld_command_line(self, cmdlist: list[str]) -> list[str]:
'''
Build command line for `dcld` executable.
Parameters
----------
cmdlist: list[str]
List of command line arguments to append to some predefined arguments.
Returns
-------
list[str]
The complete command list including the DCLD executable and node option if in production.
'''
return [self.dcld_exe] + cmdlist + ([] if self.use_test_net else ['--node', PRODUCTION_NODE_URL])
def get_dcld_cmd_output_json(self, cmdlist: list[str]) -> dict:
'''
Executes a DCLD CLI command and returns the JSON output.
Parameters
----------
cmdlist: list[str]
List of command line arguments to append to some predefined arguments.
Returns
-------
dict
The JSON output from the command.
'''
# Set the output as JSON
subprocess.Popen([self.dcld_exe, 'config', 'output', 'json'])
cmdpipe = subprocess.Popen(self.build_dcld_command_line(cmdlist),
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return json.loads(cmdpipe.stdout.read())
def get_revocation_points(self) -> list[RevocationPoint]:
'''
Get revocation points from DCL.
Returns
-------
list[RevocationPoint]
List of revocation points.
'''
response = self.get_dcld_cmd_output_json(['query', 'pki', 'all-revocation-points'])
return [RevocationPoint(**r) for r in response["PkiRevocationDistributionPoint"]]
def get_revocation_points_by_skid(self, issuer_subject_key_id) -> list[RevocationPoint]:
'''
Get revocation points by subject key ID.
Parameters
----------
issuer_subject_key_id: str
Subject key ID.
Returns
-------
list[RevocationPoint]
List of revocation points.
'''
response = self.get_dcld_cmd_output_json(['query', 'pki', 'revocation-points',
'--issuer-subject-key-id', issuer_subject_key_id])
log.debug("Response revocation points: %s", response)
return [RevocationPoint(**r) for r in response["pkiRevocationDistributionPointsByIssuerSubjectKeyID"]["points"]]
def get_approved_certificate(self, subject_name: x509.name.Name, skid_hex: str) -> tuple[bool, x509.Certificate]:
'''
Get certificate from DCL.
Parameters
----------
subject_name: x509.name.Name
Subject Name object.
skid_hex: str
Subject Key ID in hex format.
Returns
-------
tuple[bool, x509.Certificate]
Tuple of is_paa and the certificate from the DCL.
'''
subject_name_b64 = get_b64_name(subject_name)
query_cmd_list = ['query', 'pki', 'x509-cert', '-u', subject_name_b64, '-k', skid_hex]
log.debug("Fetching issuer from dcl query%s", ' '.join(query_cmd_list))
response = self.get_dcld_cmd_output_json(query_cmd_list)
return self.get_only_approved_certificate(response, skid_hex)
class RestDclClient(DclClientInterface):
'''
A client for interacting with DCLD using the REST API.
'''
def __init__(self, use_test_net: bool):
'''
Initialize the client.
use_test_net: bool
Indicates if the client should use TestNet or MainNet REST API URL.
'''
self.rest_node_url = TEST_NODE_URL_REST if use_test_net else PRODUCTION_NODE_URL_REST
def get_revocation_points(self) -> list[RevocationPoint]:
'''
Get revocation points from DCL.
Returns
-------
list[RevocationPoint]
List of revocation points.
'''
response = self.send_get_request(f"{self.rest_node_url}/dcl/pki/revocation-points")
return [RevocationPoint(**r) for r in response["PkiRevocationDistributionPoint"]]
def get_revocation_points_by_skid(self, issuer_subject_key_id) -> list[RevocationPoint]:
'''
Get revocation points by subject key ID.
Parameters
----------
issuer_subject_key_id: str
Subject key ID.
Returns
-------
list[RevocationPoint]
List of revocation points.
'''
response = self.send_get_request(f"{self.rest_node_url}/dcl/pki/revocation-points/{issuer_subject_key_id}")
return [RevocationPoint(**r) for r in response["pkiRevocationDistributionPointsByIssuerSubjectKeyID"]["points"]]
def get_approved_certificate(self, subject_name: x509.name.Name, skid_hex: str) -> tuple[bool, x509.Certificate]:
'''
Get certificate from DCL.
Parameters
----------
subject_name: x509.name.Name
Subject Name object.
skid_hex: str
Subject Key ID in hex format.
Returns
-------
tuple[bool, x509.Certificate]
Tuple of is_paa and the certificate from the DCL.
'''
log.debug("Fetching issuer from:%s/dcl/pki/certificates/%s/%s",
self.rest_node_url, get_b64_name(subject_name), self.get_formatted_hex_skid(skid_hex))
response = self.send_get_request(
f"{self.rest_node_url}/dcl/pki/certificates/{get_b64_name(subject_name)}/{self.get_formatted_hex_skid(skid_hex)}")
log.debug("Response certificate: %s", response)
return self.get_only_approved_certificate(response, skid_hex)
class LocalFilesDclClient(DclClientInterface):
'''
A client for interacting with local DLCD response data.
'''
def __init__(self, crls: [], dcl_certificates: [], revocation_points_response_file: str):
'''
Initialize the client.
Parameters
----------
crls: list
List of CRL files.
dcl_certificates: list
List of certificate files.
revocation_points_response_file: str
Path to the get-revocation-points response json file.
'''
log.debug("Loading certificates from %s", dcl_certificates)
log.debug("Loading crls from %s", crls)
log.debug("Loading revocation points response from %s", revocation_points_response_file)
self.crls = self.get_crls(crls)
self.revocation_points = [RevocationPoint(**r)
for r in json.load(revocation_points_response_file)["PkiRevocationDistributionPoint"]]
self.authoritative_certs = self.get_authoritative_certificates(dcl_certificates)
def get_lookup_key(self, certificate: x509.Certificate) -> str:
'''
Get key used in this class to lookup certificates.
Parameters
----------
certificate: x509.Certificate
Certificate object.
Returns
-------
str:
lookup key derived from the certificate.
'''
base64_name = get_b64_name(certificate.subject)
try:
skid = get_skid(certificate)
return self.format_lookup_key(base64_name, skid)
except ExtensionNotFound:
log.warning("CertificateSKID not found, continue...")
def format_lookup_key(self, base64_name: str, skid_hex: str) -> str:
'''
Get formatted key used in this class to lookup certificates.
Parameters
----------
base64_name: str
Base64 encoded subject name.
skid_hex: str
Subject Key ID in hex format.
Returns
-------
str:
Key used in this class to lookup certificates.
'''
delimiter = '/'
skid_hex_formatted = self.get_formatted_hex_skid(skid_hex)
return delimiter.join([base64_name, skid_hex_formatted])
def get_crls(self, unread_crls: []) -> list[x509.CertificateRevocationList]:
'''
Get CRLs from list of files.
Parameters
----------
unread_crls: list
List of CRL files.
Returns
-------
list[x509.CertificateRevocationList]
List of CRLs.
'''
crls = []
for file in unread_crls:
crl_content = file.read()
crl_file = x509.load_der_x509_crl(crl_content)
crls.append(crl_file)
return crls
def get_authoritative_certificates(self, dcl_certificates: []) -> dict[str, x509.Certificate]:
'''
Get certificates from revocation points response file and list of provided dcl certificates.
Parameters
----------
dcl_certificates: list
List of certificate files.
Returns
-------
dict[str, x509.CertificateRevocationList]
Dictionary of certificates, keyed by lookup key.
'''
certificates = {}
log.debug("Loading certificates from %s", dcl_certificates)
if dcl_certificates:
for file in dcl_certificates:
log.debug("Loading certificate from %s", file)
# with open(file, "r") as f:
certificate = x509.load_pem_x509_certificate(file.read())
certificates[self.get_lookup_key(certificate)] = certificate
log.debug("Loading certificates from revocation_points_response file.")
for point in self.revocation_points:
if point.crlSignerDelegator:
certificate = x509.load_pem_x509_certificate(bytes(point.crlSignerDelegator, 'utf-8'))
certificates[self.get_lookup_key(certificate)] = certificate
elif point.crlSignerCertificate:
certificate = x509.load_pem_x509_certificate(bytes(point.crlSignerCertificate, 'utf-8'))
certificates[self.get_lookup_key(certificate)] = certificate
return certificates
def get_revocation_points(self) -> list[RevocationPoint]:
'''
Get revocation points from DCL.
Returns
-------
list[RevocationPoint]
List of revocation points.
'''
return self.revocation_points
def get_revocation_points_by_skid(self, issuer_subject_key_id) -> list[RevocationPoint]:
'''
Get revocation points by subject key ID
Parameters
----------
issuer_subject_key_id: str
Subject key ID.
Returns
-------
list[RevocationPoint]
List of revocation points with the same issuer subject key ID.
'''
same_issuer_points = []
for point in self.revocation_points:
if point.issuerSubjectKeyID == issuer_subject_key_id:
same_issuer_points.append(point)
return same_issuer_points
def get_approved_certificate(self, subject_name: x509.name.Name, skid_hex: str) -> tuple[bool, x509.Certificate]:
'''
Get certificate from DCL
Parameters
----------
subject_name: x509.name.Name
Subject name object.
skid_hex: str
Subject Key ID in hex format.
Returns
-------
tuple[bool, x509.Certificate]
Tuple of is_paa and the certificate from the DCL.
'''
lookup_key = self.format_lookup_key(get_b64_name(subject_name), skid_hex)
if lookup_key in self.authoritative_certs:
return is_self_signed_certificate(self.authoritative_certs[lookup_key]), self.authoritative_certs[lookup_key]
return False, None
def get_crl_file(self,
unused_revocation_point: RevocationPoint,
crl_signer_certificate: x509.Certificate) -> x509.CertificateRevocationList:
'''
Obtain the CRL.
Parameters
----------
unused_revocation_point: RevocationPoint
Revocation point. Not used.
crl_signer_certificate: x509.Certificate
Crl signer certificate.
Returns
-------
x509.CertificateRevocationList
CRL signed by the CRL signer certificate.
'''
for crl in self.crls:
if crl.issuer.public_bytes() == crl_signer_certificate.subject.public_bytes():
log.debug("Found CRL for issuer: %s", crl.issuer.rfc4514_string())
return crl
return None
@click.group()
def cli():
pass
@cli.command('from-dcl')
@click.help_option('-h', '--help')
@optgroup.group('Input data sources', cls=RequiredMutuallyExclusiveOptionGroup)
@optgroup.option('--use-main-net-dcld', type=str, default='', metavar='PATH', help="Location of `dcld` binary, to use `dcld` for mirroring MainNet.")
@optgroup.option('--use-test-net-dcld', type=str, default='', metavar='PATH', help="Location of `dcld` binary, to use `dcld` for mirroring TestNet.")
@optgroup.option('--use-main-net-http', is_flag=True, type=str, help="Use RESTful API with HTTPS against public MainNet observer.")
@optgroup.option('--use-test-net-http', is_flag=True, type=str, help="Use RESTful API with HTTPS against public TestNet observer.")
@optgroup.option('--use-local-data', is_flag=True, type=bool, help="Fake response directory: see \" DATA_DIR/")
@optgroup.group('Required arguments if use-local-data is used', cls=AllOptionGroup)
@optgroup.option('--certificates', type=click.File('rb'), multiple=True, help='Paths to PEM formated certificates (i.e. PAA) in DCL but missing from the revocation-points-response file.')
@optgroup.option('--crls', type=click.File('rb'), multiple=True, help='Paths to the crl der files')
@optgroup.option('--revocation-points-response', type=click.File('rb'), help='Path to the get-revocation-points response json file.')
@optgroup.group('Optional output arguments')
@optgroup.option('--output', default='sample_revocation_set_list.json', type=str, metavar='FILEPATH',
help="Output filename (default: sample_revocation_set_list.json)")
@optgroup.option('--log-level', default='INFO', show_default=True, type=click.Choice(__LOG_LEVELS__.keys(),
case_sensitive=False), callback=lambda c, p, v: __LOG_LEVELS__[v],
help='Determines the verbosity of script output')
def from_dcl(use_main_net_dcld: str, use_test_net_dcld: str, use_main_net_http: bool, use_test_net_http: bool, use_local_data: bool, revocation_points_response: str, crls: [], certificates: [], output: str, log_level: str):
"""Generate revocation set from DCL using generation algorithm from Matter Spec section 6.2.4.1."""
logging.basicConfig(
level=log_level,
format='%(asctime)s %(name)s %(levelname)-7s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
if use_local_data:
dcld_client = LocalFilesDclClient(crls, certificates, revocation_points_response)
elif use_main_net_http or use_test_net_http:
dcld_client = RestDclClient(bool(use_test_net_http))
else:
dcld_client = NodeDclClient(use_main_net_dcld or use_test_net_dcld, bool(use_test_net_dcld))
revocation_point_list = dcld_client.get_revocation_points()
revocation_set = []
for revocation_point in revocation_point_list:
# 1. Validate Revocation Type
if revocation_point.revocationType != RevocationType.CRL.value:
log.warning("Revocation Type is not CRL, continue...")
continue
# 2. Parse the certificate
try:
crl_signer_certificate = x509.load_pem_x509_certificate(bytes(revocation_point.crlSignerCertificate, 'utf-8'))
except Exception:
log.warning("CRL Signer Certificate is not valid, continue...")
continue
# Parse the crl signer delegator
crl_signer_delegator_cert = None
if revocation_point.crlSignerDelegator:
crl_signer_delegator_cert_pem = revocation_point.crlSignerDelegator
log.debug("CRLSignerDelegator: %s", crl_signer_delegator_cert_pem)
try:
crl_signer_delegator_cert = x509.load_pem_x509_certificate(bytes(crl_signer_delegator_cert_pem, 'utf-8'))
except Exception:
log.warning("CRL Signer Delegator Certificate not found...")
# 3. and 4. Validate VID/PID
if not validate_vid_pid(revocation_point, crl_signer_certificate, crl_signer_delegator_cert):
log.warning("Failed to validate VID/PID, continue...")
continue
# 5. Validate the certification path containing CRLSignerCertificate.
paa_certificate_object = dcld_client.get_paa_cert(crl_signer_certificate)
if paa_certificate_object is None:
log.warning("PAA Certificate not found, continue...")
continue
if validate_cert_chain(crl_signer_certificate, crl_signer_delegator_cert, paa_certificate_object) is False:
log.warning("Failed to validate CRL Signer Certificate chain, continue...")
continue
# 6. Obtain the CRL
crl_file = dcld_client.get_crl_file(revocation_point, crl_signer_certificate)
if crl_file is None:
log.warning("CRL file not found for revocation point, continue...")
continue
# 7. Perform CRL File Validation
# a.
try:
crl_signer_skid = get_skid(crl_signer_certificate)
except ExtensionNotFound:
log.warning("CRL Signer SKID not found, continue...")
continue
try:
crl_akid = get_akid(crl_file)
except ExtensionNotFound:
log.warning("CRL AKID is not found, continue...")
continue
if crl_akid != crl_signer_skid:
log.warning("CRL AKID is not CRL Signer SKID, continue...")
continue
# b.
same_issuer_points = dcld_client.get_revocation_points_by_skid(crl_akid)
count_with_matching_vid_issuer_skid = sum(item.vid == revocation_point.vid for item in same_issuer_points)
if count_with_matching_vid_issuer_skid > 1:
try:
issuing_distribution_point = crl_file.extensions.get_extension_for_oid(
x509.oid.ExtensionOID.ISSUING_DISTRIBUTION_POINT).value
except Exception:
log.warning("CRL Issuing Distribution Point not found, continue...")
continue
uri_list = issuing_distribution_point.full_name
if len(uri_list) == 1 and isinstance(uri_list[0], x509.UniformResourceIdentifier):
if uri_list[0].value != revocation_point.dataURL:
log.warning("CRL Issuing Distribution Point URI is not CRL URL, continue...")
continue
else:
log.warning("CRL Issuing Distribution Point URI is not CRL URL, continue...")
continue
# TODO: 8. Validate CRL as per Section 6.3 of RFC 5280
# 9. Decide on certificate authority name and AKID
certificate_authority_name, certificate_akid_hex = get_certificate_authority_details(
crl_signer_certificate, crl_signer_delegator_cert, paa_certificate_object, revocation_point.isPAA)
# validate issuer skid matchces with the one in revocation points
log.debug("revocation_point.issuerSubjectKeyID: %s", revocation_point.issuerSubjectKeyID)
if revocation_point.issuerSubjectKeyID != certificate_akid_hex:
log.warning("CRL Issuer Subject Key ID is not CRL Signer Subject Key ID, continue...")
continue
# 10. Iterate through the Revoked Certificates List
entry = generate_revocation_set_from_crl(crl_file, crl_signer_certificate,
certificate_authority_name, certificate_akid_hex, crl_signer_delegator_cert)
log.debug("Entry to append: %s", entry)
revocation_set.append(entry)
with open(output, 'w+') as outfile:
json.dump([revocation.asDict() for revocation in revocation_set], outfile, indent=4)
class TestRevocationSetGeneration(unittest.TestCase):
"""Test class for revocation set generation"""
def setUp(self):
# Get the directory containing this file
self.test_base_dir = os.path.dirname(os.path.abspath(__file__))
def get_test_file_path(self, filename):
return os.path.join(self.test_base_dir, 'test', filename)
def get_expected_revocation_set(self, idx):
with open(os.path.join(self.test_base_dir, 'test/revoked-attestation-certificates/revocation-sets/revocation-set.json')) as f:
return RevocationSet(**json.load(f)[idx])
def compare_revocation_sets(self, generated_set, expected):
# Compare required fields
self.assertEqual(generated_set.type, expected.type)
self.assertEqual(generated_set.issuer_subject_key_id, expected.issuer_subject_key_id)
self.assertEqual(generated_set.issuer_name, expected.issuer_name)
self.assertEqual(set(generated_set.revoked_serial_numbers), set(expected.revoked_serial_numbers))
self.assertEqual(generated_set.crl_signer_cert, expected.crl_signer_cert)
# Compare optional fields if present in either set
if generated_set.crl_signer_delegator or expected.crl_signer_delegator:
self.assertEqual(generated_set.crl_signer_delegator, expected.crl_signer_delegator,
f'CRL signer delegator certificates do not match, expected: {expected.crl_signer_delegator}, actual: {generated_set.crl_signer_delegator}')
def test_paa_revocation_set(self):
"""Test generation of PAA revocation set"""
with open(self.get_test_file_path('revoked-attestation-certificates/Chip-Test-PAA-FFF1-CRL.pem'), 'rb') as f:
crl = x509.load_pem_x509_crl(f.read())
with open(self.get_test_file_path('revoked-attestation-certificates/Chip-Test-PAA-FFF1-Cert.pem'), 'rb') as f:
crl_signer = x509.load_pem_x509_certificate(f.read())
ca_name_b64, ca_akid_hex = get_certificate_authority_details(
crl_signer, None, None, True)
revocation_set = generate_revocation_set_from_crl(
crl, crl_signer, ca_name_b64, ca_akid_hex, None)
self.compare_revocation_sets(revocation_set, self.get_expected_revocation_set(0))
def test_pai_revocation_set(self):
"""Test generation of PAI revocation set"""
with open(self.get_test_file_path('revoked-attestation-certificates/Matter-Development-PAI-FFF1-noPID-CRL.pem'), 'rb') as f:
crl = x509.load_pem_x509_crl(f.read())
with open(self.get_test_file_path('revoked-attestation-certificates/Matter-Development-PAI-FFF1-noPID-Cert.pem'), 'rb') as f:
crl_signer = x509.load_pem_x509_certificate(f.read())
with open(self.get_test_file_path('revoked-attestation-certificates/Chip-Test-PAA-FFF1-Cert.pem'), 'rb') as f:
paa = x509.load_pem_x509_certificate(f.read())
ca_name_b64, ca_akid_hex = get_certificate_authority_details(
crl_signer, None, paa, False)
revocation_set = generate_revocation_set_from_crl(
crl, crl_signer, ca_name_b64, ca_akid_hex, None)
self.compare_revocation_sets(revocation_set, self.get_expected_revocation_set(1))
def test_revoked_pai_revocation_set(self):
"""Test generation of revocation set of revoked PAI"""
with open(self.get_test_file_path('revoked-attestation-certificates/Chip-Test-PAI-FFF1-noPID-Revoked-CRL.pem'), 'rb') as f:
crl = x509.load_pem_x509_crl(f.read())
with open(self.get_test_file_path('revoked-attestation-certificates/Chip-Test-PAI-FFF1-noPID-Revoked-Cert.pem'), 'rb') as f:
crl_signer = x509.load_pem_x509_certificate(f.read())
with open(self.get_test_file_path('revoked-attestation-certificates/Chip-Test-PAA-FFF1-Cert.pem'), 'rb') as f:
paa = x509.load_pem_x509_certificate(f.read())
ca_name_b64, ca_akid_hex = get_certificate_authority_details(
crl_signer, None, paa, False)
revocation_set = generate_revocation_set_from_crl(
crl, crl_signer, ca_name_b64, ca_akid_hex, None)
self.compare_revocation_sets(revocation_set, self.get_expected_revocation_set(2))
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == 'test':
# Remove the 'test' argument and run tests
sys.argv.pop(1)
unittest.main()
elif len(sys.argv) == 1:
cli.main(['--help'])
else:
cli()