Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/protocol/src/action/client/ClientInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
Expand Down
11 changes: 11 additions & 0 deletions packages/protocol/src/session/GroupSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
17 changes: 17 additions & 0 deletions packages/protocol/test/session/SecureSessionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
46 changes: 46 additions & 0 deletions packages/testing/src/chip/cert/controller-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,55 @@ export interface ControllerAdapter {
parseManualPairingCode(code: string): Promise<ManualPairingCodeFields>;

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<void>;

/**
* 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<void>;
}

/**
* 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.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/testing/src/chip/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type {
AttributeWriteStatus,
BatchCommandResult,
BatchCommandSpec,
CertGroupApi,
GroupKeySetSpec,
CertNodeApi,
CertNodeRef,
CommissioningTarget,
Expand Down
81 changes: 81 additions & 0 deletions support/chip-testing/src/cert/ChipToolControllerAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import type {
AttributeWriteStatus,
BatchCommandResult,
BatchCommandSpec,
CertGroupApi,
CertNodeApi,
CertNodeRef,
CommissioningTarget,
ControllerAdapter,
EventPathSpec,
GroupKeySetSpec,
EventReadEntry,
ManualPairingCodeFields,
OnboardingPayloadFields,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
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<void> {
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;
Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions support/chip-testing/src/cert/InProcessControllerAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ import {
Write,
WriteResult,
} from "@matter/main/protocol";
import { SessionManager } from "@matter/main/protocol";
import {
AttributeId,
ClusterId,
CommandId,
EndpointNumber,
EventId,
ManualPairingCodeCodec,
GroupId,
NodeId,
Status,
StatusResponseError,
Expand All @@ -62,6 +64,7 @@ import type {
AttributeWriteStatus,
BatchCommandResult,
BatchCommandSpec,
CertGroupApi,
CertNodeApi,
CertNodeRef,
CommissioningTarget,
Expand All @@ -70,6 +73,7 @@ import type {
ControllerTransport,
EventPathSpec,
EventReadEntry,
GroupKeySetSpec,
ManualPairingCodeFields,
OnboardingPayloadFields,
ReadAttributeOptions,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<void> {
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<void> {
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)) {
}
});
}
}
6 changes: 6 additions & 0 deletions support/chip-testing/test/cert-framework/cert-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
Expand Down Expand Up @@ -975,6 +978,9 @@ describe("CertTest", () => {
async parseManualPairingCode(): Promise<never> {
throw new InternalError("not used in this test");
},
group: (): never => {
throw new InternalError("not used by these tests");
},
node: () => nodeFor("dut"),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ function fakeControllerAdapter(id: string): ControllerAdapter {
async parseManualPairingCode(): Promise<never> {
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");
},
Expand Down
Loading
Loading