blob: e4b2df03740d5efcd13c8917b9fd49e9ee9c947d [file]
#
# Copyright (c) 2025 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 ipaddress
import logging
from dataclasses import dataclass
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from mobly import asserts
import matter.clusters as Clusters
from matter.ChipDeviceCtrl import ChipDeviceController
from matter.clusters.Types import NullValue
from matter.testing.matter_testing import AttributeMatcher
from matter.testing.spec_parsing import build_xml_clusters, dm_from_spec_version
logger = logging.getLogger(__name__)
GROUP_EPOCH_KEY_LENGTH_BYTES = 16
_GROUP_KEY_V1_INFO = b"GroupKey v1.0"
_GROUP_KEY_HASH_INFO = b"GroupKeyHash"
def derive_group_operational_key(epoch_key: bytes, compressed_fabric_id: bytes) -> bytes:
"""Derive the operational group encryption key from an epoch key and compressed fabric ID."""
asserts.assert_equal(len(epoch_key), GROUP_EPOCH_KEY_LENGTH_BYTES,
"Epoch key must be 16 bytes")
asserts.assert_equal(len(compressed_fabric_id), 8,
"Compressed fabric ID must be 8 bytes")
return HKDF(
algorithm=hashes.SHA256(),
length=GROUP_EPOCH_KEY_LENGTH_BYTES,
salt=compressed_fabric_id,
info=_GROUP_KEY_V1_INFO,
).derive(epoch_key)
def derive_group_session_id(epoch_key: bytes, compressed_fabric_id: bytes) -> int:
"""Derive the 16-bit Group Session ID for an epoch key on a fabric."""
operational_key = derive_group_operational_key(epoch_key, compressed_fabric_id)
session_id_bytes = HKDF(
algorithm=hashes.SHA256(),
length=2,
salt=b"",
info=_GROUP_KEY_HASH_INFO,
).derive(operational_key)
return int.from_bytes(session_id_bytes, byteorder="big")
def find_colliding_epoch_key(reference_epoch_key: bytes, compressed_fabric_id: bytes,
max_attempts: int = 65536 * 10) -> bytes:
"""Find an epoch key which lead to a Group Session ID collision with reference_epoch_key on the same fabric.
Group Session IDs are 16 bits wide, so a collision is expected after around 2^16 (65536) attempts.
Limit the number of attempts to avoid infinite loop or long execution time.
10 times the expected convergence attempts should provide less than 0.005% chance of not finding a collision.
"""
target_session_id = derive_group_session_id(reference_epoch_key, compressed_fabric_id)
for attempt in range(1, max_attempts + 1):
candidate = attempt.to_bytes(GROUP_EPOCH_KEY_LENGTH_BYTES, byteorder="big")
if candidate == reference_epoch_key:
continue
if derive_group_session_id(candidate, compressed_fabric_id) == target_session_id:
# found an epoch key whose Group Session ID collides with the one of reference_epoch_key
return candidate
raise ValueError("Could not find a colliding key")
def group_id_from_node_id(node_id: int) -> int:
"""Extracts the 16-bit Group ID from a Group-scoped Node ID."""
return node_id & 0xFFFF
def get_auxiliary_acl_equivalence_set(aux_acl, parts_list) -> set[tuple[int, int, int]]:
"""Expands AuxiliaryACL entries into a set of (fabric_index, group_id, endpoint_id) tuples.
This implements the equivalence class logic for verifying auxiliary entries, accounting
for various encodings and wildcards (empty target lists). It also strictly validates
that Groupcast auxiliary entries have the correct privilege and auth mode.
Args:
aux_acl: The list of AuxiliaryACL entries read from the DUT.
parts_list: The list of endpoints from the Root Node's Descriptor PartsList attribute.
Returns:
A set of (fabric_index, group_id, endpoint_id) tuples representing the granted access.
"""
equivalence_set = set()
for entry in aux_acl:
# We only process Groupcast auxiliary entries.
if entry.auxiliaryType != Clusters.AccessControl.Enums.AccessControlAuxiliaryTypeEnum.kGroupcast:
continue
# Strictly validate metadata for Groupcast auxiliary entries.
asserts.assert_equal(entry.privilege, Clusters.AccessControl.Enums.AccessControlEntryPrivilegeEnum.kOperate,
f"Groupcast auxiliary entry MUST have Operate privilege, but has {entry.privilege}")
asserts.assert_equal(entry.authMode, Clusters.AccessControl.Enums.AccessControlEntryAuthModeEnum.kGroup,
f"Groupcast auxiliary entry MUST have Group auth mode, but has {entry.authMode}")
subjects = entry.subjects if (entry.subjects is not None and entry.subjects is not NullValue) else []
targets = entry.targets if (entry.targets is not None and entry.targets is not NullValue) else []
for subject in subjects:
group_id = group_id_from_node_id(subject)
if not targets:
# Wildcard: empty target list represents all endpoints in the parts list (excluding root).
for endpoint_id in parts_list:
if endpoint_id != 0:
equivalence_set.add((entry.fabricIndex, group_id, endpoint_id))
else:
for target in targets:
endpoint_id = target.endpoint
if endpoint_id is None or endpoint_id is NullValue:
# Wildcard target: applies to all endpoints in the parts list (excluding root).
for ep in parts_list:
if ep != 0:
equivalence_set.add((entry.fabricIndex, group_id, ep))
else:
equivalence_set.add((entry.fabricIndex, group_id, endpoint_id))
return equivalence_set
async def get_feature_map(test) -> tuple:
"""Get supported features."""
feature_map = await test.read_single_attribute_check_success(
cluster=Clusters.Groupcast,
attribute=Clusters.Groupcast.Attributes.FeatureMap,
endpoint=0
)
ln_enabled = bool(feature_map & Clusters.Groupcast.Bitmaps.Feature.kListener)
sd_enabled = bool(feature_map & Clusters.Groupcast.Bitmaps.Feature.kSender)
pga_enabled = bool(feature_map & Clusters.Groupcast.Bitmaps.Feature.kPerGroup)
asserts.assert_true(sd_enabled or ln_enabled,
"At least one of the following features must be enabled: Listener or Sender.")
logger.info(
"FeatureMap: %s : LN supported: %s | SD supported: %s | PGA supported: %s", feature_map, ln_enabled, sd_enabled, pga_enabled)
return ln_enabled, sd_enabled, pga_enabled
async def valid_endpoints_list(test, ln_enabled: bool) -> list:
"""Get the JoinGroup cmd endpoints list based on enabled features such as Listener/Sender.
For Senders: return empty list.
For Listeners: return the list of non-root endpoints that have at least one cluster exposing
one command requiring Operate privilege.
"""
endpoints_list: list = []
if ln_enabled:
operate_only_commands_dict = await get_operate_only_commands(
test.default_controller, test.dut_node_id, exclude_ep0=True)
endpoints_list = sorted(operate_only_commands_dict.keys())
logger.info(
"Endpoints with at least one Operate-privilege command: %s", endpoints_list)
asserts.assert_greater(len(endpoints_list), 0,
"Listener feature is enabled. Endpoint list should not be empty. There should be a valid endpoint for the GroupCast JoinGroup Command.")
return endpoints_list
async def is_groupcast_on_root_node(test) -> bool:
"""Check if Groupcast cluster is present on the RootNode endpoint (EP0)."""
server_list = await test.read_single_attribute_check_success(
cluster=Clusters.Descriptor,
attribute=Clusters.Descriptor.Attributes.ServerList,
endpoint=0)
return Clusters.Groupcast.id in server_list
def generate_membership_entry_matcher(
group_id: int,
key_set_id: int | None = None,
has_auxiliary_acl: bool | None = None,
endpoints: list | None = None,
mcastAddrPolicy: Clusters.Groupcast.Enums.MulticastAddrPolicyEnum | None = None,
test_for_exists: bool = True,
) -> AttributeMatcher:
"""Create a matcher that checks if Membership attribute contains (or does not contain) an entry matching the specified criteria.
Args:
group_id: The groupID to match (required)
key_set_id: The keySetID to match (optional)
has_auxiliary_acl: The HasAuxiliaryACL value to match (optional)
endpoints: The endpoints list to match (optional)
mcastAddrPolicy: The multicast address policy to match (optional)
test_for_exists: If True, membership entry exists. (default: True)
Returns:
An AttributeMatcher that returns True when:
- test_for_exists=True: A Membership entry matches all specified criteria
- test_for_exists=False: No Membership entry matches the specified criteria
"""
def predicate(report) -> bool:
if report.attribute != Clusters.Groupcast.Attributes.Membership:
return False
found_match = False
for entry in report.value:
if entry.groupID != group_id:
continue
if key_set_id is not None and entry.keySetID != key_set_id:
continue
if has_auxiliary_acl is not None:
if entry.hasAuxiliaryACL is None or entry.hasAuxiliaryACL != has_auxiliary_acl:
continue
if endpoints is not None:
if entry.endpoints is None or entry.endpoints != endpoints:
continue
if mcastAddrPolicy is not None:
if entry.mcastAddrPolicy is None or entry.mcastAddrPolicy != mcastAddrPolicy:
continue
found_match = True
break
return found_match if test_for_exists else not found_match
desc_parts = [f"groupID={group_id}"]
if key_set_id is not None:
desc_parts.append(f"keySetID={key_set_id}")
if has_auxiliary_acl is not None:
desc_parts.append(f"hasAuxiliaryACL={has_auxiliary_acl}")
if endpoints is not None:
desc_parts.append(f"endpoints={endpoints}")
if mcastAddrPolicy is not None:
desc_parts.append(f"mcastAddrPolicy={mcastAddrPolicy}")
if test_for_exists:
description = f"Membership has entry with {', '.join(desc_parts)}"
else:
description = f"Membership does NOT have entry with {', '.join(desc_parts)}"
return AttributeMatcher.from_callable(description=description, matcher=predicate)
def generate_membership_empty_matcher() -> AttributeMatcher:
"""Create a matcher that checks if Membership attribute is empty (no groups present).
Returns:
An AttributeMatcher that returns True when the Membership list is empty.
"""
def predicate(report) -> bool:
if report.attribute != Clusters.Groupcast.Attributes.Membership:
return False
return len(report.value) == 0
description = "Membership list is empty (no groups present)"
return AttributeMatcher.from_callable(description=description, matcher=predicate)
def generate_fabric_under_test_matcher(expected_fabric_index: int) -> AttributeMatcher:
"""Create a matcher that checks if FabricUnderTest attribute has the expected value.
Args:
expected_fabric_index: The expected fabric index value.
Returns:
An AttributeMatcher that returns True when FabricUnderTest equals the expected value.
"""
def predicate(report) -> bool:
if report.attribute != Clusters.Groupcast.Attributes.FabricUnderTest:
return False
return report.value == expected_fabric_index
description = f"FabricUnderTest == {expected_fabric_index}"
return AttributeMatcher.from_callable(description=description, matcher=predicate)
def generate_usedMcastAddrCount_entry_matcher(expected_count: int) -> AttributeMatcher:
"""Create a matcher that checks if UsedMcastAddrCount attribute has the expected value.
Args:
expected_count: The expected UsedMcastAddrCount value.
Returns:
An AttributeMatcher that returns True when UsedMcastAddrCount equals the expected value.
"""
def predicate(report) -> bool:
if report.attribute != Clusters.Groupcast.Attributes.UsedMcastAddrCount:
return False
return report.value == expected_count
description = f"UsedMcastAddrCount == {expected_count}"
return AttributeMatcher.from_callable(description=description, matcher=predicate)
@dataclass
class OperateOnlyCommand:
cluster_object: Clusters.ClusterObjects.Cluster
command_object: Clusters.ClusterObjects.ClusterCommand
async def get_operate_only_commands(dev_ctrl: ChipDeviceController, node_id: int, exclude_ep0: bool = True, endpoint_id_to_search: int | None = None) -> dict[int, list[OperateOnlyCommand]]:
"""
Reads all AcceptedCommandList attributes and the SpecificationVersion to determine all
commands that only require Operate privilege.
Args:
dev_ctrl: The ChipDeviceController instance.
node_id: The node ID of the device to query.
exclude_ep0: Boolean to determine if endpoint 0 should be excluded in the search for valid cluster commands
endpoint_id_to_search: Optional argument. When specified, search for commands will only be on clusters on the specified endpoint. Search all endpoints if not specified
Returns:
A list of OperateOnlyCommand dataclass objects for each command that only requires
Operate privilege.
"""
# Helper function to perform wildcard read and get spec info
async def get_device_composition_and_spec(dev_ctrl, node_id) -> tuple[dict, int]:
wildcard_read = await dev_ctrl.Read(node_id, [()])
attributes = wildcard_read.attributes
spec_version = attributes[0][Clusters.BasicInformation][Clusters.BasicInformation.Attributes.SpecificationVersion]
return attributes, spec_version
# Helper function to parse spec
def get_xml_clusters(spec_version: int):
dm = dm_from_spec_version(spec_version)
xml_clusters, _ = build_xml_clusters(dm)
return xml_clusters
def find_commands_on_endpoint_and_cluster(endpoint_id, endpoint_data, operate_only_commands_dict):
for cluster, cluster_data in endpoint_data.items():
if cluster.Attributes.AcceptedCommandList in cluster_data:
command_list = cluster_data[cluster.Attributes.AcceptedCommandList]
for cmd_id in command_list:
try:
xml_command = xml_clusters[cluster.id].accepted_commands[cmd_id]
if xml_command.privilege == Clusters.AccessControl.Enums.AccessControlEntryPrivilegeEnum.kOperate:
cluster_object = Clusters.ClusterObjects.ALL_CLUSTERS[cluster.id]
command_object = Clusters.ClusterObjects.ALL_ACCEPTED_COMMANDS[cluster.id][cmd_id]
# Only client-to-server commands (no response commands)
if not command_object.is_client:
continue
if endpoint_id not in operate_only_commands_dict:
operate_only_commands_dict[endpoint_id] = []
# In this codebase, all generated ClusterCommand subclasses have defaults for all fields.
operate_only_commands_dict[endpoint_id].append(OperateOnlyCommand(
cluster_object=cluster_object, command_object=command_object))
except KeyError:
logger.warning(
"Command ID %s on cluster %s not found in spec XMLs. This may be a manufacturer-specific command.",
cmd_id, cluster.id)
# Main logic
attributes, spec_version = await get_device_composition_and_spec(dev_ctrl, node_id)
xml_clusters = get_xml_clusters(spec_version)
operate_only_commands_dict = {}
if endpoint_id_to_search is not None:
asserts.assert_false((exclude_ep0 and endpoint_id_to_search == 0),
"Endpoint 0 was both specified to be searched in and to be ignored.")
endpoint_data = attributes.get(endpoint_id_to_search)
if endpoint_data is None:
asserts.fail(f"Endpoint {endpoint_id_to_search} not found on the device.")
find_commands_on_endpoint_and_cluster(endpoint_id_to_search, endpoint_data, operate_only_commands_dict)
else:
for endpoint_id, endpoint_data in attributes.items():
if exclude_ep0 and endpoint_id == 0:
continue
find_commands_on_endpoint_and_cluster(endpoint_id, endpoint_data, operate_only_commands_dict)
return operate_only_commands_dict
def get_iana_multicast_address() -> bytes:
"""Returns the 16-byte IANA-assigned multicast address for Groupcast (ff05::fa)."""
return bytes.fromhex("ff0500000000000000000000000000fa")
def get_per_group_multicast_address(fabric_id: int, group_id: int) -> bytes:
"""Returns the 16-byte per-group multicast address (ff35:0040:fd<Fabric ID>00:<Group ID>)."""
# The first 32 bits will always be a fixed value. 0xFF3 defined by RFC 3306,
# 0x05 represents scope, 0x00 is a reserved byte, and 0x40 represents length
# of network prefix (64 bits)
prefix_scope_plen = 0xFF350040
# Create 64 bit network prefix. Consists of FD (locally assigned ULA prefix) and then
# the upper 56 bits of fabric ID (in big endian format)
network_prefix = 0xfd00000000000000 | ((fabric_id >> 8) & 0x00ffffffffffffff)
# Create 32 bit group identifier portion. Constists of the lower 8 bits of fabric id,
# a reserved 0x00 byte, then followed by 16 bit group id
group_id_field = ((fabric_id << 24) & 0xff000000) | (group_id & 0xffff)
# Combine all portions into 128-bit address
addr_int = (prefix_scope_plen << 96) | (network_prefix << 32) | group_id_field
return ipaddress.IPv6Address(addr_int).packed