diff --git a/CHANGELOG.md b/CHANGELOG.md index 7736824f8f..98d9b99d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The main work (all changes without a GitHub username in brackets in the below li - Fix: A discovered peer cluster records the `ClusterRevision` the peer reports rather than the standard cluster's, and peers differing only in revision no longer share a behavior - @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: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/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..9bb0b4bab3 100644 --- a/packages/protocol/src/session/GroupSession.ts +++ b/packages/protocol/src/session/GroupSession.ts @@ -98,6 +98,17 @@ export class GroupSession extends SecureSession { return this.#multicastAddress; } + /** + * 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}`; + } + /** * Create an outbound group session. */ diff --git a/packages/protocol/test/session/SecureSessionTest.ts b/packages/protocol/test/session/SecureSessionTest.ts index 723f525770..9e6826c71c 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("[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 () => { 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..2784e18ba7 100644 --- a/packages/testing/src/chip/cert/controller-adapter.ts +++ b/packages/testing/src/chip/cert/controller-adapter.ts @@ -405,9 +405,55 @@ 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. + */ + 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..c03b3e9a6a 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,16 @@ 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. 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${NodeId.fromGroupId(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 +762,71 @@ 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}`; + + // 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", + ); + } + + 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 + // 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}`); + } +} + class ChipToolCertNodeApi implements CertNodeApi { readonly #adapter: ChipToolControllerAdapter; readonly #nodeId: NodeId; @@ -1352,6 +1429,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 ea8c1e1319..346f13b6aa 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 @@ -147,6 +147,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 6fde6134e2..b913f2992f 100644 --- a/support/chip-testing/test/cert-framework/tc-support.test.ts +++ b/support/chip-testing/test/cert-framework/tc-support.test.ts @@ -1727,6 +1727,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 df70ad8192..e409b61588 100644 --- a/support/chip-testing/test/cert/AGENTS.md +++ b/support/chip-testing/test/cert/AGENTS.md @@ -2186,74 +2186,20 @@ 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). + +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 @@ -2397,3 +2343,65 @@ 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 `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` + 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, 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 +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 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 +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..b12d708436 --- /dev/null +++ b/support/chip-testing/test/cert/TC-SC-5.3.test.ts @@ -0,0 +1,319 @@ +/** + * @license + * Copyright 2022-2026 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +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 { + 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 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 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])`, + ), + ], + }, +}; + +/** + * 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. + */ +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 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, 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; + + 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, under the name it carried", + }, + ]); + + if (!absent || !arrived) { + throw new CertCheckFailedError( + `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. + * + * 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, true), + { + 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, + // 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", + 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, 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 () => {}, { + 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..a3ae9af5f4 --- /dev/null +++ b/support/chip-testing/test/cert/tc-group-support.ts @@ -0,0 +1,426 @@ +/** + * @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. + * + * `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, alsoProvisionSender = false) { + return commissioned.withRef("dut", async (cx: CertStepContext, ref: CertNodeRef) => { + if (alsoProvisionSender) { + 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 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 halves = address.split("::"); + if (halves.length > 2) { + return undefined; + } + + 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); + [...left, ...new Array(zeros).fill(0), ...right].forEach((group, index) => + view.setUint16(index * 2, group), + ); + return bytes; +}