/** * @license * Copyright 2022-2026 Matter.js Authors * SPDX-License-Identifier: Apache-2.0 */ import { AccessControlServer } from "#behaviors/access-control"; import { GroupKeyManagementServer } from "#behaviors/group-key-management"; import { NodeLifecycle } from "#node/NodeLifecycle.js"; import { Bytes, deepCopy, ImplementationError, Logger, ObservableValue, Seconds, Time, Timer } from "@matter/general"; import { AccessLevel, DataModelPath } from "@matter/model"; import { AccessControl, assertRemoteActor, Fabric, FabricManager, GroupMessageEventInfo, SessionManager, } from "@matter/protocol"; import { EndpointNumber, FabricIndex, GroupId, NodeId, Status, StatusResponseError } from "@matter/types"; import { AccessControl as AccessControlTypes } from "@matter/types/clusters/access-control"; import { Groupcast } from "@matter/types/clusters/groupcast"; import { GroupcastBehavior } from "./GroupcastBehavior.js"; const logger = Logger.get("GroupcastServer"); /** Membership.KeySetId sentinel indicating the referenced key set no longer exists in GKM. */ const UNMAPPED_KEYSET_ID = 0xffff; /** TODO: remove once the Groupcast cluster leaves provisional state in the Matter specification. */ const GROUPCAST_IS_PROVISIONAL = true; /** * This is the default server implementation of {@link GroupcastBehavior}. * * The Groupcast cluster is node-scoped and must be placed on the root endpoint. * Use {@link GroupcastServer.with} to enable the desired features (e.g. "Listener", "Sender"). * * Key behaviors: * - Manages per-fabric group membership (persistent). * - Drives {@link FabricGroups} with group-to-key-set mappings and multicast address policy. * - Registers an {@link AccessControlServer.AuxAclObservable} to supply synthetic ACL entries for groups * with `hasAuxiliaryAcl=true`. AccessControlServer calls back to collect entries when needed. * - On first use by a fabric, marks the fabric as "GroupcastAdopted" in GKM, making GroupKeyMap read-only. * - Migrates legacy group data from the Groups cluster on startup. */ export class GroupcastServer extends GroupcastBehavior { declare internal: GroupcastServer.Internal; /** Timer for GroupcastTesting auto-disable. */ #testingTimer?: Timer; override initialize() { // TODO: remove this guard once the Groupcast cluster leaves provisional state in the Matter specification if (GROUPCAST_IS_PROVISIONAL) { throw new ImplementationError( "The Groupcast cluster is provisional in Matter 1.6. Do not add it to a node.", ); } const lifecycle = this.endpoint.lifecycle as NodeLifecycle; this.reactTo(lifecycle.online, this.#online); } async #online() { // Register the aux ACL observable with AccessControlServer so it can subscribe for updates this.agent.get(AccessControlServer).registerAuxAclProvider(this.internal.auxAcl); const fabrics = this.env.get(FabricManager); // Sync FabricGroups for all existing fabrics for (const fabric of fabrics) { this.#syncFabricGroups(fabric); } // Migrate legacy Groups cluster data to Groupcast Membership await this.#migrate(); // Reconcile key sets that may have been removed via GKM somehow this.#reconcileUnmappedKeys(); // Update the derived state once after all sync/migration is done this.#updateUsedMcastAddrCount(); this.#emitAuxAcl(); // Keep in sync as fabrics are added or removed this.reactTo(fabrics.events.added, this.#handleFabricAdded); this.reactTo(fabrics.events.deleted, this.#handleFabricDeleted); // When GKM key sets are removed, groupKeyMap is also cleaned up — use that as trigger // to reconcile membership entries whose key set no longer exists (mark as UNMAPPED_KEYSET_ID) const gkmEvents = this.endpoint.eventsOf(GroupKeyManagementServer); this.reactTo(gkmEvents.groupKeyMap$Changed, this.#reconcileUnmappedKeys); // React to fabricUnderTest changes to dynamically subscribe/unsubscribe from group message events. // While no fabric is under test, SessionManager.groupMessage emits are a no-op (no listeners). this.reactTo(this.events.fabricUnderTest$Changed, this.#handleFabricUnderTestChanged); if (this.state.fabricUnderTest !== FabricIndex.NO_FABRIC) { this.#attachGroupMessageListener(); } } /** * Check whether the current session has Admin privileges for this cluster/endpoint. * Key and useAuxiliaryACL fields in Manage-access commands require Admin * privilege to prevent privilege escalation. */ #requireAdmin() { // Called only from handlers that already asserted remote actor, safe to narrow const session = this.context as AccessControl.RemoteActorSession; const location: AccessControl.Location = { path: DataModelPath.none, endpoint: this.endpoint.number, cluster: GroupcastBehavior.cluster.id, owningFabric: session.fabric, }; if (session.authorityAt(AccessLevel.Administer, location) !== AccessControl.Authority.Granted) { throw new StatusResponseError( "Admin privilege required for key or ACL operations", Status.UnsupportedAccess, ); } } override async joinGroup(request: Groupcast.JoinGroupRequest) { assertRemoteActor(this.context); const fabricIndex = this.context.session.associatedFabric.fabricIndex; const { groupId, endpoints, keySetId, key, useAuxiliaryAcl, replaceEndpoints, mcastAddrPolicy } = request; // Validate groupId range if (groupId < 1 || groupId > 0xfff7) { throw new StatusResponseError("Invalid group ID", Status.ConstraintError); } // Privilege escalation prevention: key and useAuxiliaryACL require Admin if (key !== undefined || useAuxiliaryAcl !== undefined) { this.#requireAdmin(); } // Validate endpoints: ep 0 (root) and ep > 0xFFFE are invalid per spec for (const ep of endpoints) { if (ep === 0 || ep > 0xfffe) { throw new StatusResponseError(`Endpoint ${ep} is invalid`, Status.UnsupportedEndpoint); } } // If LN-only (no SD feature), an empty endpoint list is not allowed if (!this.features.sender && endpoints.length === 0) { throw new StatusResponseError("Empty endpoint list requires Sender feature", Status.ConstraintError); } // Validate multicast address policy: PerGroup requires PerGroupAddr feature const policy = mcastAddrPolicy ?? Groupcast.MulticastAddrPolicy.IanaAddr; if (policy === Groupcast.MulticastAddrPolicy.PerGroup && !this.features.perGroup) { throw new StatusResponseError( "PerGroup multicast policy requires PerGroupAddr feature", Status.ConstraintError, ); } await this.#applyKeySet(fabricIndex, keySetId, key); const membership = deepCopy(this.state.membership); const existingIdx = membership.findIndex(m => m.groupId === groupId && m.fabricIndex === fabricIndex); if (existingIdx >= 0) { // Update existing group membership const existing = membership[existingIdx]; const lnFields = this.features.listener ? { endpoints: replaceEndpoints ? endpoints : [...new Set([...(existing.endpoints ?? []), ...endpoints])], hasAuxiliaryAcl: useAuxiliaryAcl ?? existing.hasAuxiliaryAcl, } : {}; membership[existingIdx] = { ...existing, ...lnFields, keySetId, mcastAddrPolicy: policy, }; } else { // New group: check capacity limits const fabricMemberships = membership.filter(m => m.fabricIndex === fabricIndex); const perFabricLimit = Math.floor(this.state.maxMembershipCount / 2); if (fabricMemberships.length >= perFabricLimit) { throw new StatusResponseError("Per-fabric membership limit reached", Status.ResourceExhausted); } if (membership.length >= this.state.maxMembershipCount) { throw new StatusResponseError("Total membership limit reached", Status.ResourceExhausted); } // Check MaxMcastAddrCount for new multicast address allocation if (policy === Groupcast.MulticastAddrPolicy.PerGroup) { const currentUsed = this.#computeUsedMcastAddrCount(membership); if (currentUsed >= this.state.maxMcastAddrCount) { throw new StatusResponseError("MaxMcastAddrCount limit reached", Status.ResourceExhausted); } } else if (policy === Groupcast.MulticastAddrPolicy.IanaAddr) { // IanaAddr pool counts as 1 address; only check if pool not yet allocated const hasIanaAddr = membership.some(m => m.mcastAddrPolicy === Groupcast.MulticastAddrPolicy.IanaAddr); if (!hasIanaAddr) { const currentUsed = this.#computeUsedMcastAddrCount(membership); if (currentUsed >= this.state.maxMcastAddrCount) { throw new StatusResponseError("MaxMcastAddrCount limit reached", Status.ResourceExhausted); } } } const lnFields = this.features.listener ? { endpoints, hasAuxiliaryAcl: useAuxiliaryAcl ?? false } : {}; membership.push({ groupId, ...lnFields, keySetId, mcastAddrPolicy: policy, fabricIndex, }); } this.state.membership = membership; this.#updateUsedMcastAddrCount(); this.#syncFabricGroups(this.env.get(FabricManager).for(fabricIndex)); this.#emitAuxAcl(); /* Provisional in Matter 1.6.0: const gkm = this.agent.get(GroupKeyManagementServer); gkm.setGroupcastAdopted(fabricIndex, true); */ } override leaveGroup(request: Groupcast.LeaveGroupRequest): Groupcast.LeaveGroupResponse { assertRemoteActor(this.context); const fabricIndex = this.context.session.associatedFabric.fabricIndex; const { groupId, endpoints: requestedEndpoints } = request; // GroupID 0 = wildcard: leave ALL groups for this fabric if (groupId === GroupId.NO_GROUP_ID) { const fabricMemberships = this.state.membership.filter(m => m.fabricIndex === fabricIndex); if (fabricMemberships.length === 0) { throw new StatusResponseError("No groups to leave", Status.NotFound); } this.state.membership = this.state.membership.filter(m => m.fabricIndex !== fabricIndex); this.#updateUsedMcastAddrCount(); this.#syncFabricGroups(this.env.get(FabricManager).for(fabricIndex)); this.#emitAuxAcl(); return { groupId: GroupId.NO_GROUP_ID, endpoints: [] }; } const membership = deepCopy(this.state.membership); const entryIdx = membership.findIndex(m => m.groupId === groupId && m.fabricIndex === fabricIndex); if (entryIdx < 0) { throw new StatusResponseError(`Group ${groupId} not found`, Status.NotFound); } const entry = membership[entryIdx]; let removedEndpoints: EndpointNumber[]; if (!requestedEndpoints || requestedEndpoints.length === 0) { // No specific endpoints specified → remove entire entry removedEndpoints = entry.endpoints ?? []; membership.splice(entryIdx, 1); } else { const currentEndpoints = entry.endpoints ?? []; removedEndpoints = requestedEndpoints.filter(ep => currentEndpoints.includes(ep)); const remainingEndpoints = currentEndpoints.filter(ep => !requestedEndpoints.includes(ep)); if (remainingEndpoints.length === 0) { if (this.features.sender) { // SD feature enabled: keep entry as sender-only (omit endpoints when LN absent) const { endpoints: _omit, ...entryWithoutEndpoints } = entry; membership[entryIdx] = this.features.listener ? { ...entry, endpoints: [] } : entryWithoutEndpoints; } else { // LN-only: remove the entry entirely membership.splice(entryIdx, 1); } } else { membership[entryIdx] = { ...entry, endpoints: remainingEndpoints }; } } this.state.membership = membership; this.#updateUsedMcastAddrCount(); this.#syncFabricGroups(this.env.get(FabricManager).for(fabricIndex)); this.#emitAuxAcl(); return { groupId, endpoints: removedEndpoints }; } override async updateGroupKey(request: Groupcast.UpdateGroupKeyRequest) { assertRemoteActor(this.context); const fabricIndex = this.context.session.associatedFabric.fabricIndex; const { groupId, keySetId, key } = request; // Privilege escalation prevention: key requires Admin if (key !== undefined) { this.#requireAdmin(); } const membership = deepCopy(this.state.membership); const entryIdx = membership.findIndex(m => m.groupId === groupId && m.fabricIndex === fabricIndex); if (entryIdx < 0) { throw new StatusResponseError(`Group ${groupId} not found`, Status.NotFound); } await this.#applyKeySet(fabricIndex, keySetId, key); membership[entryIdx] = { ...membership[entryIdx], keySetId }; this.state.membership = membership; this.#syncFabricGroups(this.env.get(FabricManager).for(fabricIndex)); } configureAuxiliaryAcl(request: Groupcast.ConfigureAuxiliaryAclRequest) { assertRemoteActor(this.context); const fabricIndex = this.context.session.associatedFabric.fabricIndex; const { groupId, useAuxiliaryAcl } = request; const membership = deepCopy(this.state.membership); const entryIdx = membership.findIndex(m => m.groupId === groupId && m.fabricIndex === fabricIndex); if (entryIdx < 0) { throw new StatusResponseError(`Group ${groupId} not found`, Status.NotFound); } membership[entryIdx] = { ...membership[entryIdx], hasAuxiliaryAcl: useAuxiliaryAcl }; this.state.membership = membership; this.#emitAuxAcl(); } override groupcastTesting(request: Groupcast.GroupcastTestingRequest) { assertRemoteActor(this.context); const fabricIndex = this.context.session.associatedFabric.fabricIndex; const { testOperation, durationSeconds } = request; // Cancel any running auto-disable timer if (this.#testingTimer) { this.#testingTimer.stop(); this.#testingTimer = undefined; } if (testOperation === Groupcast.GroupcastTesting.DisableTesting) { this.state.fabricUnderTest = FabricIndex.NO_FABRIC; return; } // Enable testing: set FabricUnderTest to the current fabric index this.state.fabricUnderTest = fabricIndex; // If durationSeconds is provided, auto-disable after that duration if (durationSeconds !== undefined && durationSeconds > 0) { this.#testingTimer = Time.getTimer( `groupcast-testing-${fabricIndex}`, Seconds(durationSeconds), this.callback(this.#testingTimerExpired), ); this.#testingTimer.start(); } } #testingTimerExpired() { this.#testingTimer = undefined; this.state.fabricUnderTest = FabricIndex.NO_FABRIC; } #handleFabricUnderTestChanged(value: FabricIndex) { if (value !== FabricIndex.NO_FABRIC) { this.#attachGroupMessageListener(); } else { this.#detachGroupMessageListener(); } } #attachGroupMessageListener() { if (this.internal.groupMessageHandler !== undefined) { return; } const sessions = this.env.get(SessionManager); // SessionManager.onGroupMessage fires after the dispatching action's context has exited, so // wrap the handler with this.callback to acquire a fresh context for state/event access. const handler = this.callback(this.#onGroupMessage); sessions.onGroupMessage.on(handler); this.internal.groupMessageHandler = handler; } #detachGroupMessageListener() { if (this.internal.groupMessageHandler === undefined) { return; } const sessions = this.env.get(SessionManager); sessions.onGroupMessage.off(this.internal.groupMessageHandler); this.internal.groupMessageHandler = undefined; } #onGroupMessage(info: GroupMessageEventInfo) { // Only emit for the fabric under test if (info.fabric === undefined || info.fabric.fabricIndex !== this.state.fabricUnderTest) { return; } this.events.groupcastTesting?.emit( { groupId: info.groupId, endpointId: info.endpointId, clusterId: info.clusterId, elementId: info.elementId, accessAllowed: info.accessAllowed, groupcastTestResult: info.result, fabricIndex: info.fabric.fabricIndex, }, this.context, ); } /** Sync the FabricGroups instance for a fabric from the current Membership state. */ #handleFabricAdded(fabric: Fabric) { this.#syncFabricGroups(fabric); } #handleFabricDeleted(fabric: Fabric) { this.#cleanupFabric(fabric.fabricIndex); } #syncFabricGroups(fabric: Fabric) { const fabricIndex = fabric.fabricIndex; const fabricMemberships = this.state.membership.filter(m => m.fabricIndex === fabricIndex); // Set per-group multicast address policies BEFORE updating groupKeyIdMap. The latter triggers // ServerGroupNetworking to bind the multicast address via multicastAddressFor, which reads the // policy map - so the policy must be in place first or it falls back to PerGroupId-derived. for (const m of fabricMemberships) { const policy = m.mcastAddrPolicy === Groupcast.MulticastAddrPolicy.PerGroup ? "perGroupId" : "ianaAddr"; fabric.groups.setGroupMulticastPolicy(m.groupId, policy); } // Rebuild group→keySet map const groupKeyIdMap = new Map(); for (const m of fabricMemberships) { groupKeyIdMap.set(m.groupId, m.keySetId); } fabric.groups.groupKeyIdMap = groupKeyIdMap; // Mirror Membership mappings into GKM GroupKeyMap so the attribute read reflects them. // Data dependency: Groupcast owns the group→keySet relationship; GroupKeyMap must reflect it. // GKM's per-fabric cap (maxGroupsPerFabric) and Groupcast's per-fabric cap (floor(maxMembershipCount/2)) // must stay aligned so the mirror never exceeds the GKM validator. const gkm = this.agent.get(GroupKeyManagementServer); const otherFabricsMap = gkm.state.groupKeyMap.filter(e => e.fabricIndex !== fabricIndex); const mappings = fabricMemberships .filter(m => m.keySetId !== UNMAPPED_KEYSET_ID) .map(m => ({ groupId: m.groupId, groupKeySetId: m.keySetId, fabricIndex })); gkm.state.groupKeyMap = [...otherFabricsMap, ...mappings]; // Mirror Membership endpoints to GKM GroupTable (spec attribute) and FabricGroups.endpoints // (runtime dispatch lookup used by InteractionServer for wildcard group commands). const otherFabricsTable = gkm.state.groupTable.filter(e => e.fabricIndex !== fabricIndex); const tableEntries = fabricMemberships .filter(m => m.endpoints !== undefined && m.endpoints.length > 0) .map(m => ({ groupId: m.groupId, endpoints: [...m.endpoints!], groupName: "", fabricIndex, })); gkm.state.groupTable = [...otherFabricsTable, ...tableEntries]; const previousGroupIds = new Set(fabric.groups.endpoints.keys()); for (const m of fabricMemberships) { const eps = m.endpoints ?? []; if (eps.length > 0) { fabric.groups.endpoints.set(m.groupId, [...eps]); } else { fabric.groups.endpoints.delete(m.groupId); } previousGroupIds.delete(m.groupId); } for (const gid of previousGroupIds) { fabric.groups.endpoints.delete(gid); } } /** * Mark membership entries as unmapped (KeySetId=UNMAPPED_KEYSET_ID) when their referenced key set no longer exists in GKM. * Runs at startup to reconcile state after potential GKM KeySetRemove operations. */ #reconcileUnmappedKeys() { const gkmState = this.endpoint.stateOf(GroupKeyManagementServer); const membership = this.state.membership; let dirty = false; for (const m of membership) { if (m.keySetId !== UNMAPPED_KEYSET_ID && m.keySetId !== 0) { const exists = gkmState.groupKeySets.some( ks => ks.fabricIndex === m.fabricIndex && ks.groupKeySetId === m.keySetId, ); if (!exists) { m.keySetId = UNMAPPED_KEYSET_ID; dirty = true; } } } if (dirty) { this.state.membership = [...membership]; } } /** * Validate and apply a key set for a group operation. * If `key` is provided, validates and creates a new key set in GKM. * Otherwise, validates that the key set already exists. */ async #applyKeySet(fabricIndex: FabricIndex, keySetId: number, key: Bytes | undefined) { const gkm = this.agent.get(GroupKeyManagementServer); if (key !== undefined) { if (key.byteLength !== 16) { throw new StatusResponseError("Key must be exactly 16 bytes", Status.ConstraintError); } if (gkm.validateKeySetId(fabricIndex, keySetId)) { throw new StatusResponseError(`KeySet ${keySetId} already exists for fabric`, Status.AlreadyExists); } await gkm.createKeySetForGroupcast(this.env.get(FabricManager).for(fabricIndex), keySetId, Bytes.of(key)); } else { if (!gkm.validateKeySetId(fabricIndex, keySetId)) { throw new StatusResponseError(`KeySet ${keySetId} not found for fabric`, Status.NotFound); } } } /** Recompute auxiliary ACL entries and emit them via the observable so AccessControlServer updates. */ #emitAuxAcl() { if (!this.endpoint.behaviors.has(AccessControlServer)) return; const entries: AccessControlTypes.AccessControlEntry[] = this.state.membership .filter(m => m.hasAuxiliaryAcl) .map(m => ({ privilege: AccessControlTypes.AccessControlEntryPrivilege.Operate, authMode: AccessControlTypes.AccessControlEntryAuthMode.Group, subjects: [NodeId(BigInt(m.groupId))], targets: m.endpoints?.map(ep => ({ cluster: null, endpoint: ep, deviceType: null, })) ?? null, auxiliaryType: AccessControlTypes.AccessControlAuxiliaryType.Groupcast, fabricIndex: m.fabricIndex, })); this.internal.auxAcl.emit(entries, this.context); } /** * Compute UsedMcastAddrCount from a membership array: * - Each PerGroup group contributes 1 unique address * - All IanaAddr groups together share 1 address */ #computeUsedMcastAddrCount(membership: Groupcast.Membership[]): number { const perGroupCount = membership.filter( m => m.mcastAddrPolicy === Groupcast.MulticastAddrPolicy.PerGroup, ).length; const hasIanaAddr = membership.some(m => m.mcastAddrPolicy === Groupcast.MulticastAddrPolicy.IanaAddr); return perGroupCount + (hasIanaAddr ? 1 : 0); } /** Recompute and update the UsedMcastAddrCount attribute. */ #updateUsedMcastAddrCount() { this.state.usedMcastAddrCount = this.#computeUsedMcastAddrCount(this.state.membership); } /** Remove all state for a departed fabric. */ #cleanupFabric(fabricIndex: FabricIndex) { this.state.membership = this.state.membership.filter(m => m.fabricIndex !== fabricIndex); this.#updateUsedMcastAddrCount(); this.#emitAuxAcl(); } /** * Migrate legacy group data from the GKM groupTable into Groupcast Membership entries. * Only migrates fabrics that have existing GKM groups but no Membership entries yet. * Legacy groups are assigned PerGroup multicast policy for backwards compatibility. */ async #migrate() { const fabrics = this.env.get(FabricManager); const gkmState = this.endpoint.stateOf(GroupKeyManagementServer); let migrated = false; for (const fabric of fabrics) { const fi = fabric.fabricIndex; const hasMembership = this.state.membership.some(m => m.fabricIndex === fi); if (hasMembership) continue; // already has Groupcast membership const fabricGroups = gkmState.groupTable.filter(g => g.fabricIndex === fi); if (fabricGroups.length === 0) continue; // no legacy groups logger.info(`Migrating ${fabricGroups.length} legacy group(s) for fabric ${fi} to Groupcast`); const newEntries: Groupcast.Membership[] = fabricGroups.map(group => { const keyMapping = gkmState.groupKeyMap.find(m => m.fabricIndex === fi && m.groupId === group.groupId); // Use PerGroup policy for legacy compat only if PGA feature is enabled const mcastAddrPolicy = this.features.perGroup ? Groupcast.MulticastAddrPolicy.PerGroup : Groupcast.MulticastAddrPolicy.IanaAddr; const lnFields = this.features.listener ? { endpoints: group.endpoints.filter(ep => ep !== EndpointNumber(0)), hasAuxiliaryAcl: false, } : {}; return { groupId: group.groupId, ...lnFields, keySetId: keyMapping?.groupKeySetId ?? 0, mcastAddrPolicy, fabricIndex: fi, }; }); this.state.membership = [...this.state.membership, ...newEntries]; this.#syncFabricGroups(fabric); /* Provisional in Matter 1.6.0: const gkm = this.agent.get(GroupKeyManagementServer); gkm.setGroupcastAdopted(fi, true); */ migrated = true; } if (migrated) { this.#updateUsedMcastAddrCount(); logger.info("Groupcast migration complete"); } } override async [Symbol.asyncDispose]() { this.#testingTimer?.stop(); this.#testingTimer = undefined; this.#detachGroupMessageListener(); await super[Symbol.asyncDispose]?.(); } } export namespace GroupcastServer { /** Non-persistent internal state for GroupcastServer. */ export class Internal { /** * Observable that holds all auxiliary ACL entries supplied by this cluster. * AccessControlServer subscribes to this and rebuilds its ACL cache whenever it emits. */ auxAcl: AccessControlServer.AuxAclObservable = ObservableValue([]); /** Active subscription handler on SessionManager.groupMessage while fabricUnderTest is set. */ groupMessageHandler?: (info: GroupMessageEventInfo) => void; } /** Default state overrides for GroupcastServer. */ export class State extends GroupcastBehavior.State { /** * Implementation-defined maximum membership count (min 10 per spec). * Set to 2 * GKM.maxGroupsPerFabric (44) so per-fabric quota floor(44/2)=22 aligns exactly * with GKM's per-fabric cap. Even number avoids spillover semantics. */ override maxMembershipCount = 44; /** Implementation-defined maximum multicast address count (min 1 per spec). */ override maxMcastAddrCount = 44; } }