From b13e0441daa5b87a4733391e33df5d52b8334450 Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Sun, 30 Aug 2026 18:06:43 +0200 Subject: [PATCH 1/3] test(chip-testing): TC-SC-5.3, a command sent to a group and proved by what it changed The certification plan's TC-SC-5.3 is the mirror of TC-SC-6.1: the same four setup steps - an access-control entry admitting the group, a key set, the key map binding, AddGroup - and then, where 6.1 reads that state back over unicast, 5.3 sends a group message through it. Those four steps are now one module both cases use. Sending one needed a controller capability that did not exist. A cert test can now address a group rather than a node, with a key set to encrypt to it and a command carrying no endpoint, since a group command's path names only the cluster and command and the endpoints come from the group's own membership. matter.js resolves the group as a peer whose node id encodes it; chip-tool takes the same thing as a destination id and needs three groupsettings commands of its own first, without which its group data provider refuses the send. Four things the plan does not say, each of which fails silently: The sender needs the key too. Writing the key set to the device is half of it - the controller encrypts the message, so it must hold the key as well, which is what the plan means by "DUT generates a random key". It has to go on the fabric the sending path resolves. The adapter's own handle is a different object for the same fabric index, and group state written there is invisible to the session manager, which asks its own fabric for the key. The access-control entry needs Manage, not Operate. AddGroup is a Manage command, and with Operate the message arrives, decrypts and dispatches - and does nothing, because a group message is unacknowledged and nothing reports the refusal. It reads exactly like a multicast that never arrived. Both groups must be in the key map. AddGroup answers UNSUPPORTED_ACCESS for a group the fabric's map does not name, so a case adding group 2 through group 1 binds both. What step 5 proves is all four of the plan's claims. The multicast address is not shape-matched: the sender's own membership line names the group, the fabric and the address together, so the address is recomputed from that fabric id and group id and compared byte for byte, which also establishes the destination is GroupID 1. The address and port are read from the message's own destination field, the session tag renders a group session, and a unicast read afterwards shows the group the message carried is on the device - which it can only be if the multicast arrived, decrypted under the group key and dispatched. On chip- tool, which logs the group it sent to and nothing more, the address and port half says so rather than passing. Steps 6 and 7 need the Groupcast cluster, which neither test harness has. Verification (repository root): npm run build, npm run format-verify, npm run lint and npm test all pass; the framework suite passes, the certification suite passes on the matter.js leg, and TC-SC-5.3 passes on all four matrix legs. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../src/action/client/ClientInteraction.ts | 2 +- packages/protocol/src/session/GroupSession.ts | 9 + .../test/session/SecureSessionTest.ts | 17 + .../src/chip/cert/controller-adapter.ts | 49 +++ packages/testing/src/chip/index.ts | 2 + .../src/cert/ChipToolControllerAdapter.ts | 66 +++ .../src/cert/InProcessControllerAdapter.ts | 86 ++++ .../test/cert-framework/cert-test.test.ts | 6 + .../cert-framework/controller-adapter.test.ts | 3 + .../test/cert-framework/tc-dd-support.test.ts | 6 + .../cert-framework/tc-idm-4.1-support.test.ts | 3 + .../test/cert-framework/tc-support.test.ts | 3 + support/chip-testing/test/cert/AGENTS.md | 43 ++ .../chip-testing/test/cert/TC-SC-5.3.test.ts | 251 +++++++++++ .../chip-testing/test/cert/TC-SC-6.1.test.ts | 336 ++------------- .../test/cert/tc-group-support.ts | 397 ++++++++++++++++++ 17 files changed, 970 insertions(+), 310 deletions(-) create mode 100644 support/chip-testing/test/cert/TC-SC-5.3.test.ts create mode 100644 support/chip-testing/test/cert/tc-group-support.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 589054d157..f84d0f23bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The main work (all changes without a GitHub username in brackets in the below li - Fix: A mandatory command a cluster leaves unimplemented is no longer dispatched; an invoke answers `UNSUPPORTED_COMMAND`, matching what the cluster advertises in `AcceptedCommandList` - @matter/protocol + - Enhancement: A group message's log line names the port beside the multicast address it went to, in the usual IPv6 form (`dest: [ff35:0040:…]:5540`) - Fix: A command whose payload does not match the command's schema is answered with `INVALID_COMMAND` instead of `FAILURE` - Fix: `UpdateFabricLabel` accepts an empty label, as the specification's `max 32` constraint sets no minimum; it previously failed the command - Enhancement: An interaction can be abandoned by the caller: `ClientRequest.abort` takes an `AbortSignal`, honored for read, write, invoke and subscribe diff --git a/packages/protocol/src/action/client/ClientInteraction.ts b/packages/protocol/src/action/client/ClientInteraction.ts index 44e03d2c11..2ff9a64381 100644 --- a/packages/protocol/src/action/client/ClientInteraction.ts +++ b/packages/protocol/src/action/client/ClientInteraction.ts @@ -70,7 +70,7 @@ const logger = Logger.get("ClientInteraction"); */ function peerAddressDiagnostic(session: Session | undefined) { if (session !== undefined && GroupSession.is(session)) { - return Diagnostic.dict({ dest: session.multicastAddress }); + return Diagnostic.dict({ dest: session.destination }); } return ""; } diff --git a/packages/protocol/src/session/GroupSession.ts b/packages/protocol/src/session/GroupSession.ts index 1ff6b4fec4..98725735d5 100644 --- a/packages/protocol/src/session/GroupSession.ts +++ b/packages/protocol/src/session/GroupSession.ts @@ -98,6 +98,15 @@ export class GroupSession extends SecureSession { return this.#multicastAddress; } + /** + * Where this session sends, in the form an IPv6 destination is written: the multicast address in + * brackets and the port beside it. Group traffic always goes to the standard port, which is what + * makes the pair worth printing together — an address alone does not say where a message went. + */ + get destination(): string { + return `[${this.#multicastAddress}]:${STANDARD_MATTER_PORT}`; + } + /** * Create an outbound group session. */ diff --git a/packages/protocol/test/session/SecureSessionTest.ts b/packages/protocol/test/session/SecureSessionTest.ts index 723f525770..a251617fc8 100644 --- a/packages/protocol/test/session/SecureSessionTest.ts +++ b/packages/protocol/test/session/SecureSessionTest.ts @@ -204,6 +204,23 @@ describe("SecureSession", () => { expect(result.message.packetHeader.destGroupId).equals(groupId); expect(result.message.packetHeader.messageId).equals(0x12345679); }); + it("names where it sends, address and port together", async () => { + const { fabric } = await groupFabric(); + const current = fabric.groups.keySets.currentKeyForId(1); + const groupId = 2; + const session = new GroupSession({ + id: current.sessionId!, + fabric, + keySetId: 1, + operationalGroupKey: current.key, + operationalPrivacyKey: current.privacyKey, + peerNodeId: NodeId(0xffffffffffff0000n | BigInt(groupId)), + multicastAddress: fabric.groups.multicastAddressFor(GroupId(groupId)), + messageCounter: new MessageCounter(fabric.crypto), + }); + + expect(session.destination).equal(`[${session.multicastAddress}]:5540`); + }); it("matches a cached session by fabric, session id and operational key, not by id alone", async () => { const { fabric } = await groupFabric(); diff --git a/packages/testing/src/chip/cert/controller-adapter.ts b/packages/testing/src/chip/cert/controller-adapter.ts index 9a47620097..693ac221bc 100644 --- a/packages/testing/src/chip/cert/controller-adapter.ts +++ b/packages/testing/src/chip/cert/controller-adapter.ts @@ -405,9 +405,58 @@ export interface ControllerAdapter { parseManualPairingCode(code: string): Promise; node(ref: CertNodeRef): CertNodeApi; + + /** + * Addresses a group rather than a node, for a case whose subject is the groupcast itself + * (TC-SC-5.3 step 5). The fabric's group key set and the group's membership are established by + * ordinary unicast commands first; this only decides how the command that follows is addressed. + * + * A controller that cannot send a groupcast throws {@link UnsupportedByControllerError} here + * rather than from the invoke, so a step is refused before it acts. + */ + group(groupId: number): CertGroupApi; + log: LogFollower; } +/** + * Controller-side view of a group, which is a destination rather than a node: a groupcast is + * unacknowledged and carries no response, so there is nothing to read back and no status to await. + * What a step proves about one is proved from the sender's log and from the receiver's later state. + * + * @see {@link MatterSpecification.v16.Core} § 4.15.3 + */ +export interface CertGroupApi { + /** + * Installs the key material the sender needs, which is the other half of the key set a step writes + * to the device: a groupcast is encrypted with the group key, so a controller that only told the + * device about the key cannot send one (Matter Core § 4.16.2). + * + * The plan has the controller *generate* this key, so a case provisions itself here with the same + * key set it writes to the device. + */ + defineKeySet(keySet: GroupKeySetSpec): Promise; + + /** + * No endpoint: a group command's path names only the cluster and command, and the endpoints it + * reaches are the ones the group's own membership names (Matter Core § 8.2.5.1). + */ + invoke(cluster: string | number, command: string, args?: object): Promise; +} + +/** + * The fields of a `GroupKeySetStruct` a cert test provisions on both sides. + * + * @see {@link MatterSpecification.v16.Core} § 11.2.4.1 + */ +export interface GroupKeySetSpec { + groupKeySetId: number; + groupKeySecurityPolicy: number; + /** The 16-byte epoch key, as the caller's own byte type renders it. */ + epochKey0: AllowSharedBufferSource; + epochStartTime0: bigint; +} + /** * An onboarding payload's fixed fields, as {@link ControllerAdapter.parseQrPayload} reports them. * diff --git a/packages/testing/src/chip/index.ts b/packages/testing/src/chip/index.ts index 9e1362907f..d8ba1a6ae5 100644 --- a/packages/testing/src/chip/index.ts +++ b/packages/testing/src/chip/index.ts @@ -51,6 +51,8 @@ export type { AttributeWriteStatus, BatchCommandResult, BatchCommandSpec, + CertGroupApi, + GroupKeySetSpec, CertNodeApi, CertNodeRef, CommissioningTarget, diff --git a/support/chip-testing/src/cert/ChipToolControllerAdapter.ts b/support/chip-testing/src/cert/ChipToolControllerAdapter.ts index ee03914707..a38bdb76a5 100644 --- a/support/chip-testing/src/cert/ChipToolControllerAdapter.ts +++ b/support/chip-testing/src/cert/ChipToolControllerAdapter.ts @@ -25,11 +25,13 @@ import type { AttributeWriteStatus, BatchCommandResult, BatchCommandSpec, + CertGroupApi, CertNodeApi, CertNodeRef, CommissioningTarget, ControllerAdapter, EventPathSpec, + GroupKeySetSpec, EventReadEntry, ManualPairingCodeFields, OnboardingPayloadFields, @@ -185,6 +187,15 @@ function largePayloadArg(transport?: ControllerTransport) { return transport === "tcp" ? " --allow-large-payload 1" : ""; } +/** + * How a group is addressed as a destination: a node id whose upper 48 bits are all ones carries the + * group in its lower 16 (Matter Core § 2.5.4), and chip-tool takes that in place of a node id on any + * command it sends. + */ +function groupDestination(groupId: number): string { + return `0x${(0xffffffffffff0000n | BigInt(groupId)).toString(16)}`; +} + /** chip-tool's own name for the timed-interaction timeout, on `command-by-id` and `write-by-id` alike. */ function timedArg(options?: TimedInteractionOptions) { const timeout = timedInteractionTimeoutOf(options); @@ -750,6 +761,57 @@ function portOverrideFor(id: string) { return port; } +/** + * Sends a command to a group rather than to a node. chip-tool takes the group's destination id in + * place of a node id, and answers nothing: a groupcast is unacknowledged, so its own reply carries no + * status and none is awaited. + */ +class ChipToolCertGroupApi implements CertGroupApi { + readonly #adapter: ChipToolControllerAdapter; + readonly #groupId: number; + + constructor(adapter: ChipToolControllerAdapter, groupId: number) { + this.#adapter = adapter; + this.#groupId = groupId; + } + + /** + * chip-tool keeps its own group state, which the commands a plan's steps send to the *device* do + * not touch: without this its group send fails in `GroupDataProviderImpl` with "item not found". + * `groupsettings` is how its commissioner is told about a group and its key, and the three + * commands together are what `defineKeySet` means on this controller. + */ + async defineKeySet(keySet: GroupKeySetSpec): Promise { + const group = `${this.#groupId}`; + const keySetId = `${keySet.groupKeySetId}`; + + await this.#adapter.execute(`groupsettings add-group group${group} ${group}`); + await this.#adapter.execute( + // Validity 0: the key is current from now, which is what the plan's own epoch start means + `groupsettings add-keysets ${keySetId} ${keySet.groupKeySecurityPolicy} 0 ` + + `hex:${Bytes.toHex(keySet.epochKey0)}`, + ); + await this.#adapter.execute(`groupsettings bind-keyset ${group} ${keySetId}`); + } + + async invoke(cluster: string | number, command: string, args?: object): Promise { + const { cluster: clusterModel, clusterId, command: commandModel } = commandModelFor(cluster, command); + const fields = + args !== undefined && Object.keys(args).length > 0 + ? stringifyChipJson(matterToChipJson(args, commandModel, clusterModel, "hex")) + : "{}"; + + const reply = await this.#adapter.execute( + `any command-by-id ${hex(clusterId)} ${hex(commandModel.id)} ${quoteArg(fields)} ` + + // chip-tool wants an endpoint argument even for a group command, which carries none; + // 0 is what its own group tests pass + `${groupDestination(this.#groupId)} 0${largePayloadArg(this.#adapter.transport)}`, + ); + + assertNoFailure(reply, `group invoke ${clusterModel.name}.${commandModel.name}`); + } +} + class ChipToolCertNodeApi implements CertNodeApi { readonly #adapter: ChipToolControllerAdapter; readonly #nodeId: NodeId; @@ -1352,6 +1414,10 @@ export class ChipToolControllerAdapter implements ControllerAdapter { return new ChipToolCertNodeApi(this, ref); } + group(groupId: number): CertGroupApi { + return new ChipToolCertGroupApi(this, groupId); + } + /** * Subscribes `node` to `path` and starts forwarding its reports, returning the establishing reply * so the caller can take the priming values out of it. diff --git a/support/chip-testing/src/cert/InProcessControllerAdapter.ts b/support/chip-testing/src/cert/InProcessControllerAdapter.ts index 4c8d1e4591..9b5223977e 100644 --- a/support/chip-testing/src/cert/InProcessControllerAdapter.ts +++ b/support/chip-testing/src/cert/InProcessControllerAdapter.ts @@ -42,6 +42,7 @@ import { Write, WriteResult, } from "@matter/main/protocol"; +import { SessionManager } from "@matter/main/protocol"; import { AttributeId, ClusterId, @@ -49,6 +50,7 @@ import { EndpointNumber, EventId, ManualPairingCodeCodec, + GroupId, NodeId, Status, StatusResponseError, @@ -62,6 +64,7 @@ import type { AttributeWriteStatus, BatchCommandResult, BatchCommandSpec, + CertGroupApi, CertNodeApi, CertNodeRef, CommissioningTarget, @@ -70,6 +73,7 @@ import type { ControllerTransport, EventPathSpec, EventReadEntry, + GroupKeySetSpec, ManualPairingCodeFields, OnboardingPayloadFields, ReadAttributeOptions, @@ -217,6 +221,25 @@ function commandRequestFor(spec: BatchCommandSpec, commandRef?: number) { }); } +/** + * A command path without an endpoint, which is what a group command carries: the endpoint comes from + * the group's own membership rather than from the sender (Matter Core § 8.2.5.1), and matter.js + * refuses a group invoke that names one. + */ +function groupCommandRequestFor(cluster: string | number, command: string, args?: object) { + const { model: clusterModel, id: clusterId } = certClusterModelFor(cluster); + const commandModel = clusterModel.commands(command); + if (commandModel?.id === undefined) { + throw new ImplementationError(`Unknown command "${command}" on cluster ${cluster}`); + } + + return { + cluster: { id: ClusterId(clusterId), name: clusterModel.name }, + command: { id: CommandId(commandModel.id), name: commandModel.name, schema: commandModel }, + fields: args !== undefined && Object.keys(args).length > 0 ? args : undefined, + }; +} + function isConcretePath(path: AttributePathSpec) { return path.endpoint !== undefined && path.cluster !== undefined && path.attribute !== undefined; } @@ -988,4 +1011,67 @@ export class InProcessControllerAdapter implements ControllerAdapter { node(ref: CertNodeRef): CertNodeApi { return new InProcessCertNodeApi(this.id, this.#startedController, this.#adminFabric, ref); } + + group(groupId: number): CertGroupApi { + return new InProcessCertGroupApi(this.id, this.#startedController, this.#adminFabric, groupId); + } +} + +/** + * Sends a command to a group rather than to a node. matter.js addresses a group as a peer whose node + * id encodes the group (Matter Core § 2.5.4), so the fabric's own address for that node id resolves + * to a {@link ClientGroup} and its interaction sends the groupcast. + */ +class InProcessCertGroupApi implements CertGroupApi { + readonly #adapterId: string; + readonly #controller: ServerNode; + readonly #fabric: Fabric; + readonly #groupId: number; + + constructor(adapterId: string, controller: ServerNode, fabric: Fabric, groupId: number) { + this.#adapterId = adapterId; + this.#controller = controller; + this.#fabric = fabric; + this.#groupId = groupId; + } + + /** + * The fabric the sending path itself resolves. The adapter's own handle is a different object for + * the same fabric index, and group state written on that one is invisible to the session manager, + * which asks its own fabric for the key when it opens the group session. + */ + get #sendingFabric(): Fabric { + return this.#controller.env.get(SessionManager).fabricFor(this.#address); + } + + get #address() { + return this.#fabric.addressOf(NodeId.fromGroupId(this.#groupId)); + } + + async defineKeySet(keySet: GroupKeySetSpec): Promise { + await runTagged(this.#adapterId, async () => { + const fabric = this.#sendingFabric; + await fabric.groups.setFromGroupKeySet({ + ...keySet, + epochKey1: null, + epochStartTime1: null, + epochKey2: null, + epochStartTime2: null, + }); + fabric.groups.groupKeyIdMap.set(GroupId(this.#groupId), keySet.groupKeySetId); + }); + } + + async invoke(cluster: string | number, command: string, args?: object): Promise { + await runTagged(this.#adapterId, async () => { + const group = await this.#controller.peers.forAddress(this.#address); + + const request = Invoke({ commands: [groupCommandRequestFor(cluster, command, args)] }); + + // A groupcast is unacknowledged and answered by nobody, so the iteration ends without + // yielding; draining it is what sends the message + for await (const _chunk of group.interaction.invoke(request)) { + } + }); + } } diff --git a/support/chip-testing/test/cert-framework/cert-test.test.ts b/support/chip-testing/test/cert-framework/cert-test.test.ts index 0e2ae30710..27d54e65fa 100644 --- a/support/chip-testing/test/cert-framework/cert-test.test.ts +++ b/support/chip-testing/test/cert-framework/cert-test.test.ts @@ -160,6 +160,9 @@ function stubControllerAdapter(log: LogFollower): ControllerAdapter { async commission() { return "ref"; }, + group(): never { + throw new InternalError("not used by these tests"); + }, node() { throw new Error("not implemented in this test"); }, @@ -975,6 +978,9 @@ describe("CertTest", () => { async parseManualPairingCode(): Promise { throw new InternalError("not used in this test"); }, + group: (): never => { + throw new InternalError("not used by these tests"); + }, node: () => nodeFor("dut"), }; diff --git a/support/chip-testing/test/cert-framework/controller-adapter.test.ts b/support/chip-testing/test/cert-framework/controller-adapter.test.ts index bd0374e8d8..d6cae2161c 100644 --- a/support/chip-testing/test/cert-framework/controller-adapter.test.ts +++ b/support/chip-testing/test/cert-framework/controller-adapter.test.ts @@ -40,6 +40,9 @@ function fakeControllerAdapter(id: string): ControllerAdapter { async parseManualPairingCode(): Promise { throw new InternalError("not used in this test"); }, + group(): never { + throw new InternalError("not used by these tests"); + }, node() { throw new InternalError("not used in this test"); }, diff --git a/support/chip-testing/test/cert-framework/tc-dd-support.test.ts b/support/chip-testing/test/cert-framework/tc-dd-support.test.ts index cb8e175da2..1acb1b9611 100644 --- a/support/chip-testing/test/cert-framework/tc-dd-support.test.ts +++ b/support/chip-testing/test/cert-framework/tc-dd-support.test.ts @@ -216,6 +216,9 @@ function contextWith( parseQrPayload: unused, parseManualPairingCode: unused, node: nodeFor, + group: (): never => { + throw new InternalError("not used by these tests"); + }, } satisfies ControllerAdapter; const checks = new Array(); @@ -1135,6 +1138,9 @@ class UnpairFixture { // The commissioning helpers record what the DUT reads from the code before they use it parseQrPayload: async payload => qrPayloadFields(payload), parseManualPairingCode: unused, + group: (): never => { + throw new InternalError("not used by these tests"); + }, node: () => node, }; diff --git a/support/chip-testing/test/cert-framework/tc-idm-4.1-support.test.ts b/support/chip-testing/test/cert-framework/tc-idm-4.1-support.test.ts index 0cfd9a2487..a0f9166610 100644 --- a/support/chip-testing/test/cert-framework/tc-idm-4.1-support.test.ts +++ b/support/chip-testing/test/cert-framework/tc-idm-4.1-support.test.ts @@ -146,6 +146,9 @@ class Fixture { async parseManualPairingCode(): Promise { throw new InternalError("not used in this test"); }, + group: (): never => { + throw new InternalError("not used by these tests"); + }, node: () => node, }; diff --git a/support/chip-testing/test/cert-framework/tc-support.test.ts b/support/chip-testing/test/cert-framework/tc-support.test.ts index afe03d777a..a2c3dca758 100644 --- a/support/chip-testing/test/cert-framework/tc-support.test.ts +++ b/support/chip-testing/test/cert-framework/tc-support.test.ts @@ -1660,6 +1660,9 @@ describe("CommissionedRefs", () => { async parseManualPairingCode(): Promise { throw new InternalError("not used in this test"); }, + group: (): never => { + throw new InternalError("not used by these tests"); + }, node: () => nodeFor(role), }); diff --git a/support/chip-testing/test/cert/AGENTS.md b/support/chip-testing/test/cert/AGENTS.md index 96ac18d756..d7665af9d8 100644 --- a/support/chip-testing/test/cert/AGENTS.md +++ b/support/chip-testing/test/cert/AGENTS.md @@ -2395,3 +2395,46 @@ Nothing in the controller expresses "either transport is usable" as a request fl `transportPreference` is set once, for the session, and the protocol layer's hard `requiredTransport` lever is not surfaced. So the plan's step text describes what an ordinary invoke already is on this stack, and the case's substance lives entirely in the three checks above. + +## The group-messaging block, and what sending one actually needed (`TC-SC-5.3`) + +`TC-SC-5.3` is the mirror of `TC-SC-6.1`: the same four setup steps — an ACL entry admitting the +group, a key set, the GroupKeyMap binding, AddGroup — and then, where 6.1 reads that state back over +unicast, 5.3 sends a **groupcast** through it. The setup is one module (`tc-group-support.ts`) both +cases use, so the two cannot drift. + +**The controller gained a group destination.** `cx.controllers.dut.group(id)` returns a `CertGroupApi` +with `defineKeySet` and an `invoke` that takes **no endpoint** — a group command's path names only the +cluster and command, and matter.js refuses a group invoke that names an endpoint. matter.js resolves +the group as a peer whose node id encodes it (`NodeId.fromGroupId`); chip-tool takes the same thing as +a destination id of `0xFFFF'FFFF'FFFF'0000 | groupId`. + +Four things the plan does not say, each of which failed silently until found: + +- **The sender needs the key too.** Writing the key set to the device is half of it; the controller + encrypts the groupcast, so it must hold the key as well — which is what the plan's "DUT generates a + random key" means in practice. `defineKeySet` does that, and the case calls it in the same step that + writes the key set to the device. +- **On the fabric the sender resolves.** The adapter's own `Fabric` handle is a *different object* for + the same fabric index than the one `SessionManager.fabricFor` returns, and group state written on the + adapter's copy is invisible to the sending path. The symptom is `No group key set found for groupId` + from a controller that provably just provisioned one. +- **The ACL entry needs Manage, not Operate.** `Groups.AddGroup` is a Manage command. With Operate the + message arrives, decrypts and dispatches — and does nothing, because a group message is + unacknowledged and nothing reports the refusal. It reads exactly like a multicast that never arrived. + `aclAdmitsGroupStep` therefore takes the privilege the case's own later steps need. +- **Both groups must be in the GroupKeyMap.** `AddGroup` answers `UNSUPPORTED_ACCESS` for a group the + fabric's map does not name (Application Clusters § 1.3.7.1), so a case that adds group 2 *through* + group 1 binds both in the GroupKeyMap step. + +**What step 5 proves.** The plan asks for four things, and the sender's own log carries all four. The +multicast address is not shape-matched: the DUT's membership line names the group, the fabric and the +address together, so the address is recomputed from that fabric id and group id and compared byte for +byte — which also establishes the destination is GroupID 1. The port and address are read from the +invoke's `dest:` field, and the session tag renders `•group#…`, which is DSIZ naming a group. Beside +that is the effect: a unicast `ViewGroup(2)` afterwards, which can only succeed if the multicast +arrived, decrypted under the group key and dispatched. + +**A production change came with it.** The group invoke's diagnostic printed the address alone; +`GroupSession.destination` now renders `[
]:` so the log says where a message actually +went. Steps 6 and 7 stay not-applicable: they need the Groupcast cluster, which neither TH has. diff --git a/support/chip-testing/test/cert/TC-SC-5.3.test.ts b/support/chip-testing/test/cert/TC-SC-5.3.test.ts new file mode 100644 index 0000000000..984f30af31 --- /dev/null +++ b/support/chip-testing/test/cert/TC-SC-5.3.test.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Bytes } from "@matter/main"; +import type { CertStepContext, CheckRecord } from "@matter/testing"; +import { certTest, resolveControllerImplementation } from "@matter/testing"; +import { + aclAdmitsGroupStep, + addGroupStep, + GROUP, + GROUPS, + GROUPS_ENDPOINT, + groupKeyMapStep, + groupMulticastAddress, + ipv6Bytes, + PRIVILEGE_MANAGE, + keyMaterialStep, + keySetWriteStep, +} from "./tc-group-support.js"; +import { + CertCheckFailedError, + CommissionedRefs, + describeValue, + expectSequence, + literally, + LOG_TIMEOUT, + recordAll, +} from "./tc-support.js"; + +const commissioned = new CommissionedRefs(); + +/** The group the plan's step 5 adds *through* the group the steps before it established. */ +const SECOND_GROUP = { id: 2, name: "GroupTwo" }; + +/** + * The plan's step 5: an AddGroup sent as a group command over GroupID 1. + * + * A groupcast is unacknowledged and answered by nobody, so nothing comes back to check. What proves it + * arrived is the TH's own state afterwards: group 2 can only be in the TH's Groups cluster if the + * multicast reached it, decrypted under the group key and dispatched. The read that establishes that is + * unicast, because a group session carries no reads. + */ +/** + * The sender's own line for the group it joined, which names the fabric and the address together — + * so the address the invoke goes to can be tied to *this* group rather than shape-matched. + */ +const MEMBERSHIP_LINE = /Adding membership for group (\d+) on fabric (\d+) .*with address (\S+)/; + +/** + * matter.js's line for a group invoke: the session tag says the session is a group one, and `dest:` + * names where the message went, address and port together in the usual IPv6 form. + */ +function groupInvokeLine(address: string) { + return new RegExp( + `ClientInteraction Invoke » •group#[0-9a-f]+⇵[0-9a-f]+ dest: ${literally(`[${address}]:${MATTER_PORT}`)} `, + ); +} + +/** The port group traffic goes to, which the plan's step 5 asks to see (Matter Core § 4.15.3). */ +const MATTER_PORT = 5540; + +async function addGroupOverGroupcast(cx: CertStepContext) { + const dut = cx.controllers.dut; + const node = dut.node(commissioned.require("dut")); + const from = await dut.log.markSettled(); + + await cx.controllers.dut + .group(GROUP.id) + .invoke(GROUPS.name, "addGroup", { groupId: SECOND_GROUP.id, groupName: SECOND_GROUP.name }); + + const sent = await groupcastSentCheck(cx, from); + + const response = await node.invoke(GROUPS.name, "viewGroup", { groupId: SECOND_GROUP.id }, GROUPS_ENDPOINT); + const { status, groupId } = + typeof response === "object" && response !== null ? (response as { status?: unknown; groupId?: unknown }) : {}; + + const arrived = Number(status) === 0 && Number(groupId) === SECOND_GROUP.id; + recordAll(cx, [ + { check: () => sent, what: "the DUT sent the command to the group's own multicast address" }, + { + check: () => ({ + type: "response", + verdict: arrived ? "pass" : "fail", + detail: `after the groupcast the TH answers ViewGroup(${SECOND_GROUP.id}) with ${describeValue(response)}`, + }), + what: "the group the groupcast asked the TH to add is on the TH", + }, + ]); + + if (!arrived) { + throw new CertCheckFailedError( + `the TH does not hold group ${SECOND_GROUP.id} after the groupcast: ${describeValue(response)}`, + ); + } +} + +/** + * Confirms the message went where a group message must go: to the multicast address this fabric uses + * for this group, on a session the sender itself renders as a group one. + * + * The address is not shape-matched. The sender's own membership line names the group, the fabric and + * the address together, so the address is recomputed from that fabric id and group id and compared + * byte for byte — which is what the plan's "FF35:0040:FD00:" asks for, and what + * also establishes the destination is GroupID 1 rather than some other group. + */ +async function groupcastSentCheck(cx: CertStepContext, from: number): Promise { + const dut = cx.controllers.dut; + + if (resolveControllerImplementation() !== "matterjs") { + // chip-tool names the group it sends to and nothing else — no destination address, no port — + // so what it can show is the group, and the step's other check is what shows the message + // arrived + const sent = await expectSequence( + dut.log, + "chip", + `a group send to group ${GROUP.id}`, + { chip: [new RegExp(`Sending command to group 0x${GROUP.id.toString(16)}(?![0-9a-f])`)] }, + from, + LOG_TIMEOUT, + ); + return sent.verdict === "pass" + ? { + ...sent, + verdict: "unverified", + accepted: + "chip-tool logs the group it sent to but not the destination address or port, so the " + + "address format and the port cannot be read from this controller's own output", + } + : sent; + } + + const membership = await expectSequence( + dut.log, + "matterjs", + MEMBERSHIP_LINE.source, + { matterjs: [MEMBERSHIP_LINE] }, + 0, + LOG_TIMEOUT, + ); + if (membership.verdict !== "pass" || membership.matched === undefined) { + return membership; + } + + const [, group, fabric, address] = MEMBERSHIP_LINE.exec(membership.matched) ?? []; + if (group === undefined || fabric === undefined || address === undefined) { + return { type: "device-log", verdict: "fail", detail: `unreadable membership line: ${membership.matched}` }; + } + if (Number(group) !== GROUP.id) { + return { + type: "device-log", + verdict: "fail", + detail: `the DUT joined group ${group}, not the group ${GROUP.id} this case sends on`, + }; + } + + const expected = groupMulticastAddress(BigInt(fabric), GROUP.id); + const actual = ipv6Bytes(address); + if (actual === undefined || Bytes.toHex(actual) !== Bytes.toHex(expected)) { + return { + type: "device-log", + verdict: "fail", + detail: + `the address for group ${GROUP.id} on fabric ${fabric} is ${address}, and § 4.15.3 makes it ` + + `${Bytes.toHex(expected)}`, + matched: membership.matched, + logLine: membership.logLine, + }; + } + + return expectSequence( + dut.log, + "matterjs", + `a group invoke to [${address}]:${MATTER_PORT}`, + { matterjs: [groupInvokeLine(address)] }, + from, + LOG_TIMEOUT, + ); +} + +certTest("TC-SC-5.3", { + plan: "group_communication.adoc", + pics: ["MCORE.ROLE.COMMISSIONER", "GRPKEY.C"], + app: "all-clusters", +}) + .step( + "1a", + "TH should have the ACL entry with the AuthMode as Group by DUT", + aclAdmitsGroupStep(commissioned, PRIVILEGE_MANAGE), + { + expected: + "The TH's ACL carries an entry whose AuthMode is Group and whose subjects name the group, alongside " + + "the administer entry the DUT itself uses.", + }, + ) + .step("1b", "DUT generates a random key and EpochKey0 assigned to GroupKeySetID 1", keyMaterialStep(), { + expected: + "The DUT holds a key set the next step can write. This step produces the artifact in-process, so it is " + + "not evidence about the TH.", + }) + .step( + 2, + "DUT sends KeySetWrite command to GroupKeyManagement cluster to TH on EP0", + keySetWriteStep(commissioned), + { + pics: "GRPKEY.C.C00.Tx", + expected: "Test Harness receives the KeySetWrite command from the DUT.", + }, + ) + .step( + 3, + "DUT binds GroupId with GroupKeySetID in the GroupKeyMap attribute list on GroupKeyManagement cluster", + // Both groups: the group the message travels on, and the one its AddGroup names, which the TH + // would otherwise refuse for want of a key set + groupKeyMapStep(commissioned, [GROUP.id, SECOND_GROUP.id]), + { + pics: "GRPKEY.C.A0000", + expected: "Test Harness receives the binding of GroupKeySetID with the GroupID from DUT.", + }, + ) + .step( + 4, + 'DUT sends AddGroup Command to TH on EP0 with GroupID 1 and GroupName "GroupOne"', + addGroupStep(commissioned), + { + pics: "G.C.C00.Tx", + expected: "Test Harness receives the AddGroup command from the DUT.", + }, + ) + .step( + 5, + "DUT sends a AddGroup Command to the Groups cluster with the GroupID field set to 2 and the GroupName set " + + 'to "GroupTwo". The command is sent as a group command using GroupID 1', + addGroupOverGroupcast, + { + pics: "G.C.C00.Tx", + expected: + "The group message goes to the multicast address for this fabric and group on port 5540, with DSIZ " + + "naming a group and the destination naming GroupID 1, and the TH holds the group it carried.", + }, + ) + .step(6, "DUT sends the Groupcast JoinGroup command on the TH on EP0", async () => {}, { + notApplicable: + "The plan skips this where the TH's root endpoint has no Groupcast cluster, and neither TH has one.", + }) + .step(7, "DUT sends a command to the TH as a group command over the Groupcast address", async () => {}, { + notApplicable: "Sends through the membership step 6 would have established, which neither TH has.", + }) + .finalize(cx => commissioned.decommissionAll(cx)); diff --git a/support/chip-testing/test/cert/TC-SC-6.1.test.ts b/support/chip-testing/test/cert/TC-SC-6.1.test.ts index edcdc400c7..fc27e427ce 100644 --- a/support/chip-testing/test/cert/TC-SC-6.1.test.ts +++ b/support/chip-testing/test/cert/TC-SC-6.1.test.ts @@ -4,262 +4,44 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Bytes } from "@matter/main"; -import { Matter } from "@matter/model"; -import type { CertNodeApi, CertNodeRef, CertStepContext } from "@matter/testing"; import { certTest } from "@matter/testing"; -import type { CommandFieldValue } from "./tc-support.js"; import { - answersWithStatus, - CertCheckFailedError, - CommissionedRefs, - describeValue, - expectCommandInvoke, - expectMessageWithPath, - LOG_TIMEOUT, - record, - requireId, - responseStatusOf, -} from "./tc-support.js"; - -const GROUP_KEY_MANAGEMENT = Matter.clusters.require("GroupKeyManagement"); -const GROUPS = Matter.clusters.require("Groups"); -const ACCESS_CONTROL = Matter.clusters.require("AccessControl"); - -const GROUP_KEY_MANAGEMENT_ID = requireId(GROUP_KEY_MANAGEMENT.id, "GroupKeyManagement cluster"); -const GROUPS_ID = requireId(GROUPS.id, "Groups cluster"); -const ACCESS_CONTROL_ID = requireId(ACCESS_CONTROL.id, "AccessControl cluster"); - -/** GroupKeyManagement and AccessControl are root-node clusters; Groups lives on the on/off light. */ -const ROOT_ENDPOINT = 0; -const GROUPS_ENDPOINT = 1; - -const GROUP = { id: 1, name: "GroupOne" }; -const GROUP_KEY_SET_ID = 1; - -/** The fabric's own IPK key set, which commissioning writes and no step removes (Matter Core § 11.2.2). */ -const IPK_KEY_SET_ID = 0; - -/** The Groups feature that decides whether a `ViewGroupResponse` may answer with an empty name. */ -const GROUP_NAMES_PROPERTY = "groupNames"; -const GROUP_NAMES_FEATURE = 1 << 0; - -/** `AccessControlEntryPrivilegeEnum.Operate` and `AccessControlEntryAuthModeEnum.Group`. */ -const PRIVILEGE_OPERATE = 3; -const AUTH_MODE_GROUP = 3; + aclAdmitsGroupStep, + addGroupStep, + GROUP, + GROUP_KEY_MANAGEMENT, + GROUP_KEY_MANAGEMENT_ID, + GROUP_KEY_SET_ID, + GROUPS, + GROUPS_ENDPOINT, + GROUPS_ID, + groupKeyMapStep, + invokeAndCheck, + IPK_KEY_SET_ID, + keepsGroupNames, + keyMaterialStep, + keySetWriteStep, + ROOT_ENDPOINT, +} from "./tc-group-support.js"; +import { CommissionedRefs, describeValue, record } from "./tc-support.js"; const commissioned = new CommissionedRefs(); -function attributeId(cluster: typeof GROUP_KEY_MANAGEMENT, attributeName: string): number { - return requireId(cluster.attributes.require(attributeName).id, `${cluster.name}.${attributeName}`); -} - -/** - * The key set the DUT writes, in the shape the plan's step 1b describes. The start time is a Unix - * timestamp rather than the plan's own literal, for the reason AGENTS.md records under "An `epoch-us` - * cannot carry the plan's literal start time"; nothing here depends on its value. - */ -function groupKeySet() { - return { - groupKeySetId: GROUP_KEY_SET_ID, - groupKeySecurityPolicy: 0, - epochKey0: Bytes.fromHex("d0d1d2d3d4d5d6d7d8d9dadbdcdddedf"), - epochStartTime0: 1_600_000_000_000_000n, - epochKey1: null, - epochStartTime1: null, - epochKey2: null, - epochStartTime2: null, - }; -} - -/** - * Invokes a command on the TH and verifies the TH's own log recorded it with the fields sent. A - * response carrying its own status is checked separately, since a command the cluster refused still - * resolves. - */ -async function invokeAndCheck( - cx: CertStepContext, - ref: CertNodeRef, - cluster: typeof GROUP_KEY_MANAGEMENT, - clusterId: number, - endpoint: number, - commandName: string, - args: object, - fields: CommandFieldValue[], -): Promise { - const th = cx.devices.th; - const from = th.log.mark(); - - let response: unknown; - try { - response = await cx.controllers.dut.node(ref).invoke(cluster.name, commandName, args, endpoint); - } catch (e) { - cx.recorder.check({ type: "response", verdict: "fail", detail: String(e) }); - throw e; - } - cx.recorder.check({ - type: "response", - verdict: "pass", - detail: response === undefined ? "status=Success" : `status=Success, response=${describeValue(response)}`, - }); - - if (answersWithStatus(cluster, commandName)) { - const payloadStatus = responseStatusOf(response); - record( - cx, - { - type: "response", - verdict: payloadStatus === 0 ? "pass" : "fail", - detail: - payloadStatus === undefined - ? `${commandName} answered ${describeValue(response)}, which carries no status` - : `${commandName} response status=${payloadStatus}`, - }, - `${cluster.name}.${commandName} response status`, - ); - } - - const logCheck = await expectCommandInvoke( - th.log, - th.flavor, - endpoint, - clusterId, - requireId(cluster.commands.require(commandName).id, `${cluster.name}.${commandName}`), - fields, - from, - LOG_TIMEOUT, - ); - record(cx, logCheck, `CommandDataIB log for ${cluster.name}.${commandName}`); - - return response; -} - -/** - * Whether the TH's Groups cluster keeps group names. A feature map is a bitmap, and both adapters - * decode a bitmap through the model into an object of named bits rather than the raw number. - */ -async function keepsGroupNames(node: CertNodeApi): Promise { - const value = await node.readAttribute({ - endpoint: GROUPS_ENDPOINT, - cluster: GROUPS_ID, - attribute: attributeId(GROUPS, "featureMap"), - }); - if (typeof value === "number") { - return (value & GROUP_NAMES_FEATURE) !== 0; - } - if (typeof value === "object" && value !== null && GROUP_NAMES_PROPERTY in value) { - return Boolean(value[GROUP_NAMES_PROPERTY]); - } - throw new CertCheckFailedError( - `TH answered Groups FeatureMap with ${describeValue(value)}, which names no features`, - ); -} - -/** The ACL the TH already holds for this fabric, which a new entry is appended to rather than replacing. */ -async function readAcl(node: CertNodeApi): Promise { - const value = await node.readAttribute({ - endpoint: ROOT_ENDPOINT, - cluster: ACCESS_CONTROL_ID, - attribute: attributeId(ACCESS_CONTROL, "acl"), - }); - if (!Array.isArray(value)) { - throw new CertCheckFailedError(`TH answered its ACL with ${describeValue(value)}, not a list`); - } - return value; -} - -/** - * Whether an ACL entry admits this group — what step 1a asks the DUT to put in place. A subject is a - * uint64, so it reaches here as a `bigint` on one controller and a `number` on another; the comparison - * is on the value, not the type. - */ -function isGroupEntry(entry: unknown): boolean { - if (typeof entry !== "object" || entry === null) { - return false; - } - const { authMode, subjects } = entry as { authMode?: unknown; subjects?: unknown }; - if (Number(authMode) !== AUTH_MODE_GROUP || !Array.isArray(subjects)) { - return false; - } - return subjects.some(subject => { - const value = typeof subject === "bigint" || typeof subject === "number" ? Number(subject) : undefined; - return value === GROUP.id; - }); -} - certTest("TC-SC-6.1", { plan: "group_communication.adoc", pics: ["MCORE.ROLE.COMMISSIONER", "GRPKEY.C"], app: "all-clusters", }) - .step( - "1a", - "TH should have the ACL entry with the AuthMode as Group by DUT", - async cx => { - const dut = cx.controllers.dut; - const th = cx.devices.th; - - const ref = await dut.commission({ - passcode: th.commissioning.passcode, - discriminator: th.commissioning.discriminator, - }); - commissioned.set("dut", ref); - - const node = dut.node(ref); - - // The DUT's own administer entry is in this list; writing the group entry alone would - // revoke the access every later step needs. - const existing = await readAcl(node); - await node.writeAttribute( - { endpoint: ROOT_ENDPOINT, cluster: ACCESS_CONTROL_ID, attribute: attributeId(ACCESS_CONTROL, "acl") }, - [ - ...existing, - { privilege: PRIVILEGE_OPERATE, authMode: AUTH_MODE_GROUP, subjects: [GROUP.id], targets: null }, - ], - ); - - const readBack = await readAcl(node); - record( - cx, - { - type: "response", - verdict: readBack.some(isGroupEntry) ? "pass" : "fail", - detail: `ACL holds ${readBack.length} entries, ${ - readBack.some(isGroupEntry) ? "one of them admitting" : "none admitting" - } group ${GROUP.id}`, - }, - "the TH's ACL admits the group the DUT is about to add it to", - ); - }, - { - expected: - "The TH's ACL carries an entry whose AuthMode is Group and whose subjects name the group, alongside " + - "the administer entry the DUT itself uses.", - }, - ) + .step("1a", "TH should have the ACL entry with the AuthMode as Group by DUT", aclAdmitsGroupStep(commissioned), { + expected: + "The TH's ACL carries an entry whose AuthMode is Group and whose subjects name the group, alongside " + + "the administer entry the DUT itself uses.", + }) .step( "1b", "DUT generates a random key and EpochKey0 assigned to GroupKeySetID 1, with " + "GroupKeySecurityPolicy TrustFirst and an EpochStartTime0", - async cx => { - const set = groupKeySet(); - record( - cx, - { - type: "response", - verdict: - set.groupKeySetId === GROUP_KEY_SET_ID && - set.groupKeySecurityPolicy === 0 && - set.epochKey0 !== undefined - ? "pass" - : "fail", - detail: - `key set ${set.groupKeySetId}, policy TrustFirst(${set.groupKeySecurityPolicy}), ` + - `EpochKey0 ${Bytes.toHex(set.epochKey0)}, EpochStartTime0 ${set.epochStartTime0}`, - }, - "the key material the next step writes", - ); - }, + keyMaterialStep(), { expected: "The DUT holds a key set the next step can write. This step produces the artifact in-process, so it " + @@ -269,18 +51,7 @@ certTest("TC-SC-6.1", { .step( 2, "DUT sends KeySetWrite command to GroupKeyManagement cluster to TH on EP0", - commissioned.withRef("dut", async (cx, ref) => { - await invokeAndCheck( - cx, - ref, - GROUP_KEY_MANAGEMENT, - GROUP_KEY_MANAGEMENT_ID, - ROOT_ENDPOINT, - "keySetWrite", - { groupKeySet: groupKeySet() }, - [], - ); - }), + keySetWriteStep(commissioned), { pics: "GRPKEY.C.C00.Tx", expected: "Test Harness receives the KeySetWrite command from the DUT.", @@ -289,46 +60,7 @@ certTest("TC-SC-6.1", { .step( 3, "DUT binds GroupID 1 with GroupKeySetID 1 in the GroupKeyMap attribute list on GroupKeyManagement cluster", - commissioned.withRef("dut", async (cx, ref) => { - const th = cx.devices.th; - const from = th.log.mark(); - const path = { - endpoint: ROOT_ENDPOINT, - cluster: GROUP_KEY_MANAGEMENT_ID, - attribute: attributeId(GROUP_KEY_MANAGEMENT, "groupKeyMap"), - }; - - await cx.controllers.dut - .node(ref) - .writeAttribute(path, [{ groupId: GROUP.id, groupKeySetId: GROUP_KEY_SET_ID }]); - cx.recorder.check({ type: "response", verdict: "pass", detail: "GroupKeyMap write accepted" }); - - record( - cx, - await expectMessageWithPath(th.log, th.flavor, "write", path, from, LOG_TIMEOUT), - "WriteRequestMessage log for GroupKeyManagement.groupKeyMap", - ); - - const readBack = await cx.controllers.dut.node(ref).readAttribute(path); - const bound = - Array.isArray(readBack) && - readBack.some(entry => { - if (typeof entry !== "object" || entry === null) { - return false; - } - const { groupId, groupKeySetId } = entry as { groupId?: unknown; groupKeySetId?: unknown }; - return groupId === GROUP.id && groupKeySetId === GROUP_KEY_SET_ID; - }); - record( - cx, - { - type: "response", - verdict: bound ? "pass" : "fail", - detail: `GroupKeyMap reads back as ${describeValue(readBack)}`, - }, - "the binding the TH kept", - ); - }), + groupKeyMapStep(commissioned), { pics: "GRPKEY.C.A0000", expected: "Test Harness receives the binding of GroupKeySetID 1 with the GroupID 1 from DUT.", @@ -337,21 +69,7 @@ certTest("TC-SC-6.1", { .step( 4, 'DUT sends AddGroup Command to TH with the GroupID 1 and GroupName "GroupOne"', - commissioned.withRef("dut", async (cx, ref) => { - await invokeAndCheck( - cx, - ref, - GROUPS, - GROUPS_ID, - GROUPS_ENDPOINT, - "addGroup", - { groupId: GROUP.id, groupName: GROUP.name }, - [ - { id: 0, value: GROUP.id }, - { id: 1, value: GROUP.name }, - ], - ); - }), + addGroupStep(commissioned), { pics: "G.C.C00.Tx", expected: "Test Harness receives the AddGroup command from the DUT.", diff --git a/support/chip-testing/test/cert/tc-group-support.ts b/support/chip-testing/test/cert/tc-group-support.ts new file mode 100644 index 0000000000..2bce108b09 --- /dev/null +++ b/support/chip-testing/test/cert/tc-group-support.ts @@ -0,0 +1,397 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Bytes } from "@matter/main"; +import { Matter } from "@matter/model"; +import type { CertNodeApi, CertNodeRef, CertStepContext } from "@matter/testing"; +import type { CommandFieldValue } from "./tc-support.js"; +import { + answersWithStatus, + CertCheckFailedError, + CommissionedRefs, + describeValue, + expectCommandInvoke, + expectMessageWithPath, + LOG_TIMEOUT, + record, + requireId, + responseStatusOf, +} from "./tc-support.js"; + +/** + * What TC-SC-5.3 and TC-SC-6.1 both do before they diverge: the group communication plan opens both + * cases by having the controller admit a group in the device's ACL, write a key set, bind the group to + * it and add the group. TC-SC-6.1 then reads that state back over unicast; TC-SC-5.3 sends a + * groupcast through it. + * + * @see {@link MatterSpecification.v16.Core} § 11.2 + */ + +export const GROUP_KEY_MANAGEMENT = Matter.clusters.require("GroupKeyManagement"); +export const GROUPS = Matter.clusters.require("Groups"); +export const ACCESS_CONTROL = Matter.clusters.require("AccessControl"); + +export const GROUP_KEY_MANAGEMENT_ID = requireId(GROUP_KEY_MANAGEMENT.id, "GroupKeyManagement cluster"); +export const GROUPS_ID = requireId(GROUPS.id, "Groups cluster"); +export const ACCESS_CONTROL_ID = requireId(ACCESS_CONTROL.id, "AccessControl cluster"); + +/** GroupKeyManagement and AccessControl are root-node clusters; Groups lives on the on/off light. */ +export const ROOT_ENDPOINT = 0; +export const GROUPS_ENDPOINT = 1; + +export const GROUP = { id: 1, name: "GroupOne" }; +export const GROUP_KEY_SET_ID = 1; + +/** The fabric's own IPK key set, which commissioning writes and no step removes (Matter Core § 11.2.2). */ +export const IPK_KEY_SET_ID = 0; + +/** The Groups feature that decides whether a `ViewGroupResponse` may answer with an empty name. */ +export const GROUP_NAMES_PROPERTY = "groupNames"; +export const GROUP_NAMES_FEATURE = 1 << 0; + +/** `AccessControlEntryPrivilegeEnum.Operate` and `AccessControlEntryAuthModeEnum.Group`. */ +export const PRIVILEGE_OPERATE = 3; + +/** `AccessControlEntryPrivilegeEnum.Manage`, which a group needs to invoke `Groups.AddGroup`. */ +export const PRIVILEGE_MANAGE = 4; +export const AUTH_MODE_GROUP = 3; + +export function attributeId(cluster: typeof GROUP_KEY_MANAGEMENT, attributeName: string): number { + return requireId(cluster.attributes.require(attributeName).id, `${cluster.name}.${attributeName}`); +} + +/** + * The key set the DUT writes, in the shape the plan's step 1b describes. The start time is a Unix + * timestamp rather than the plan's own literal, for the reason AGENTS.md records under "An `epoch-us` + * cannot carry the plan's literal start time"; nothing here depends on its value. + */ +export function groupKeySet() { + return { + groupKeySetId: GROUP_KEY_SET_ID, + groupKeySecurityPolicy: 0, + epochKey0: Bytes.fromHex("d0d1d2d3d4d5d6d7d8d9dadbdcdddedf"), + epochStartTime0: 1_600_000_000_000_000n, + epochKey1: null, + epochStartTime1: null, + epochKey2: null, + epochStartTime2: null, + }; +} + +/** + * Invokes a command on the TH and verifies the TH's own log recorded it with the fields sent. A + * response carrying its own status is checked separately, since a command the cluster refused still + * resolves. + */ +export async function invokeAndCheck( + cx: CertStepContext, + ref: CertNodeRef, + cluster: typeof GROUP_KEY_MANAGEMENT, + clusterId: number, + endpoint: number, + commandName: string, + args: object, + fields: CommandFieldValue[], +): Promise { + const th = cx.devices.th; + const from = th.log.mark(); + + let response: unknown; + try { + response = await cx.controllers.dut.node(ref).invoke(cluster.name, commandName, args, endpoint); + } catch (e) { + cx.recorder.check({ type: "response", verdict: "fail", detail: String(e) }); + throw e; + } + cx.recorder.check({ + type: "response", + verdict: "pass", + detail: response === undefined ? "status=Success" : `status=Success, response=${describeValue(response)}`, + }); + + if (answersWithStatus(cluster, commandName)) { + const payloadStatus = responseStatusOf(response); + record( + cx, + { + type: "response", + verdict: payloadStatus === 0 ? "pass" : "fail", + detail: + payloadStatus === undefined + ? `${commandName} answered ${describeValue(response)}, which carries no status` + : `${commandName} response status=${payloadStatus}`, + }, + `${cluster.name}.${commandName} response status`, + ); + } + + const logCheck = await expectCommandInvoke( + th.log, + th.flavor, + endpoint, + clusterId, + requireId(cluster.commands.require(commandName).id, `${cluster.name}.${commandName}`), + fields, + from, + LOG_TIMEOUT, + ); + record(cx, logCheck, `CommandDataIB log for ${cluster.name}.${commandName}`); + + return response; +} + +/** + * Whether the TH's Groups cluster keeps group names. A feature map is a bitmap, and both adapters + * decode a bitmap through the model into an object of named bits rather than the raw number. + */ +export async function keepsGroupNames(node: CertNodeApi): Promise { + const value = await node.readAttribute({ + endpoint: GROUPS_ENDPOINT, + cluster: GROUPS_ID, + attribute: attributeId(GROUPS, "featureMap"), + }); + if (typeof value === "number") { + return (value & GROUP_NAMES_FEATURE) !== 0; + } + if (typeof value === "object" && value !== null && GROUP_NAMES_PROPERTY in value) { + return Boolean(value[GROUP_NAMES_PROPERTY]); + } + throw new CertCheckFailedError( + `TH answered Groups FeatureMap with ${describeValue(value)}, which names no features`, + ); +} + +/** The ACL the TH already holds for this fabric, which a new entry is appended to rather than replacing. */ +export async function readAcl(node: CertNodeApi): Promise { + const value = await node.readAttribute({ + endpoint: ROOT_ENDPOINT, + cluster: ACCESS_CONTROL_ID, + attribute: attributeId(ACCESS_CONTROL, "acl"), + }); + if (!Array.isArray(value)) { + throw new CertCheckFailedError(`TH answered its ACL with ${describeValue(value)}, not a list`); + } + return value; +} + +/** + * Whether an ACL entry admits this group — what step 1a asks the DUT to put in place. A subject is a + * uint64, so it reaches here as a `bigint` on one controller and a `number` on another; the comparison + * is on the value, not the type. + */ +export function isGroupEntry(entry: unknown): boolean { + if (typeof entry !== "object" || entry === null) { + return false; + } + const { authMode, subjects } = entry as { authMode?: unknown; subjects?: unknown }; + if (Number(authMode) !== AUTH_MODE_GROUP || !Array.isArray(subjects)) { + return false; + } + return subjects.some(subject => { + const value = typeof subject === "bigint" || typeof subject === "number" ? Number(subject) : undefined; + return value === GROUP.id; + }); +} + +/** + * The plan's step "1a": the controller commissions the device and admits the group in its ACL. + * + * `privilege` is what the group is allowed to do, and a case picks it from what its own later steps + * send: a group message carrying `Groups.AddGroup` needs Manage, while one that only operates needs + * Operate. Too little is not refused — a group message is unacknowledged, so the device simply does + * nothing and the case fails on the effect it looked for rather than on a status. + */ +export function aclAdmitsGroupStep(commissioned: CommissionedRefs, privilege = PRIVILEGE_OPERATE) { + return async (cx: CertStepContext) => { + const dut = cx.controllers.dut; + const th = cx.devices.th; + + const ref = await dut.commission({ + passcode: th.commissioning.passcode, + discriminator: th.commissioning.discriminator, + }); + commissioned.set("dut", ref); + + const node = dut.node(ref); + + // The DUT's own administer entry is in this list; writing the group entry alone would + // revoke the access every later step needs. + const existing = await readAcl(node); + await node.writeAttribute( + { endpoint: ROOT_ENDPOINT, cluster: ACCESS_CONTROL_ID, attribute: attributeId(ACCESS_CONTROL, "acl") }, + [...existing, { privilege, authMode: AUTH_MODE_GROUP, subjects: [GROUP.id], targets: null }], + ); + + const readBack = await readAcl(node); + record( + cx, + { + type: "response", + verdict: readBack.some(isGroupEntry) ? "pass" : "fail", + detail: `ACL holds ${readBack.length} entries, ${ + readBack.some(isGroupEntry) ? "one of them admitting" : "none admitting" + } group ${GROUP.id}`, + }, + "the TH's ACL admits the group the DUT is about to add it to", + ); + }; +} + +/** The plan's step "1b": the key material the next step writes, produced in-process. */ +export function keyMaterialStep() { + return async (cx: CertStepContext) => { + const set = groupKeySet(); + record( + cx, + { + type: "response", + verdict: + set.groupKeySetId === GROUP_KEY_SET_ID && + set.groupKeySecurityPolicy === 0 && + set.epochKey0 !== undefined + ? "pass" + : "fail", + detail: + `key set ${set.groupKeySetId}, policy TrustFirst(${set.groupKeySecurityPolicy}), ` + + `EpochKey0 ${Bytes.toHex(set.epochKey0)}, EpochStartTime0 ${set.epochStartTime0}`, + }, + "the key material the next step writes", + ); + }; +} + +/** + * The plan's KeySetWrite step. The controller provisions itself with the same key set it writes to the + * device: the plan has it *generate* the key, and a controller that only told the device about it + * could not encrypt a groupcast with it. + */ +export function keySetWriteStep(commissioned: CommissionedRefs) { + return commissioned.withRef("dut", async (cx: CertStepContext, ref: CertNodeRef) => { + await cx.controllers.dut.group(GROUP.id).defineKeySet(groupKeySet()); + + await invokeAndCheck( + cx, + ref, + GROUP_KEY_MANAGEMENT, + GROUP_KEY_MANAGEMENT_ID, + ROOT_ENDPOINT, + "keySetWrite", + { groupKeySet: groupKeySet() }, + [], + ); + }); +} + +/** + * The plan's GroupKeyMap step, which binds the group to the key set that was just written. + * + * `groups` is every group the case needs bound, because `Groups.AddGroup` answers `UNSUPPORTED_ACCESS` + * for a group the fabric's GroupKeyMap does not name (Matter Application Clusters § 1.3.7.1) — so a + * case that later adds a *second* group has to bind that one here as well. + */ +export function groupKeyMapStep(commissioned: CommissionedRefs, groups: number[] = [GROUP.id]) { + return commissioned.withRef("dut", async (cx: CertStepContext, ref: CertNodeRef) => { + const th = cx.devices.th; + const from = th.log.mark(); + const path = { + endpoint: ROOT_ENDPOINT, + cluster: GROUP_KEY_MANAGEMENT_ID, + attribute: attributeId(GROUP_KEY_MANAGEMENT, "groupKeyMap"), + }; + + await cx.controllers.dut.node(ref).writeAttribute( + path, + groups.map(groupId => ({ groupId, groupKeySetId: GROUP_KEY_SET_ID })), + ); + cx.recorder.check({ type: "response", verdict: "pass", detail: "GroupKeyMap write accepted" }); + + record( + cx, + await expectMessageWithPath(th.log, th.flavor, "write", path, from, LOG_TIMEOUT), + "WriteRequestMessage log for GroupKeyManagement.groupKeyMap", + ); + + const readBack = await cx.controllers.dut.node(ref).readAttribute(path); + const bound = + Array.isArray(readBack) && + groups.every(wanted => + readBack.some(entry => { + if (typeof entry !== "object" || entry === null) { + return false; + } + const { groupId, groupKeySetId } = entry as { groupId?: unknown; groupKeySetId?: unknown }; + return groupId === wanted && groupKeySetId === GROUP_KEY_SET_ID; + }), + ); + record( + cx, + { + type: "response", + verdict: bound ? "pass" : "fail", + detail: `GroupKeyMap reads back as ${describeValue(readBack)}`, + }, + "the binding the TH kept", + ); + }); +} + +/** The plan's AddGroup step, which makes the device a member of the group. */ +export function addGroupStep(commissioned: CommissionedRefs) { + return commissioned.withRef("dut", async (cx: CertStepContext, ref: CertNodeRef) => { + await invokeAndCheck( + cx, + ref, + GROUPS, + GROUPS_ID, + GROUPS_ENDPOINT, + "addGroup", + { groupId: GROUP.id, groupName: GROUP.name }, + [ + { id: 0, value: GROUP.id }, + { id: 1, value: GROUP.name }, + ], + ); + }); +} + +/** + * The multicast address a fabric uses for a group: `FF35:0040:FD00:`, sixteen + * bytes with the fabric's own id in the middle. + * + * @see {@link MatterSpecification.v16.Core} § 4.15.3 + */ +export function groupMulticastAddress(fabricId: bigint, groupId: number): Uint8Array { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setUint16(0, 0xff35); + view.setUint16(2, 0x0040); + view.setUint8(4, 0xfd); + view.setBigUint64(5, fabricId); + view.setUint8(13, 0x00); + view.setUint16(14, groupId); + return bytes; +} + +/** An IPv6 address as its sixteen bytes, or undefined for text that is not one. */ +export function ipv6Bytes(address: string): Uint8Array | undefined { + const [head, tail] = address.split("::", 2); + const parse = (part: string) => (part === "" ? [] : part.split(":").map(group => Number.parseInt(group, 16))); + const left = parse(head); + const right = tail === undefined ? [] : parse(tail); + if ([...left, ...right].some(group => !Number.isInteger(group) || group < 0 || group > 0xffff)) { + return undefined; + } + const groups = + tail === undefined ? left : [...left, ...new Array(8 - left.length - right.length).fill(0), ...right]; + if (groups.length !== 8) { + return undefined; + } + + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + groups.forEach((group, index) => view.setUint16(index * 2, group)); + return bytes; +} From 420b336ba0f501240ca6005f3da78260fb3fa4cd Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Sun, 30 Aug 2026 18:44:09 +0200 Subject: [PATCH 2/3] test(chip-testing): the group message is proved by what the receiver did, not only by what it holds Review findings on this pull request, and one of them was live rather than theoretical. The only evidence the group message arrived was that the device held group 2 afterwards, with nothing establishing it did not hold it before. A chip device keeps its state on disk between runs, so the chip legs had been passing on a group a previous run left behind: with the absence check in place they failed, and the reason they now pass is that the message really is dispatched. The step reads the group before the send and requires NOT_FOUND, and it checks the name afterwards as well as the group id, which ties what the device holds to the fields this message carried. The device's own dispatch is now evidence too, which also gives the step something to wait for. An unacknowledged multicast orders nothing against the unicast read that follows it, so that read was racing the device. A group command's path is endpoint-wildcarded on the wire, so the dispatch is identified by the endpoint it reached: matter.js names endpoint, cluster, command and fields on one line, and chip says more than that - it names the group id read off the packet, which is the receiver's own view of the destination this case is about. Also from the review: The chip-tool provisioning ignored all three of its replies, so a failure to install or bind the key would have surfaced much later as a message that seemed never to have arrived. Each reply is checked now. The address parser threw a RangeError on an over-long compressed address and accepted text that is not an address at all, though its own documentation promised otherwise. It validates each group and the width of the compression. Sender-side key provisioning is opt-in. It makes the controller join the group's multicast address, and the case that never sends a group message does not need that failure surface. The group destination is built through NodeId.fromGroupId, so both controllers refuse the same group ids, and the group send no longer carries a large-payload flag that cannot apply to a multicast. Four claims that outran their evidence are now written as what they are: the session tag says which kind of session the sender used rather than reading DSIZ off the wire; the controller interface no longer promises a refusal nobody implements; the changelog example shows the address form actually rendered; and the destination getter says what it means for a session created from a received packet. The protocol test pins the rendered address instead of recomputing it. Verification (repository root): npm run build, npm run format-verify, npm run lint and npm test all pass; the framework suite passes, the certification suite passes on the matter.js leg, and TC-SC-5.3 passes on all four matrix legs. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- packages/protocol/src/session/GroupSession.ts | 8 +- .../test/session/SecureSessionTest.ts | 2 +- .../src/chip/cert/controller-adapter.ts | 3 - .../src/cert/ChipToolControllerAdapter.ts | 33 +++-- support/chip-testing/test/cert/AGENTS.md | 101 ++++---------- .../chip-testing/test/cert/TC-SC-5.3.test.ts | 130 +++++++++++++----- .../test/cert/tc-group-support.ts | 59 ++++++-- 8 files changed, 201 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f84d0f23bb..3131105f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ The main work (all changes without a GitHub username in brackets in the below li - Fix: A mandatory command a cluster leaves unimplemented is no longer dispatched; an invoke answers `UNSUPPORTED_COMMAND`, matching what the cluster advertises in `AcceptedCommandList` - @matter/protocol - - Enhancement: A group message's log line names the port beside the multicast address it went to, in the usual IPv6 form (`dest: [ff35:0040:…]:5540`) + - Enhancement: A group message's log line names the port beside the multicast address it went to, in the usual IPv6 form (`dest: [ff35:40:…]:5540`) - Fix: A command whose payload does not match the command's schema is answered with `INVALID_COMMAND` instead of `FAILURE` - Fix: `UpdateFabricLabel` accepts an empty label, as the specification's `max 32` constraint sets no minimum; it previously failed the command - Enhancement: An interaction can be abandoned by the caller: `ClientRequest.abort` takes an `AbortSignal`, honored for read, write, invoke and subscribe diff --git a/packages/protocol/src/session/GroupSession.ts b/packages/protocol/src/session/GroupSession.ts index 98725735d5..9bb0b4bab3 100644 --- a/packages/protocol/src/session/GroupSession.ts +++ b/packages/protocol/src/session/GroupSession.ts @@ -99,9 +99,11 @@ export class GroupSession extends SecureSession { } /** - * Where this session sends, in the form an IPv6 destination is written: the multicast address in - * brackets and the port beside it. Group traffic always goes to the standard port, which is what - * makes the pair worth printing together — an address alone does not say where a message went. + * Where a message on this session's group is addressed, in the form an IPv6 destination is + * written: the multicast address in brackets and the port beside it. Group traffic goes to the + * standard port, which is what makes the pair worth printing together — an address alone does not + * say where a message went. A session created from a received packet reports the same pair, though + * it sends nothing itself. */ get destination(): string { return `[${this.#multicastAddress}]:${STANDARD_MATTER_PORT}`; diff --git a/packages/protocol/test/session/SecureSessionTest.ts b/packages/protocol/test/session/SecureSessionTest.ts index a251617fc8..9e6826c71c 100644 --- a/packages/protocol/test/session/SecureSessionTest.ts +++ b/packages/protocol/test/session/SecureSessionTest.ts @@ -219,7 +219,7 @@ describe("SecureSession", () => { messageCounter: new MessageCounter(fabric.crypto), }); - expect(session.destination).equal(`[${session.multicastAddress}]:5540`); + expect(session.destination).equal("[ff35:40:fd45:6789:abcd:ef12:3400:2]:5540"); }); it("matches a cached session by fabric, session id and operational key, not by id alone", async () => { diff --git a/packages/testing/src/chip/cert/controller-adapter.ts b/packages/testing/src/chip/cert/controller-adapter.ts index 693ac221bc..2784e18ba7 100644 --- a/packages/testing/src/chip/cert/controller-adapter.ts +++ b/packages/testing/src/chip/cert/controller-adapter.ts @@ -410,9 +410,6 @@ export interface ControllerAdapter { * Addresses a group rather than a node, for a case whose subject is the groupcast itself * (TC-SC-5.3 step 5). The fabric's group key set and the group's membership are established by * ordinary unicast commands first; this only decides how the command that follows is addressed. - * - * A controller that cannot send a groupcast throws {@link UnsupportedByControllerError} here - * rather than from the invoke, so a step is refused before it acts. */ group(groupId: number): CertGroupApi; diff --git a/support/chip-testing/src/cert/ChipToolControllerAdapter.ts b/support/chip-testing/src/cert/ChipToolControllerAdapter.ts index a38bdb76a5..c03b3e9a6a 100644 --- a/support/chip-testing/src/cert/ChipToolControllerAdapter.ts +++ b/support/chip-testing/src/cert/ChipToolControllerAdapter.ts @@ -190,10 +190,11 @@ function largePayloadArg(transport?: ControllerTransport) { /** * How a group is addressed as a destination: a node id whose upper 48 bits are all ones carries the * group in its lower 16 (Matter Core § 2.5.4), and chip-tool takes that in place of a node id on any - * command it sends. + * command it sends. Built through {@link NodeId.fromGroupId} so a group id neither controller may use + * is refused the same way on both. */ function groupDestination(groupId: number): string { - return `0x${(0xffffffffffff0000n | BigInt(groupId)).toString(16)}`; + return `0x${NodeId.fromGroupId(groupId).toString(16)}`; } /** chip-tool's own name for the timed-interaction timeout, on `command-by-id` and `write-by-id` alike. */ @@ -785,13 +786,25 @@ class ChipToolCertGroupApi implements CertGroupApi { const group = `${this.#groupId}`; const keySetId = `${keySet.groupKeySetId}`; - await this.#adapter.execute(`groupsettings add-group group${group} ${group}`); - await this.#adapter.execute( - // Validity 0: the key is current from now, which is what the plan's own epoch start means - `groupsettings add-keysets ${keySetId} ${keySet.groupKeySecurityPolicy} 0 ` + - `hex:${Bytes.toHex(keySet.epochKey0)}`, + // Each reply is checked: `execute` decodes chip-tool's answer rather than throwing on it, and a + // provisioning step that silently did nothing surfaces much later as a groupcast that seems + // never to have arrived + assertCommandSucceeded( + await this.#adapter.execute(`groupsettings add-group group${group} ${group}`), + "groupsettings add-group", + ); + assertCommandSucceeded( + await this.#adapter.execute( + // Validity 0: the key is current from now, which is what the plan's own epoch start means + `groupsettings add-keysets ${keySetId} ${keySet.groupKeySecurityPolicy} 0 ` + + `hex:${Bytes.toHex(keySet.epochKey0)}`, + ), + "groupsettings add-keysets", + ); + assertCommandSucceeded( + await this.#adapter.execute(`groupsettings bind-keyset ${group} ${keySetId}`), + "groupsettings bind-keyset", ); - await this.#adapter.execute(`groupsettings bind-keyset ${group} ${keySetId}`); } async invoke(cluster: string | number, command: string, args?: object): Promise { @@ -805,7 +818,9 @@ class ChipToolCertGroupApi implements CertGroupApi { `any command-by-id ${hex(clusterId)} ${hex(commandModel.id)} ${quoteArg(fields)} ` + // chip-tool wants an endpoint argument even for a group command, which carries none; // 0 is what its own group tests pass - `${groupDestination(this.#groupId)} 0${largePayloadArg(this.#adapter.transport)}`, + // No large-payload flag: a groupcast is UDP multicast, so the TCP preference a + // transport option expresses cannot apply to it + `${groupDestination(this.#groupId)} 0`, ); assertNoFailure(reply, `group invoke ${clusterModel.name}.${commandModel.name}`); diff --git a/support/chip-testing/test/cert/AGENTS.md b/support/chip-testing/test/cert/AGENTS.md index d7665af9d8..37dc2956b8 100644 --- a/support/chip-testing/test/cert/AGENTS.md +++ b/support/chip-testing/test/cert/AGENTS.md @@ -2184,75 +2184,18 @@ group the fabric's key map must already carry. What it adds: `AttributeValueList` the plan actually names. Sending an empty list would have been the quieter mistake: the step would pass while exercising none of the shape it is about. -## The group-messaging block splits in two (`TC-SC-6.1` runnable, `TC-SC-5.3` not) - -`TC-SC-6.1` ("adding member to a group") is entirely **unicast** — KeySetWrite, a GroupKeyMap write, -AddGroup, ViewGroup, KeySetRead, KeySetRemove, KeySetReadAllIndices — so it needed no new capability -at all, only three more client PICS declarations (`G.C.C01.Tx`, `GRPKEY.C.C03.Tx`, `GRPKEY.C.C04.Tx`; -the last two are absent from the device file entirely, and an absent key evaluates false). `TC-SC-5.3` -is the one that needs the DUT to *send* a groupcast, and it is blocked on evidence rather than on -sending — see the note below. - -What the steps rest on, beyond the log: - -- **Step 1a writes an ACL entry, and the read-modify-write is load-bearing.** The entry the plan wants - is `AuthMode: Group` with the group id as its subject, but the ACL also carries the DUT's own - administer entry — writing the group entry alone revokes the access every later step needs. Read, - append, write. -- **An ACL subject is a `uint64`, so it comes back as a `bigint`** from one controller and a `number` - from another. Compare the value, not the type: a `subjects.includes(1)` against `[1n]` is false, and - reads as "the write did not take" rather than as a decoding difference. -- **`JSON.stringify` throws on a `bigint`.** A key set's epoch start times are `epoch-us`, so a step - reporting what the TH answered fails on its own evidence — the run error is - `Do not know how to serialize a BigInt`, and it names no field. Evidence text for a response that - may carry one goes through a replacer. -- **KeySetRemove then KeySetReadAllIndices is what makes the removal checkable**: the indices must no - longer list the removed set (only the IPK's 0 remains). Without that, both steps would rest on the - TH having logged a command it could equally have ignored. -- **Steps 6 and 7 are declared `notApplicable`, not omitted.** The plan skips them where the TH's root - endpoint has no Groupcast cluster, and neither TH has one — matter.js's all-clusters device - registers none, and chip's all-clusters ZAP enables none. Declaring them keeps the plan's own - numbering in the evidence and states why the run jumps from 5 to 8. - -## `TC-SC-5.3` is blocked on what the receiver can show, not on sending (assessed 2026-08-29) - -Sending a group command is **available on both controllers** and simply unexercised: - -- matter.js: `NodeId.fromGroupId(GroupId(id))` addresses a group, `Peers.forAddress` then yields a - `ClientGroup` rather than a `ClientNode`, and `ClientGroupInteraction` supports `invoke` and `write` - (it forces `suppressResponse` and refuses reads, subscriptions and timed requests). Nothing in the - repository drives this over a real socket today — `BindingIntegrationTest` says so in its own comment, - because the mock network has no multicast loopback. -- chip-tool: a destination id of `0xFFFF'FFFF'FFFF'0000 | groupId` *is* the group form - (`ModelCommand::RunCommand` → `SendGroupCommand`), and `any command-by-id` inherits it. The mask - matches matter.js's `GroupId.isGroupNodeId` exactly. - -What is missing is a **witness** for the receiver's side of the plan's step 5, which asks to validate -four things about the message. Only the first reaches a log line: - -- **DSIZ is group** — visible: a group session renders as `group#` in `exchange.via`, so every - inbound invoke line says it. -- **The destination group id** — not logged, though matter.js does use it: `GroupSession.subjectFor` - resolves it into the subject access control checks against, and `SessionManager.onGroupMessage` - emits it for every group invoke. -- **The IPv6 destination `FF35:0040:FD00:`** — not captured from the received - packet at all. The UDP layer surfaces only the *sender's* address and port (`UdpTransport`'s - `onData` takes `rinfo`), with no `IP_PKTINFO`-style destination capture; the `destIp` on that event - is filled by the sending path. -- **UDP port 5540** — implicit in the bound socket, never stated in a log line. - -So the open question is which witness to build, and it is a decision rather than a work item: - -- **Log the destination.** The receiving path holds the group id already; the multicast address it - would first have to capture. -- **Observe `SessionManager.onGroupMessage` in-process.** A matterjs-flavor device runs inside the - test process, so a check could listen to it directly — stronger about the group id than any log - line, but a different kind of evidence from a device log, and unavailable on the chip flavors, so - the TC would prove different things on different legs. - -Until one of those exists, a TC written here could claim "the TH received this on a group session" -and nothing about the addressing the step is actually about. +## The group-messaging block, and what its two cases share (`TC-SC-6.1`, `TC-SC-5.3`) +Both cases open the same way — an access-control entry admitting the group, a key set, the GroupKeyMap +binding, AddGroup — and that opening lives in `tc-group-support.ts` rather than in either file. +`TC-SC-6.1` then reads the state back over unicast; `TC-SC-5.3` sends a group message through it. See +"what sending one actually needed" below for the four things that are not in the plan. + +`TC-SC-6.1` needed no new capability at all, only three more client PICS declarations (`G.C.C01.Tx`, +`GRPKEY.C.C03.Tx`, `GRPKEY.C.C04.Tx`; the last two are absent from the device file entirely, and an +absent key evaluates false). + +What its steps rest on, beyond the log: ## The TCP cases invert the topology, and a controller must be asked for TCP before it starts `TC-SC-8.x` is the first block where the **DUT is the device** — the plan's preconditions say "DUT is a @@ -2413,8 +2356,9 @@ Four things the plan does not say, each of which failed silently until found: - **The sender needs the key too.** Writing the key set to the device is half of it; the controller encrypts the groupcast, so it must hold the key as well — which is what the plan's "DUT generates a - random key" means in practice. `defineKeySet` does that, and the case calls it in the same step that - writes the key set to the device. + random key" means in practice. `defineKeySet` does that, and `keySetWriteStep` takes a flag for it: + provisioning also makes the controller join the group's multicast address, so a case that never sends + a groupcast (TC-SC-6.1) does not take on that failure surface. - **On the fabric the sender resolves.** The adapter's own `Fabric` handle is a *different object* for the same fabric index than the one `SessionManager.fabricFor` returns, and group state written on the adapter's copy is invisible to the sending path. The symptom is `No group key set found for groupId` @@ -2431,9 +2375,20 @@ Four things the plan does not say, each of which failed silently until found: multicast address is not shape-matched: the DUT's membership line names the group, the fabric and the address together, so the address is recomputed from that fabric id and group id and compared byte for byte — which also establishes the destination is GroupID 1. The port and address are read from the -invoke's `dest:` field, and the session tag renders `•group#…`, which is DSIZ naming a group. Beside -that is the effect: a unicast `ViewGroup(2)` afterwards, which can only succeed if the multicast -arrived, decrypted under the group key and dispatched. +invoke's `dest:` field, and and the session tag renders `•group#…`. That last one is the sender saying which +*kind* of session it used, not a read of the packet's own DSIZ field — which is what makes it evidence +for the claim rather than the claim itself, and the step's expected outcome says so. + +The arrival is proved three ways, because the first is what makes the last mean anything: the TH does +not hold group 2 beforehand, the TH's own log shows it dispatching the AddGroup with the group and name +the message carried, and only then does a unicast `ViewGroup(2)` answer with them. The dispatch line is +also the step's synchronisation — an unacknowledged multicast orders nothing against the unicast read +that follows it, so without that wait the read races the device. + +A group command's path is endpoint-wildcarded on the wire (`invokes: *.0x4.0x0`), so the dispatch is +identified by the endpoint it *reached*: matter.js names endpoint, cluster, command and fields on its +`ProtocolService Invoke «` line, and chip prints `Received command for Endpoint=1 Cluster=0x0000_0004 +Command=0x0000_0000`. **A production change came with it.** The group invoke's diagnostic printed the address alone; `GroupSession.destination` now renders `[
]:` so the log says where a message actually diff --git a/support/chip-testing/test/cert/TC-SC-5.3.test.ts b/support/chip-testing/test/cert/TC-SC-5.3.test.ts index 984f30af31..205894e85d 100644 --- a/support/chip-testing/test/cert/TC-SC-5.3.test.ts +++ b/support/chip-testing/test/cert/TC-SC-5.3.test.ts @@ -5,6 +5,7 @@ */ import { Bytes } from "@matter/main"; +import { Status } from "@matter/main/types"; import type { CertStepContext, CheckRecord } from "@matter/testing"; import { certTest, resolveControllerImplementation } from "@matter/testing"; import { @@ -36,67 +37,132 @@ const commissioned = new CommissionedRefs(); const SECOND_GROUP = { id: 2, name: "GroupTwo" }; /** - * The plan's step 5: an AddGroup sent as a group command over GroupID 1. - * - * A groupcast is unacknowledged and answered by nobody, so nothing comes back to check. What proves it - * arrived is the TH's own state afterwards: group 2 can only be in the TH's Groups cluster if the - * multicast reached it, decrypted under the group key and dispatched. The read that establishes that is - * unicast, because a group session carries no reads. - */ -/** - * The sender's own line for the group it joined, which names the fabric and the address together — - * so the address the invoke goes to can be tied to *this* group rather than shape-matched. + * The TH dispatching the AddGroup this groupcast carried, named the way each flavor names it. A group + * command's own path is endpoint-wildcarded on the wire, so what identifies the dispatch is the + * endpoint it *reached*: matter.js names the endpoint, cluster, command and fields on one line, and + * chip prints the resolved path of the command it is about to run. */ -const MEMBERSHIP_LINE = /Adding membership for group (\d+) on fabric (\d+) .*with address (\S+)/; +const DISPATCH_LINES = { + matterjs: [ + new RegExp( + `ProtocolService Invoke « \\S+\\.ep${GROUPS_ENDPOINT}\\.groups\\.addGroup •group#[0-9a-f]+⇵[0-9a-f]+✉[0-9a-f]+ ` + + `groupId: ${SECOND_GROUP.id}(?!\\d) groupName: ${SECOND_GROUP.name}(?=$|\\s\\w+:)`, + ), + ], + // chip says more than matter.js here: it names the group the message carried, read off the packet + // rather than off the session it used + chip: { + ordered: [ + new RegExp(`Received Groupcast Message with GroupId 0x${GROUP.id.toString(16).padStart(4, "0")} `), + new RegExp( + `Processing group command for Endpoint=${GROUPS_ENDPOINT} Cluster=0x0000_0004 ` + + `Command=0x0000_0000(?![0-9a-f])`, + ), + ], + }, +}; /** - * matter.js's line for a group invoke: the session tag says the session is a group one, and `dest:` - * names where the message went, address and port together in the usual IPv6 form. + * The plan's step 5: an AddGroup sent as a group command over GroupID 1. + * + * A groupcast is unacknowledged and answered by nobody, so nothing comes back to check. Three things + * stand in for a response, and the first is what makes the last one mean anything: + * + * - the TH does *not* hold group 2 beforehand, so its presence afterwards cannot predate the message; + * - the TH's own log shows it dispatched this AddGroup, with the group and name the message carried — + * which is also what tells the step the message has been processed, since an unacknowledged + * multicast orders nothing against the unicast read that follows; + * - and the TH then answers `ViewGroup(2)` with the group and the name. */ -function groupInvokeLine(address: string) { - return new RegExp( - `ClientInteraction Invoke » •group#[0-9a-f]+⇵[0-9a-f]+ dest: ${literally(`[${address}]:${MATTER_PORT}`)} `, - ); -} - -/** The port group traffic goes to, which the plan's step 5 asks to see (Matter Core § 4.15.3). */ -const MATTER_PORT = 5540; - async function addGroupOverGroupcast(cx: CertStepContext) { const dut = cx.controllers.dut; + const th = cx.devices.th; const node = dut.node(commissioned.require("dut")); + + // The window opens before the step acts at all: a device's own log reaches the follower on its own + // schedule, and the lines this step looks for name the group and command it sends, so nothing else + // in the step's span can satisfy them + const thFrom = th.log.mark(); + + const before = await node.invoke(GROUPS.name, "viewGroup", { groupId: SECOND_GROUP.id }, GROUPS_ENDPOINT); + const absent = Number(statusOf(before)) === Status.NotFound; + const from = await dut.log.markSettled(); - await cx.controllers.dut + await dut .group(GROUP.id) .invoke(GROUPS.name, "addGroup", { groupId: SECOND_GROUP.id, groupName: SECOND_GROUP.name }); const sent = await groupcastSentCheck(cx, from); + const dispatched = await expectSequence( + th.log, + th.flavor, + `the TH dispatching AddGroup(${SECOND_GROUP.id}, "${SECOND_GROUP.name}")`, + DISPATCH_LINES, + thFrom, + LOG_TIMEOUT, + ); const response = await node.invoke(GROUPS.name, "viewGroup", { groupId: SECOND_GROUP.id }, GROUPS_ENDPOINT); - const { status, groupId } = - typeof response === "object" && response !== null ? (response as { status?: unknown; groupId?: unknown }) : {}; + const { status, groupId, groupName } = + typeof response === "object" && response !== null + ? (response as { status?: unknown; groupId?: unknown; groupName?: unknown }) + : {}; + const arrived = + Number(status) === Status.Success && Number(groupId) === SECOND_GROUP.id && groupName === SECOND_GROUP.name; - const arrived = Number(status) === 0 && Number(groupId) === SECOND_GROUP.id; recordAll(cx, [ + { + check: () => ({ + type: "response", + verdict: absent ? "pass" : "fail", + detail: `before the groupcast the TH answers ViewGroup(${SECOND_GROUP.id}) with ${describeValue(before)}`, + }), + what: "the group the groupcast will add is not on the TH already", + }, { check: () => sent, what: "the DUT sent the command to the group's own multicast address" }, + { check: () => dispatched, what: "the TH dispatched the AddGroup the groupcast carried" }, { check: () => ({ type: "response", verdict: arrived ? "pass" : "fail", detail: `after the groupcast the TH answers ViewGroup(${SECOND_GROUP.id}) with ${describeValue(response)}`, }), - what: "the group the groupcast asked the TH to add is on the TH", + what: "the group the groupcast asked the TH to add is on the TH, under the name it carried", }, ]); - if (!arrived) { + if (!absent || !arrived) { throw new CertCheckFailedError( - `the TH does not hold group ${SECOND_GROUP.id} after the groupcast: ${describeValue(response)}`, + `group ${SECOND_GROUP.id} was ${absent ? "not added by" : "already on the TH before"} the groupcast`, ); } } +/** The status a `ViewGroupResponse` carries, or undefined for an answer that is not one. */ +function statusOf(response: unknown): unknown { + return typeof response === "object" && response !== null ? (response as { status?: unknown }).status : undefined; +} + +/** + * The sender's own line for the group it joined, which names the fabric and the address together — so + * the address the invoke goes to can be tied to *this* group rather than shape-matched. + */ +const MEMBERSHIP_LINE = new RegExp(`Adding membership for group (${GROUP.id}) on fabric (\\d+) .*with address (\\S+)`); + +/** The port group traffic goes to, which the plan's step 5 asks to see (Matter Core § 4.15.3). */ +const MATTER_PORT = 5540; + +/** + * matter.js's line for a group invoke: the session tag says the session is a group one, and `dest:` + * names where the message went, address and port together in the usual IPv6 form. + */ +function groupInvokeLine(address: string) { + return new RegExp( + `ClientInteraction Invoke » •group#[0-9a-f]+⇵[0-9a-f]+ dest: ${literally(`[${address}]:${MATTER_PORT}`)} `, + ); +} + /** * Confirms the message went where a group message must go: to the multicast address this fabric uses * for this group, on a session the sender itself renders as a group one. @@ -203,7 +269,7 @@ certTest("TC-SC-5.3", { .step( 2, "DUT sends KeySetWrite command to GroupKeyManagement cluster to TH on EP0", - keySetWriteStep(commissioned), + keySetWriteStep(commissioned, true), { pics: "GRPKEY.C.C00.Tx", expected: "Test Harness receives the KeySetWrite command from the DUT.", @@ -237,8 +303,8 @@ certTest("TC-SC-5.3", { { pics: "G.C.C00.Tx", expected: - "The group message goes to the multicast address for this fabric and group on port 5540, with DSIZ " + - "naming a group and the destination naming GroupID 1, and the TH holds the group it carried.", + "The group message goes to the multicast address for this fabric and group on port 5540, sent on a " + + "group session — which is what DSIZ names on the wire — and the TH holds the group it carried.", }, ) .step(6, "DUT sends the Groupcast JoinGroup command on the TH on EP0", async () => {}, { diff --git a/support/chip-testing/test/cert/tc-group-support.ts b/support/chip-testing/test/cert/tc-group-support.ts index 2bce108b09..a3ae9af5f4 100644 --- a/support/chip-testing/test/cert/tc-group-support.ts +++ b/support/chip-testing/test/cert/tc-group-support.ts @@ -264,13 +264,18 @@ export function keyMaterialStep() { } /** - * The plan's KeySetWrite step. The controller provisions itself with the same key set it writes to the - * device: the plan has it *generate* the key, and a controller that only told the device about it - * could not encrypt a groupcast with it. + * The plan's KeySetWrite step. + * + * `alsoProvisionSender` has the controller keep the key set it writes, which the plan means by having + * it *generate* the key: a controller that only told the device about the key cannot encrypt a + * groupcast with it. Only a case that sends one asks for this — provisioning also makes the controller + * join the group's multicast address, which is failure surface a case that never sends does not need. */ -export function keySetWriteStep(commissioned: CommissionedRefs) { +export function keySetWriteStep(commissioned: CommissionedRefs, alsoProvisionSender = false) { return commissioned.withRef("dut", async (cx: CertStepContext, ref: CertNodeRef) => { - await cx.controllers.dut.group(GROUP.id).defineKeySet(groupKeySet()); + if (alsoProvisionSender) { + await cx.controllers.dut.group(GROUP.id).defineKeySet(groupKeySet()); + } await invokeAndCheck( cx, @@ -375,23 +380,47 @@ export function groupMulticastAddress(fabricId: bigint, groupId: number): Uint8A return bytes; } -/** An IPv6 address as its sixteen bytes, or undefined for text that is not one. */ +/** + * An IPv6 address as its sixteen bytes, or undefined for text this cannot read as one. + * + * Written here rather than taken from `@matter/general`'s `ipv6ToBytes`, which answers *something* for + * text that is not an address at all — this one gates a certification claim, so it validates each + * group and the width of a `::` before it believes the text. + */ export function ipv6Bytes(address: string): Uint8Array | undefined { - const [head, tail] = address.split("::", 2); - const parse = (part: string) => (part === "" ? [] : part.split(":").map(group => Number.parseInt(group, 16))); - const left = parse(head); - const right = tail === undefined ? [] : parse(tail); - if ([...left, ...right].some(group => !Number.isInteger(group) || group < 0 || group > 0xffff)) { + const halves = address.split("::"); + if (halves.length > 2) { return undefined; } - const groups = - tail === undefined ? left : [...left, ...new Array(8 - left.length - right.length).fill(0), ...right]; - if (groups.length !== 8) { + + const parse = (part: string) => { + if (part === "") { + return []; + } + const groups = part.split(":"); + if (groups.some(group => !/^[0-9a-f]{1,4}$/i.test(group))) { + return undefined; + } + return groups.map(group => Number.parseInt(group, 16)); + }; + + const left = parse(halves[0]); + const right = halves.length === 2 ? parse(halves[1]) : []; + if (left === undefined || right === undefined) { + return undefined; + } + + // A `::` stands for at least one group of zeros, so an address that already has eight without it + // is not one this compression can appear in + const zeros = halves.length === 2 ? 8 - left.length - right.length : 0; + if (zeros < (halves.length === 2 ? 1 : 0) || left.length + zeros + right.length !== 8) { return undefined; } const bytes = new Uint8Array(16); const view = new DataView(bytes.buffer); - groups.forEach((group, index) => view.setUint16(index * 2, group)); + [...left, ...new Array(zeros).fill(0), ...right].forEach((group, index) => + view.setUint16(index * 2, group), + ); return bytes; } From a33cf82a1307c0409f533d9da55ad2d456928adc Mon Sep 17 00:00:00 2001 From: Ingo Fischer Date: Sun, 30 Aug 2026 20:22:19 +0200 Subject: [PATCH 3/3] docs(chip-testing): the report names the endpoint the step used, and the guidance names the controller Review feedback on this pull request, all of it text rather than behaviour. A step said it sends AddGroup to endpoint 0 while sending it to endpoint 1, which is where Groups lives on both test harnesses - the plan's own wording, copied without checking it against what the step does. A certification report describes what ran, so the step names endpoint 1, and the contributor guidance now says the same about both cases in this block. The guidance claimed the sender's log carries all four of the plan's step-5 observations. That is true of a matter.js sender; chip-tool names the group it sent to and nothing about where the message went, which is why the case records the address and port as unverified there. Both are now written down, along with the two lines chip prints on the receiving side - one of which names the group id read off the packet, the only place in this suite that appears. Also: an unfinished lead-in left behind when the shared setup was extracted, a doubled word, and a stale reference to the chip log line this case no longer matches. Verification (repository root): npm run build, npm run format-verify and npm run lint pass; TC-SC-5.3 passes. Co-Authored-By: Claude Opus 5 (1M context) --- support/chip-testing/test/cert/AGENTS.md | 30 ++++++++++++------- .../chip-testing/test/cert/TC-SC-5.3.test.ts | 4 ++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/support/chip-testing/test/cert/AGENTS.md b/support/chip-testing/test/cert/AGENTS.md index 37dc2956b8..1b84a7d9ec 100644 --- a/support/chip-testing/test/cert/AGENTS.md +++ b/support/chip-testing/test/cert/AGENTS.md @@ -2195,7 +2195,10 @@ binding, AddGroup — and that opening lives in `tc-group-support.ts` rather tha `GRPKEY.C.C03.Tx`, `GRPKEY.C.C04.Tx`; the last two are absent from the device file entirely, and an absent key evaluates false). -What its steps rest on, beyond the log: +Note the endpoint: `Groups` is not a root-node cluster, so both cases send `AddGroup`/`ViewGroup` to +the on/off light rather than to endpoint 0, whatever the plan's own step text says. A step's text names +the endpoint it exercises, because that is what the certification report describes. + ## The TCP cases invert the topology, and a controller must be asked for TCP before it starts `TC-SC-8.x` is the first block where the **DUT is the device** — the plan's preconditions say "DUT is a @@ -2371,13 +2374,18 @@ Four things the plan does not say, each of which failed silently until found: fabric's map does not name (Application Clusters § 1.3.7.1), so a case that adds group 2 *through* group 1 binds both in the GroupKeyMap step. -**What step 5 proves.** The plan asks for four things, and the sender's own log carries all four. The -multicast address is not shape-matched: the DUT's membership line names the group, the fabric and the -address together, so the address is recomputed from that fabric id and group id and compared byte for -byte — which also establishes the destination is GroupID 1. The port and address are read from the -invoke's `dest:` field, and and the session tag renders `•group#…`. That last one is the sender saying which -*kind* of session it used, not a read of the packet's own DSIZ field — which is what makes it evidence -for the claim rather than the claim itself, and the step's expected outcome says so. +**What step 5 proves, and on which controller.** The plan asks for four things, and a **matter.js** +sender's log carries all four. The multicast address is not shape-matched: the DUT's membership line +names the group, the fabric and the address together, so the address is recomputed from that fabric id +and group id and compared byte for byte — which also establishes the destination is GroupID 1. The port +and address are read from the invoke's `dest:` field, and the session tag renders `•group#…`. That last +one is the sender saying which *kind* of session it used, not a read of the packet's own DSIZ field — +which is what makes it evidence for the claim rather than the claim itself, and the step's expected +outcome says so. + +**chip-tool shows less, and says so.** Its log names the group it sent to (`Sending command to group +0x1`) and nothing about where the message went, so on that controller the address-and-port half is +recorded `unverified` with that reason rather than passing. The arrival evidence below runs on both. The arrival is proved three ways, because the first is what makes the last mean anything: the TH does not hold group 2 beforehand, the TH's own log shows it dispatching the AddGroup with the group and name @@ -2387,8 +2395,10 @@ that follows it, so without that wait the read races the device. A group command's path is endpoint-wildcarded on the wire (`invokes: *.0x4.0x0`), so the dispatch is identified by the endpoint it *reached*: matter.js names endpoint, cluster, command and fields on its -`ProtocolService Invoke «` line, and chip prints `Received command for Endpoint=1 Cluster=0x0000_0004 -Command=0x0000_0000`. +`ProtocolService Invoke «` line, and chip prints `Received Groupcast Message with GroupId 0x0001` +followed by `Processing group command for Endpoint=1 Cluster=0x0000_0004 Command=0x0000_0000`. chip's +first line is worth knowing about — it names the group id read off the *packet*, which is the +receiver's own view of the destination, and the only place in this suite where that appears. **A production change came with it.** The group invoke's diagnostic printed the address alone; `GroupSession.destination` now renders `[
]:` so the log says where a message actually diff --git a/support/chip-testing/test/cert/TC-SC-5.3.test.ts b/support/chip-testing/test/cert/TC-SC-5.3.test.ts index 205894e85d..b12d708436 100644 --- a/support/chip-testing/test/cert/TC-SC-5.3.test.ts +++ b/support/chip-testing/test/cert/TC-SC-5.3.test.ts @@ -288,7 +288,9 @@ certTest("TC-SC-5.3", { ) .step( 4, - 'DUT sends AddGroup Command to TH on EP0 with GroupID 1 and GroupName "GroupOne"', + // The plan writes EP0 here, but Groups is not a root-node cluster: on both THs it lives on the + // on/off light, so that is the endpoint the step exercises and the endpoint the report names + 'DUT sends AddGroup Command to TH on EP1 with GroupID 1 and GroupName "GroupOne"', addGroupStep(commissioned), { pics: "G.C.C00.Tx",