Skip to content

Commit 65d9649

Browse files
Automatically retrieve PAA certificates for device attestation (#240)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
1 parent 014d821 commit 65d9649

File tree

5 files changed

+148
-9
lines changed

5 files changed

+148
-9
lines changed

matter_server/server/device_controller.py

+37-7
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
from datetime import datetime
88
from functools import partial
99
import logging
10-
from typing import TYPE_CHECKING, Any, Callable, Deque, Type, TypeVar, cast
10+
import pathlib
11+
from typing import TYPE_CHECKING, Any, Callable, Deque, Final, Type, TypeVar, cast
1112

1213
from chip.ChipDeviceCtrl import CommissionableNode
1314
from chip.clusters import Attribute, Objects as Clusters
1415
from chip.clusters.ClusterObjects import ALL_CLUSTERS, Cluster
1516
from chip.exceptions import ChipStackError
1617

18+
from matter_server.server.helpers.paa_certificates import fetch_certificates
19+
1720
from ..common.const import SCHEMA_VERSION
1821
from ..common.errors import (
1922
NodeCommissionFailed,
@@ -31,6 +34,7 @@
3134

3235
if TYPE_CHECKING:
3336
from .server import MatterServer
37+
from chip.ChipDeviceCtrl import ChipDeviceController
3438

3539
_T = TypeVar("_T")
3640

@@ -39,18 +43,34 @@
3943

4044
LOGGER = logging.getLogger(__name__)
4145

46+
# the paa-root-certs path is hardcoded in the sdk at this time
47+
# and always uses the development subfolder
48+
# regardless of anything you pass into instantiating the controller
49+
# revisit this once matter 1.1 is released
50+
PAA_ROOT_CERTS_DIR: Final[pathlib.Path] = (
51+
pathlib.Path(__file__)
52+
.parent.resolve()
53+
.parent.resolve()
54+
.parent.resolve()
55+
.joinpath("credentials/development/paa-root-certs")
56+
)
57+
4258

4359
class MatterDeviceController:
4460
"""Class that manages the Matter devices."""
4561

62+
chip_controller: ChipDeviceController | None
63+
4664
def __init__(
4765
self,
4866
server: MatterServer,
4967
):
5068
"""Initialize the device controller."""
5169
self.server = server
5270
# Instantiate the underlying ChipDeviceController instance on the Fabric
53-
self.chip_controller = server.stack.fabric_admin.NewController()
71+
if not PAA_ROOT_CERTS_DIR.is_dir():
72+
raise RuntimeError("PAA certificates directory not found")
73+
5474
# we keep the last events in memory so we can include them in the diagnostics dump
5575
self.event_history: Deque[Attribute.EventReadResult] = deque(maxlen=25)
5676
self._subscriptions: dict[int, Attribute.SubscriptionTransaction] = {}
@@ -60,13 +80,14 @@ def __init__(
6080
self.compressed_fabric_id: int | None = None
6181
self._interview_task: asyncio.Task | None = None
6282

63-
@property
64-
def fabric_id(self) -> int:
65-
"""Return Fabric ID."""
66-
return cast(int, self.chip_controller.fabricId)
67-
6883
async def initialize(self) -> None:
6984
"""Async initialize of controller."""
85+
# (re)fetch all PAA certificates once at startup
86+
# NOTE: this must be done before initializing the controller
87+
await fetch_certificates(PAA_ROOT_CERTS_DIR)
88+
self.chip_controller = self.server.stack.fabric_admin.NewController(
89+
paaTrustStorePath=str(PAA_ROOT_CERTS_DIR)
90+
)
7091
self.compressed_fabric_id = await self._call_sdk(
7192
self.chip_controller.GetCompressedFabricId
7293
)
@@ -126,6 +147,9 @@ async def commission_with_code(self, code: str) -> MatterNodeData:
126147
127148
Returns full NodeInfo once complete.
128149
"""
150+
# perform a quick delta sync of certificates to make sure
151+
# we have the latest paa root certs
152+
await fetch_certificates(PAA_ROOT_CERTS_DIR)
129153
node_id = self._get_next_node_id()
130154

131155
success = await self._call_sdk(
@@ -160,6 +184,12 @@ async def commission_on_network(
160184
a string or None depending on the actual type of selected filter.
161185
Returns full NodeInfo once complete.
162186
"""
187+
# perform a quick delta sync of certificates to make sure
188+
# we have the latest paa root certs
189+
# NOTE: Its not very clear if the newly fetched certificates can be used without
190+
# restarting the device controller
191+
await fetch_certificates(PAA_ROOT_CERTS_DIR)
192+
163193
node_id = self._get_next_node_id()
164194

165195
success = await self._call_sdk(
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Helpers/utils for the Matter Server."""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Utils to fetch CHIP Development Product Attestation Authority (PAA) certificates from DCL.
3+
4+
This is based on the original script from project-chip here:
5+
https://github.com/project-chip/connectedhomeip/edit/master/credentials/fetch-paa-certs-from-dcl.py
6+
7+
All rights reserved.
8+
"""
9+
10+
import asyncio
11+
import logging
12+
import pathlib
13+
import re
14+
15+
from aiohttp import ClientSession, ClientError
16+
from cryptography import x509
17+
from cryptography.hazmat.primitives import serialization
18+
19+
LOGGER = logging.getLogger(__name__)
20+
PRODUCTION_URL = "https://on.dcl.csa-iot.org"
21+
TEST_URL = "https://on.test-net.dcl.csa-iot.org"
22+
23+
LAST_CERT_IDS: set[str] = set()
24+
25+
26+
async def write_paa_root_cert(
27+
certificate: str, subject: str, root_path: pathlib.Path
28+
) -> None:
29+
"""Write certificate from string to file."""
30+
31+
def _write() -> None:
32+
filename_base = "dcld_mirror_" + re.sub(
33+
"[^a-zA-Z0-9_-]", "", re.sub("[=, ]", "_", subject)
34+
)
35+
filepath_base = root_path.joinpath(filename_base)
36+
# handle PEM certificate file
37+
file_path_pem = f"{filepath_base}.pem"
38+
LOGGER.debug("Writing certificate %s", file_path_pem)
39+
with open(file_path_pem, "w+", encoding="utf-8") as outfile:
40+
outfile.write(certificate)
41+
# handle DER certificate file (converted from PEM)
42+
pem_certificate = x509.load_pem_x509_certificate(certificate.encode())
43+
file_path_der = f"{filepath_base}.der"
44+
LOGGER.debug("Writing certificate %s", file_path_der)
45+
with open(file_path_der, "wb+") as outfile:
46+
der_certificate = pem_certificate.public_bytes(serialization.Encoding.DER)
47+
outfile.write(der_certificate)
48+
49+
return await asyncio.get_running_loop().run_in_executor(None, _write)
50+
51+
52+
async def fetch_certificates(
53+
paa_trust_store_path: pathlib.Path,
54+
fetch_test_certificates: bool = True,
55+
fetch_production_certificates: bool = True,
56+
) -> int:
57+
"""Fetch PAA Certificates."""
58+
LOGGER.info("Fetching the latest PAA root certificates from DCL.")
59+
fetch_count: int = 0
60+
base_urls = set()
61+
# determine which url's need to be queried.
62+
# if we're going to fetch both prod and test, do test first
63+
# so any duplicates will be overwritten/preferred by the production version
64+
# NOTE: While Matter is in BETA we fetch the test certificates by default
65+
if fetch_test_certificates:
66+
base_urls.add(TEST_URL)
67+
if fetch_production_certificates:
68+
base_urls.add(PRODUCTION_URL)
69+
70+
try:
71+
async with ClientSession(raise_for_status=True) as http_session:
72+
for url_base in base_urls:
73+
# fetch the paa certificates list
74+
async with http_session.get(
75+
f"{url_base}/dcl/pki/root-certificates"
76+
) as response:
77+
result = await response.json()
78+
paa_list = result["approvedRootCertificates"]["certs"]
79+
# grab each certificate
80+
for paa in paa_list:
81+
# do not fetch a certificate if we already fetched it
82+
if paa["subjectKeyId"] in LAST_CERT_IDS:
83+
continue
84+
async with http_session.get(
85+
f"{url_base}/dcl/pki/certificates/{paa['subject']}/{paa['subjectKeyId']}"
86+
) as response:
87+
result = await response.json()
88+
89+
certificate_data: dict = result["approvedCertificates"]["certs"][0]
90+
certificate: str = certificate_data["pemCert"]
91+
subject = certificate_data["subjectAsText"]
92+
certificate = certificate.rstrip("\n")
93+
94+
await write_paa_root_cert(
95+
certificate,
96+
subject,
97+
paa_trust_store_path,
98+
)
99+
LAST_CERT_IDS.add(paa["subjectKeyId"])
100+
fetch_count += 1
101+
except ClientError as err:
102+
LOGGER.warning(
103+
"Fetching latest certificates failed: error %s", err, exc_info=err
104+
)
105+
else:
106+
LOGGER.info("Fetched %s PAA root certificates from DCL.", fetch_count)
107+
return fetch_count

matter_server/server/server.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def get_info(self) -> ServerInfoMessage:
137137
"""Return (version)info of the Matter Server."""
138138
assert self.device_controller.compressed_fabric_id is not None
139139
return ServerInfoMessage(
140-
fabric_id=self.device_controller.fabric_id,
140+
fabric_id=self.fabric_id,
141141
compressed_fabric_id=self.device_controller.compressed_fabric_id,
142142
schema_version=SCHEMA_VERSION,
143143
min_supported_schema_version=MIN_SCHEMA_VERSION,

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ dependencies = [
3131

3232
[project.optional-dependencies]
3333
server = [
34-
"home-assistant-chip-core==2023.2.2"
34+
"home-assistant-chip-core==2023.2.2",
35+
"cryptography==39.0.1"
3536
]
3637
test = [
3738
"black==23.1.0",

0 commit comments

Comments
 (0)