/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { ActionContext } from "#behavior/context/ActionContext.js"; import { NodeLifecycle } from "#node/NodeLifecycle.js"; import { deepCopy, ImplementationError, Logger } from "@matter/general"; import { FieldElement } from "@matter/model"; import { assertRemoteActor, Fabric, FabricManager, hasRemoteActor, IPK_DEFAULT_EPOCH_START_TIME, } from "@matter/protocol"; import { EndpointNumber, FabricIndex, GroupId, MATTER_EPOCH_OFFSET_US, Status, StatusResponseError, } from "@matter/types"; import { GroupKeyManagement } from "@matter/types/clusters/group-key-management"; import { GroupKeyManagementBehavior } from "./GroupKeyManagementBehavior.js"; const logger = Logger.get("GroupKeyManagementServer"); const MAX_64BIT_TIME = BigInt("0xffffffffffffffff"); /** Per core§11.27.7.1.4 a Groupcast-created GroupKeySet has EpochStartTime0=1 (internal times are unix-epoch based). */ const GROUPCAST_KEY_EPOCH_START_TIME = MATTER_EPOCH_OFFSET_US + BigInt(1); const GroupKeyManagementBase = GroupKeyManagementBehavior; // Enhance the schema by a fabric scoped structure for the GroupKeySetStruct to enable persistence const groupKeySetStruct = GroupKeyManagementBase.schema.datatypes.require("GroupKeySetStruct"); const groupKeySetStructFS = groupKeySetStruct.extend( { name: "GroupKeySetStructFS", }, FieldElement({ name: "FabricIndex", id: 0xfe, type: "FabricIndex", conformance: "M" }), ); // GroupKeyMulticastPolicy has no effect here; it is reported as the PerGroupID default until the field is obsolete const reportsMulticastPolicy = !groupKeySetStruct.fields.require("GroupKeyMulticastPolicy").isObsolete; const schema = GroupKeyManagementBase.schema.extend( {}, groupKeySetStructFS, FieldElement( { name: "groupKeySets", type: "list", quality: "N", access: "RW VM F", conformance: "M", }, FieldElement({ name: "entry", type: "GroupKeySetStructFS" }), ), ); function withoutMulticastPolicy({ groupKeyMulticastPolicy: _policy, ...keySet }: T): Omit { return keySet; } /** * This is the default server implementation of {@link GroupKeyManagementBehavior}. */ export class GroupKeyManagementServer extends GroupKeyManagementBase { declare readonly state: GroupKeyManagementServer.State; static override readonly schema = schema; override initialize() { if (this.features.cacheAndSync) { throw new ImplementationError("The CacheAndSync feature is provisional. Do not use it."); } // TODO: remove this guard once the Groupcast feature leaves provisional state in the Matter specification if (this.features.groupcast) { throw new ImplementationError( "The Groupcast feature of GroupKeyManagement is provisional in Matter 1.6.1. Do not enable it.", ); } // Key sets stored by earlier versions still carry the multicast policy if (this.state.groupKeySets.some(({ groupKeyMulticastPolicy }) => groupKeyMulticastPolicy !== undefined)) { this.state.groupKeySets = this.state.groupKeySets.map(withoutMulticastPolicy); } // Validate the state this.reactTo(this.events.groupKeyMap$Changing, this.#validateGroupKeyMap); this.reactTo(this.events.groupTable$Changing, this.#validateGroupTable); // Update the group key map when the group key sets change this.reactTo(this.events.groupKeyMap$Changed, this.#updateGroupKeyMap); const lifecycle = this.endpoint.lifecycle as NodeLifecycle; this.reactTo(lifecycle.online, this.#online); } async #online() { // Validate the maximum supported group keys and groups per fabric if they are set to minimum values. if (this.state.maxGroupKeysPerFabric === 1 && this.state.maxGroupsPerFabric === 0) { // We assume these are the specification defaults: maxGroupKeysPerFabric = 1, maxGroupsPerFabric = 0 let groupsFound = false; this.endpoint.visit(endpoint => { if (!groupsFound && "groups" in endpoint.behaviors.supported) { groupsFound = true; } }); if (groupsFound) { throw new ImplementationError( "One of the device types you use has a Groups cluster. Please adjust the Group Key Management cluster maximum defaults. You need to support groups management.", ); } } const fabrics = this.env.get(FabricManager); // When we have group key sets, we need to ensure that they are initialized on the Fabric group manager if (this.state.groupKeySets.length) { const groupKeysForFabric = new Map(); const groupKeySets = deepCopy(this.state.groupKeySets); for (let i = 0; i < groupKeySets.length; i++) { const groupKeySet = groupKeySets[i]; const fabricIndex = groupKeySet.fabricIndex; const keys = groupKeysForFabric.get(fabricIndex) ?? []; if (keys.length === 0 && !fabrics.has(fabricIndex)) { // Should normally not happen, but we saw such crashes without details, so handle it gracefully logger.warn( `Stored GroupKeySets for FabricIndex ${fabricIndex}, but no such fabric exists anymore. Cleaning up stale entries.`, ); groupKeySets.splice(i, 1); i--; continue; } keys.push(groupKeySet); groupKeysForFabric.set(fabricIndex, keys); } if (groupKeySets.length !== this.state.groupKeySets.length) { // Some entries were removed, so update the persisted state this.state.groupKeySets = groupKeySets; } for (const [fabricIndex, keys] of groupKeysForFabric.entries()) { const fabric = fabrics.for(fabricIndex); for (const groupKeySet of keys) { await fabric.groups.setFromGroupKeySet(groupKeySet); } } } if (this.state.groupKeyMap.length) { this.#updateGroupKeyMap(this.state.groupKeyMap); } if (this.state.groupTable.length) { // Restore the runtime endpoint map, which group command dispatch and the UDP multicast memberships use for (const { fabricIndex, groupId, endpoints } of this.state.groupTable) { if (fabrics.has(fabricIndex)) { fabrics.for(fabricIndex).groups.endpoints.set(groupId, [...endpoints]); } } } } #validateGroupKeyMap(groupKeyMap: GroupKeyManagement.GroupKeyMap[]) { // When GCAST is active and the fabric has adopted Groupcast, GroupKeyMap is read-only if (this.features.groupcast && hasRemoteActor(this.context)) { const fabricIndex = this.context.session?.fabric?.fabricIndex; if (fabricIndex !== undefined && this.#isGroupcastAdoptedForFabric(fabricIndex)) { throw new StatusResponseError( "GroupKeyMap is read-only when GroupcastAdoption.GroupcastAdopted is true", Status.InvalidInState, ); } } const knownGroupKeys = new Set(); for (const keySetId of this.state.groupKeySets) { const { groupKeySetId, fabricIndex } = keySetId; const id = `${fabricIndex}-${groupKeySetId}`; knownGroupKeys.add(id); } const knownGroupIds = new Set(); const groupIdsPerFabric = new Array(); for (const entry of groupKeyMap) { const { groupId, fabricIndex } = entry; if (!GroupId.isApplicationGroupId(groupId)) { throw new StatusResponseError( "Only operational GroupIds are allowed in GroupKeyMap", Status.InvalidAction, ); } groupIdsPerFabric[fabricIndex] = (groupIdsPerFabric[fabricIndex] ?? 0) + 1; const id = `${fabricIndex}-${groupId}`; if (knownGroupIds.has(id)) { throw new StatusResponseError( `Duplicate GroupId ${groupId} for FabricIndex ${fabricIndex}`, Status.ConstraintError, ); } knownGroupIds.add(id); // The spec does not mandate to check if a key set exists where a group is added for and Tests also try it /*const keyId = `${fabricIndex}-${groupKeySetId}`; if (!knownGroupKeys.has(keyId)) { throw new StatusResponseError( `GroupKeySetId ${groupKeySetId} for FabricIndex ${fabricIndex} not found`, Status.ConstraintError, ); }*/ } if (groupIdsPerFabric.some(count => count > this.state.maxGroupsPerFabric)) { throw new StatusResponseError( `Too many groups per fabric, maximum is ${this.state.maxGroupsPerFabric}`, Status.ResourceExhausted, ); } } #validateGroupTable(groupTable: GroupKeyManagement.GroupInfoMap[]) { const knownGroupIds = new Set(); for (const entry of groupTable) { const { groupId, fabricIndex, endpoints } = entry; if (groupId === 0) { throw new StatusResponseError( "GroupId 0 can not be used as operational group id", Status.ConstraintError, ); } const id = `${fabricIndex}-${groupId}`; if (knownGroupIds.has(id)) { throw new StatusResponseError( `Duplicate GroupId ${groupId} for FabricIndex ${fabricIndex}`, Status.ConstraintError, ); } knownGroupIds.add(id); const endpointIds = new Set(endpoints); if (endpointIds.size !== endpoints.length) { throw new StatusResponseError( `Duplicate endpoint IDs in GroupId ${groupId} for FabricIndex ${fabricIndex}`, Status.ConstraintError, ); } } } #updateGroupKeyMap( groupKeyMap: GroupKeyManagement.GroupKeyMap[], _oldMap?: GroupKeyManagement.GroupKeyMap[], context?: ActionContext, ) { if (context !== undefined && hasRemoteActor(context)) { const fabric = context.session?.associatedFabric; const fabricIndex = fabric?.fabricIndex; // Online context, so we can just update the relevant fabric if (fabric !== undefined && fabricIndex !== undefined) { fabric.groups.groupKeyIdMap = new Map( groupKeyMap .filter(({ fabricIndex: entryIndex }) => entryIndex === fabricIndex) .map(({ groupId, groupKeySetId }) => [groupId, groupKeySetId]), ); return; } } const fabrics = this.env.get(FabricManager); const fabricMaps = new Map>(); for (const entry of groupKeyMap) { const { fabricIndex, groupId, groupKeySetId } = entry; const fabricMap = fabricMaps.get(fabricIndex) ?? new Map(); fabricMap.set(groupId, groupKeySetId); fabricMaps.set(fabricIndex, fabricMap); } for (const fabric of fabrics) { const fabricIndex = fabric.fabricIndex; const map = fabricMaps.get(fabricIndex); if (map === undefined) { continue; } fabric.groups.groupKeyIdMap = map; } } override async keySetWrite({ groupKeySet }: GroupKeyManagement.KeySetWriteRequest) { assertRemoteActor(this.context); const { groupKeySetId, epochKey0, epochKey1, epochKey2, epochStartTime0, epochStartTime1, epochStartTime2, groupKeySecurityPolicy, } = groupKeySet; // Unclear if that should be checked here, but it basically only makes sense here // An epoch key marked with the maximum start time SHALL be disabled and render the corresponding epoch // key slot unused. if (groupKeySet.epochStartTime0 === MAX_64BIT_TIME) { groupKeySet.epochStartTime0 = null; groupKeySet.epochKey0 = null; } if (groupKeySet.epochStartTime1 === MAX_64BIT_TIME) { groupKeySet.epochStartTime1 = null; groupKeySet.epochKey1 = null; } if (groupKeySet.epochStartTime2 === MAX_64BIT_TIME) { groupKeySet.epochStartTime2 = null; groupKeySet.epochKey2 = null; } if (epochKey0 === null || epochStartTime0 === null) { throw new StatusResponseError("EpochKey0 and EpochStartTime0 must be set", Status.InvalidCommand); } if (epochStartTime0 <= IPK_DEFAULT_EPOCH_START_TIME) { // Formally can never be < IPK_DEFAULT_EPOCH_START_TIME, but let's be sure throw new StatusResponseError("EpochStartTime0 must not be 0", Status.InvalidCommand); } if (epochKey1 !== null && (epochStartTime1 === null || epochStartTime1 <= epochStartTime0)) { throw new StatusResponseError( "EpochStartTime1 must be set and greater than EpochStartTime0", Status.InvalidCommand, ); } if (epochKey1 === null && epochStartTime1 !== null) { throw new StatusResponseError("EpochKey1 must be set if EpochStartTime1 is set", Status.InvalidCommand); } if (epochKey2 !== null && epochKey1 === null) { throw new StatusResponseError("EpochKey1 must be set if EpochKey2 is set", Status.InvalidCommand); } if ( epochKey2 !== null && (epochStartTime2 === null || epochStartTime1 === null || epochStartTime2 <= epochStartTime1) ) { throw new StatusResponseError( "EpochStartTime2 must be set and greater than EpochStartTime1", Status.InvalidCommand, ); } if (epochKey2 === null && epochStartTime2 !== null) { throw new StatusResponseError("EpochKey2 must be set if EpochStartTime2 is set", Status.InvalidCommand); } if (groupKeySecurityPolicy !== GroupKeyManagement.GroupKeySecurityPolicy.TrustFirst) { throw new StatusResponseError("GroupKeySecurityPolicy must be TrustFirst", Status.InvalidCommand); } const fabric = this.context.session.associatedFabric; const fabricIndex = fabric.fabricIndex; const keySet = withoutMulticastPolicy(groupKeySet); // Replace or add the group key set to the internal persisted state const existingIndex = this.state.groupKeySets.findIndex( ({ groupKeySetId: entryId, fabricIndex: entryIndex }) => entryIndex === fabricIndex && entryId === groupKeySetId, ); if (existingIndex !== -1) { // Update existing group key set this.state.groupKeySets[existingIndex] = { ...keySet, fabricIndex }; } else { // Add a new group key set const keySetsOfFabric = this.state.groupKeySets.filter(({ fabricIndex: entryIndex }) => entryIndex === fabricIndex).length + 1; // Add 1 because Group Key set 0 for IPK always exists implicitly if (keySetsOfFabric >= this.state.maxGroupKeysPerFabric) { throw new StatusResponseError( `Too many group key sets for fabric ${fabricIndex}, maximum is ${this.state.maxGroupKeysPerFabric}`, Status.ResourceExhausted, ); } this.state.groupKeySets.push({ ...keySet, fabricIndex }); } // Update the Fabric group manager to kick off the internal processes await fabric.groups.setFromGroupKeySet(keySet); } override keySetRead({ groupKeySetId, }: GroupKeyManagement.KeySetReadRequest): GroupKeyManagement.KeySetReadResponse { assertRemoteActor(this.context); const fabric = this.context.session.associatedFabric; // We use the fabric group manager to retrieve the group key set because he also has the id 0 and is synced anyway const groupKeySet = fabric.groups.keySets.asGroupKeySet(groupKeySetId); if (groupKeySet === undefined) { throw new StatusResponseError(`GroupKeySet ${groupKeySetId} not found`, Status.NotFound); } return { groupKeySet: { ...groupKeySet, epochKey0: null, epochKey1: null, epochKey2: null, ...(reportsMulticastPolicy ? { groupKeyMulticastPolicy: GroupKeyManagement.GroupKeyMulticastPolicy.PerGroupId } : {}), }, }; } override keySetRemove({ groupKeySetId }: GroupKeyManagement.KeySetRemoveRequest) { if (groupKeySetId === 0) { throw new StatusResponseError(`GroupKeySet ${groupKeySetId} cannot be removed`, Status.InvalidCommand); } assertRemoteActor(this.context); const fabric = this.context.session.associatedFabric; const fabricIndex = fabric.fabricIndex; // Remove the group key set from the internal persisted state const existingIndex = this.state.groupKeySets.findIndex( ({ groupKeySetId: entryId, fabricIndex: entryIndex }) => entryIndex === fabricIndex && entryId === groupKeySetId, ); if (existingIndex === -1) { throw new StatusResponseError(`GroupKeySet ${groupKeySetId} not found`, Status.NotFound); } this.state.groupKeySets.splice(existingIndex, 1); // Per core§11.2.7.4.1: GroupKeyMap entries for the accessing fabric that refer to the removed key set SHALL be // removed from the list. this.state.groupKeyMap = this.state.groupKeyMap.filter( entry => entry.fabricIndex !== fabricIndex || entry.groupKeySetId !== groupKeySetId, ); // Sync to Fabric group manager to remove too fabric.groups.removeGroupKeySet(groupKeySetId); } override keySetReadAllIndices(): GroupKeyManagement.KeySetReadAllIndicesResponse { assertRemoteActor(this.context); const fabric = this.context.session.associatedFabric; const fabricIndex = fabric.fabricIndex; const groupKeySetIds = this.state.groupKeySets .filter(({ fabricIndex: entryIndex }) => entryIndex === fabricIndex) .map(({ groupKeySetId }) => groupKeySetId); groupKeySetIds.unshift(0); // Always include the group key set 0 return { groupKeySetIds, }; } addEndpointForGroup(fabric: Fabric, groupId: GroupId, endpointId: EndpointNumber, groupName: string): void { const fabricIndex = fabric.fabricIndex; // Add the group to the group table const existingGroupIndex = this.state.groupTable.findIndex( entry => entry.groupId === groupId && entry.fabricIndex === fabricIndex, ); if (existingGroupIndex !== -1) { // If the group already exists, add the endpoint if (!this.state.groupTable[existingGroupIndex].endpoints.includes(endpointId)) { this.state.groupTable[existingGroupIndex].endpoints.push(endpointId); fabric.groups.endpoints.set(groupId, this.state.groupTable[existingGroupIndex].endpoints); } this.state.groupTable[existingGroupIndex].groupName = groupName; } else { if ( this.state.groupTable.filter(({ fabricIndex: entryFabricIndex }) => entryFabricIndex === fabricIndex) .length >= this.state.maxGroupsPerFabric ) { throw new StatusResponseError( `Too many groups for fabric ${fabricIndex}, maximum is ${this.state.maxGroupsPerFabric}`, Status.ResourceExhausted, ); } // Create a new group entry this.state.groupTable.push({ groupId, endpoints: [endpointId], groupName, fabricIndex, }); fabric.groups.endpoints.set(groupId, [endpointId]); } logger.info( `Added endpoint ${endpointId} to group ${groupId} on fabric ${fabricIndex} with name "${groupName}"`, ); } /** * Remove endpoint from the provided group, or all groups if no groupId is provided. */ removeEndpoint(fabric: Fabric, endpointId: EndpointNumber, groupId?: GroupId): boolean { let existing = false; // Remove the endpoint from the group table const groupTable = this.state.groupTable; const fabricIndex = fabric.fabricIndex; // Remove the endpoint from all groups for (let i = groupTable.length - 1; i >= 0; i--) { const entry = groupTable[i]; if (entry.fabricIndex !== fabricIndex || (groupId !== undefined && entry.groupId !== groupId)) { continue; } const endpointExists = entry.endpoints.includes(endpointId); if (endpointExists) { if (entry.endpoints.length === 1 && entry.endpoints[0] === endpointId) { const groupId = entry.groupId; // If no endpoints left, remove the group entry groupTable.splice(i, 1); fabric.groups.endpoints.delete(groupId); } else { entry.endpoints = entry.endpoints.filter(id => id !== endpointId); fabric.groups.endpoints.set(entry.groupId, entry.endpoints); } existing = true; } } return existing; } #isGroupcastAdoptedForFabric(fabricIndex: FabricIndex): boolean { if (!this.features.groupcast) return false; if (!this.state.groupcastAdoption) return false; return this.state.groupcastAdoption.some(entry => entry.fabricIndex === fabricIndex && entry.groupcastAdopted); } /** * Returns whether a GroupKeySetId exists for a given fabric. Called by GroupcastServer to validate KeySetIDs. */ validateKeySetId(fabricIndex: FabricIndex, keySetId: number): boolean { if (keySetId === 0) { // IPK key set always exists return true; } return this.state.groupKeySets.some( entry => entry.fabricIndex === fabricIndex && entry.groupKeySetId === keySetId, ); } /** * Creates a new GroupKeySet for a Groupcast group key, bypassing normal KeySetWrite validation. * Used by GroupcastServer when JoinGroup or UpdateGroupKey is called with a `key` parameter. * The key set uses TrustFirst policy and EpochStartTime0=1 as required by core§11.27.7.1.4. * * @returns true on success, throws if the key set already exists or resources are exhausted */ createKeySetForGroupcast(fabric: Fabric, keySetId: number, epochKey0: Uint8Array): Promise { const fabricIndex = fabric.fabricIndex; const keySetsOfFabric = this.state.groupKeySets.filter(({ fabricIndex: fi }) => fi === fabricIndex).length + 1; if (keySetsOfFabric >= this.state.maxGroupKeysPerFabric) { throw new StatusResponseError( `Too many group key sets for fabric ${fabricIndex}`, Status.ResourceExhausted, ); } const groupKeySet: GroupKeyManagement.GroupKeySet & { fabricIndex: FabricIndex } = { groupKeySetId: keySetId, groupKeySecurityPolicy: GroupKeyManagement.GroupKeySecurityPolicy.TrustFirst, epochKey0, epochStartTime0: GROUPCAST_KEY_EPOCH_START_TIME, epochKey1: null, epochStartTime1: null, epochKey2: null, epochStartTime2: null, fabricIndex, }; this.state.groupKeySets.push(groupKeySet); // Update the fabric group manager so the key is immediately usable return fabric.groups.setFromGroupKeySet(groupKeySet); } setGroupcastAdopted(fabricIndex: FabricIndex, adopted: boolean) { if (!this.state.groupcastAdoption) return; const existing = this.state.groupcastAdoption.findIndex(e => e.fabricIndex === fabricIndex); if (existing !== -1) { this.state.groupcastAdoption[existing] = { ...this.state.groupcastAdoption[existing], groupcastAdopted: adopted, }; } else { this.state.groupcastAdoption.push({ fabricIndex, groupcastAdopted: adopted }); } } } export namespace GroupKeyManagementServer { export class State extends GroupKeyManagementBase.State { /** * Extended state to hold and persist the group key sets for this server. This structure contains all * GroupKeySet entries for all fabrics beside the fabric specific entries of the groupKeyset 0 */ groupKeySets: (GroupKeyManagement.GroupKeySet & { fabricIndex: FabricIndex })[] = []; // Overwrite defaults to allow more than 3 group keys and 4 groups per fabric, because we can override maxGroupKeysPerFabric = 20; // The Minimum would be 3; override maxGroupsPerFabric = 22; // The Minimum would be 4. Aligned with Groupcast quota=floor(44/2). /** * Per-fabric Groupcast adoption state. Only present when the Groupcast feature is enabled and the optional * GroupcastAdoption attribute is defined; the default server never populates it. */ declare groupcastAdoption?: GroupKeyManagement.GroupcastAdoption[]; } }