From 2d9d1cbc842ae7b2af94649b9029a56c35bcefe1 Mon Sep 17 00:00:00 2001 From: lauckhart Date: Tue, 1 Oct 2024 04:08:48 -0700 Subject: [PATCH] Peer management API (#1252) Formalize the notion of a "peer" (commissioned node on a shared fabric) and restructures logic for dealing with peers: - ControllerCommissioner handles commissioning. It comprises code extracted from MatterController. - The previous "ControllerCommissioner" class is now "ControllerCommissioningFlow" and still performs the actual commissioning steps. - PeerSet manages operational peers and also consists primarily of code formerly housed in MatterController. - PeerAddress is a fabric index/node ID tuple for addressing peers. Other components that previously worked with only a single fabric or took fabric+node parameters now use this for identifying peers. - OperationalPeer is the record managed by PeerSet that describes operational information for a specific PeerAddress. I created these components in packages/protocol/src/peer and moved ControllerDiscovery there as well. Co-authored-by: Ingo Fischer --- packages/general/src/net/NetInterface.ts | 10 +- packages/general/src/util/Set.ts | 4 + packages/general/src/util/Stream.ts | 7 +- .../matter.js/src/CommissioningController.ts | 49 +- packages/matter.js/src/CommissioningServer.ts | 4 +- packages/matter.js/src/MatterController.ts | 839 +++--------------- packages/matter.js/src/compat/protocol.ts | 5 +- packages/matter.js/src/device/PairedNode.ts | 2 +- .../server/TransactionalInteractionServer.ts | 4 +- .../internal/ClusterServerBackingTest.ts | 7 +- packages/protocol/src/MatterDevice.ts | 7 +- .../src/certificate/CertificateManager.ts | 73 +- .../CertificationDeclarationManager.ts | 2 +- .../src/certificate/RootCertificateManager.ts | 10 + .../protocol/src/common/FailsafeContext.ts | 2 +- packages/protocol/src/common/Scanner.ts | 21 +- packages/protocol/src/common/index.ts | 1 + packages/protocol/src/fabric/Fabric.ts | 17 +- packages/protocol/src/fabric/FabricManager.ts | 12 + packages/protocol/src/index.ts | 1 + .../src/interaction/InteractionClient.ts | 3 +- .../src/interaction/InteractionServer.ts | 7 +- .../src/interaction/ServerSubscription.ts | 13 +- .../src/peer/ControllerCommissioner.ts | 372 ++++++++ .../ControllerCommissioningFlow.ts} | 603 +++++++------ .../{protocol => peer}/ControllerDiscovery.ts | 4 +- packages/protocol/src/peer/OperationalPeer.ts | 32 + packages/protocol/src/peer/PeerAddress.ts | 78 ++ packages/protocol/src/peer/PeerSet.ts | 648 ++++++++++++++ packages/protocol/src/peer/PeerStore.ts | 19 + packages/protocol/src/peer/index.ts | 13 + .../protocol/src/protocol/ChannelManager.ts | 63 +- .../protocol/src/protocol/DeviceAdvertiser.ts | 2 +- .../src/protocol/DeviceCommissioner.ts | 4 +- .../protocol/src/protocol/ExchangeManager.ts | 8 +- .../protocol/src/protocol/MessageExchange.ts | 4 +- .../protocol/src/protocol/NodeDiscoverer.ts | 81 -- packages/protocol/src/protocol/index.ts | 3 - .../protocol/src/session/SecureSession.ts | 23 +- .../protocol/src/session/SessionManager.ts | 43 +- .../protocol/src/session/case/CaseClient.ts | 2 +- 41 files changed, 1833 insertions(+), 1269 deletions(-) create mode 100644 packages/protocol/src/peer/ControllerCommissioner.ts rename packages/protocol/src/{protocol/ControllerCommissioner.ts => peer/ControllerCommissioningFlow.ts} (71%) rename packages/protocol/src/{protocol => peer}/ControllerDiscovery.ts (98%) create mode 100644 packages/protocol/src/peer/OperationalPeer.ts create mode 100644 packages/protocol/src/peer/PeerAddress.ts create mode 100644 packages/protocol/src/peer/PeerSet.ts create mode 100644 packages/protocol/src/peer/PeerStore.ts create mode 100644 packages/protocol/src/peer/index.ts delete mode 100644 packages/protocol/src/protocol/NodeDiscoverer.ts diff --git a/packages/general/src/net/NetInterface.ts b/packages/general/src/net/NetInterface.ts index b0da7dad89..e4f047e33c 100644 --- a/packages/general/src/net/NetInterface.ts +++ b/packages/general/src/net/NetInterface.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Environment } from "#environment/Environment.js"; +import { Environmental } from "#environment/Environmental.js"; import { Channel } from "./Channel.js"; import { ServerAddress } from "./ServerAddress.js"; import { TransportInterface, TransportInterfaceSet } from "./TransportInterface.js"; @@ -22,4 +24,10 @@ export function isNetworkInterface(obj: TransportInterface | NetInterface): obj /** * A collection of {@link NetInterfaces} managed as a unit. */ -export class NetInterfaceSet extends TransportInterfaceSet {} +export class NetInterfaceSet extends TransportInterfaceSet { + [Environmental.create](env: Environment) { + const instance = new NetInterfaceSet(); + env.set(NetInterfaceSet, this); + return instance; + } +} diff --git a/packages/general/src/util/Set.ts b/packages/general/src/util/Set.ts index 03a4775a66..713ec42cde 100644 --- a/packages/general/src/util/Set.ts +++ b/packages/general/src/util/Set.ts @@ -68,6 +68,10 @@ export class BasicSet implements ImmutableSet, MutableSet(mapper: (item: T) => R) { + return [...this].map(mapper); + } + find(predicate: (item: T) => boolean | undefined) { for (const item of this) { if (predicate(item)) { diff --git a/packages/general/src/util/Stream.ts b/packages/general/src/util/Stream.ts index 0d06e32939..ddd6ed502e 100644 --- a/packages/general/src/util/Stream.ts +++ b/packages/general/src/util/Stream.ts @@ -5,7 +5,12 @@ */ import { MatterError } from "../MatterError.js"; -export class EndOfStreamError extends MatterError {} +export class EndOfStreamError extends MatterError { + constructor(message = "Unexpected end of stream") { + super(message); + } +} + export class NoResponseTimeoutError extends MatterError {} export interface Stream { diff --git a/packages/matter.js/src/CommissioningController.ts b/packages/matter.js/src/CommissioningController.ts index 276aa992c0..3ca155227a 100644 --- a/packages/matter.js/src/CommissioningController.ts +++ b/packages/matter.js/src/CommissioningController.ts @@ -13,7 +13,6 @@ import { NetInterfaceSet, Network, NoProviderError, - ServerAddress, StorageContext, SupportedStorageTypes, SyncStorage, @@ -24,13 +23,14 @@ import { Ble, CommissionableDevice, CommissionableDeviceIdentifiers, - ControllerCommissioningOptions, ControllerDiscovery, DiscoveryData, InteractionClient, MdnsBroadcaster, MdnsScanner, MdnsService, + NodeDiscoveryType, + PeerCommissioningOptions, ScannerSet, SupportedAttributeClient, } from "#protocol"; @@ -46,7 +46,7 @@ import { VendorId, } from "#types"; import { CommissioningControllerNodeOptions, PairedNode } from "./device/PairedNode.js"; -import { MatterController, NodeDiscoveryType } from "./MatterController.js"; +import { MatterController } from "./MatterController.js"; import { MatterNode } from "./MatterNode.js"; const logger = new Logger("CommissioningController"); @@ -120,44 +120,8 @@ export type CommissioningControllerOptions = CommissioningControllerNodeOptions /** Options needed to commission a new node */ export type NodeCommissioningOptions = CommissioningControllerNodeOptions & { - /** Commission related options. */ - commissioning?: ControllerCommissioningOptions; - - /** Discovery related options. */ - discovery: ( - | { - /** - * Device identifiers (Short or Long Discriminator, Product/Vendor-Ids, Device-type or a pre-discovered - * instance Id, or "nothing" to discover all commissionable matter devices) to use for discovery. - * If the property commissionableDevice is provided this property is ignored. - */ - identifierData: CommissionableDeviceIdentifiers; - } - | { - /** - * Commissionable device object returned by a discovery run. - * If this property is provided then identifierData and knownAddress are ignored. - */ - commissionableDevice: CommissionableDevice; - } - ) & { - /** - * Discovery capabilities to use for discovery. These are included in the QR code normally and defined if BLE - * is supported for initial commissioning. - */ - discoveryCapabilities?: TypeFromPartialBitSchema; - - /** - * Known address of the device to use for discovery. if this is set this will be tried first before discovering - * the device. - */ - knownAddress?: ServerAddress; - - /** Timeout in seconds for the discovery process. Default: 30 seconds */ - timeoutSeconds?: number; - }; - - /** Passcode to use for commissioning. */ + commissioning: Omit; + discovery: PeerCommissioningOptions["discovery"]; passcode: number; }; @@ -274,7 +238,8 @@ export class CommissioningController extends MatterNode { } /** - * Commissions/Pairs a new device into the controller fabric. The method returns the NodeId of the commissioned node. + * Commissions/Pairs a new device into the controller fabric. The method returns the NodeId of the commissioned + * node. */ async commissionNode(nodeOptions: NodeCommissioningOptions, connectNodeAfterCommissioning = true) { this.assertIsAddedToMatterServer(); diff --git a/packages/matter.js/src/CommissioningServer.ts b/packages/matter.js/src/CommissioningServer.ts index bb3df49870..095d8dcec3 100644 --- a/packages/matter.js/src/CommissioningServer.ts +++ b/packages/matter.js/src/CommissioningServer.ts @@ -596,8 +596,8 @@ export class CommissioningServer extends MatterNode { randomizationWindowSeconds: this.options.subscriptionRandomizationWindowSeconds, }, maxPathsPerInvoke, - initiateExchange: (fabric, nodeId, protocolId) => { - return deviceInstance.initiateExchange(fabric, nodeId, protocolId); + initiateExchange: (address, protocolId) => { + return deviceInstance.initiateExchange(address, protocolId); }, }); deviceInstance.addProtocolHandler(this.interactionServer); diff --git a/packages/matter.js/src/MatterController.ts b/packages/matter.js/src/MatterController.ts index 063201d573..4b309f8ac3 100644 --- a/packages/matter.js/src/MatterController.ts +++ b/packages/matter.js/src/MatterController.ts @@ -11,53 +11,39 @@ */ import { GeneralCommissioning } from "#clusters"; +import { NodeCommissioningOptions } from "#CommissioningController.js"; import { CRYPTO_SYMMETRIC_KEY_LENGTH, - Channel, ChannelType, Construction, Crypto, ImplementationError, Logger, NetInterfaceSet, - NoResponseTimeoutError, - ServerAddress, ServerAddressIp, StorageBackendMemory, StorageContext, StorageManager, SupportedStorageTypes, - Time, - Timer, - anyPromise, - createPromise, - isIPv6, serverAddressToString, } from "#general"; import { - CaseClient, ChannelManager, ClusterClient, - CommissionableDevice, CommissioningError, - CommissioningSuccessfullyFinished, ControllerCommissioner, - ControllerCommissioningOptions, - ControllerDiscovery, DiscoveryData, - DiscoveryError, + DiscoveryOptions, ExchangeManager, - ExchangeProvider, Fabric, FabricBuilder, FabricJsonObject, FabricManager, - InteractionClient, - MdnsScanner, - MessageChannel, - NoChannelError, - PairRetransmissionLimitReachedError, - PaseClient, + NodeDiscoveryType, + OperationalPeer, + PeerCommissioningOptions, + PeerSet, + PeerStore, ResumptionRecord, RetransmissionLimitReachedError, RootCertificateManager, @@ -72,7 +58,6 @@ import { FabricId, FabricIndex, NodeId, - SECURE_CHANNEL_PROTOCOL_ID, TlvEnum, TlvField, TlvObject, @@ -81,7 +66,6 @@ import { TypeFromSchema, VendorId, } from "#types"; -import { NodeCommissioningOptions } from "./CommissioningController.js"; const TlvCommissioningSuccessFailureResponse = TlvObject({ /** Contain the result of the operation. */ @@ -98,37 +82,20 @@ export type CommissionedNodeDetails = { basicInformationData?: Record; }; -type DiscoveryOptions = { - discoveryType?: NodeDiscoveryType; - timeoutSeconds?: number; - discoveryData?: DiscoveryData; -}; - +const DEFAULT_ADMIN_VENDOR_ID = VendorId(0xfff1); const DEFAULT_FABRIC_INDEX = FabricIndex(1); const DEFAULT_FABRIC_ID = FabricId(1); -const DEFAULT_ADMIN_VENDOR_ID = VendorId(0xfff1); - -const RECONNECTION_POLLING_INTERVAL_MS = 600_000; // 10 minutes -const RETRANSMISSION_DISCOVERY_TIMEOUT_MS = 5_000; const CONTROLLER_CONNECTIONS_PER_FABRIC_AND_NODE = 3; const CONTROLLER_MAX_PATHS_PER_INVOKE = 10; const logger = Logger.get("MatterController"); -export enum NodeDiscoveryType { - /** No discovery is done, in calls means that only known addresses are tried. */ - None = 0, - - /** Retransmission discovery means that we ignore known addresses and start a query for 5s. */ - RetransmissionDiscovery = 1, +// Operational peer extended with basic information as required for conversion to CommissionedNodeDetails +type CommissionedPeer = OperationalPeer & { basicInformationData?: Record }; - /** Timed discovery means that the device is discovered for a defined timeframe, including known addresses. */ - TimedDiscovery = 2, - - /** Full discovery means that the device is discovered until it is found, excluding known addresses. */ - FullDiscovery = 3, -} +// Backward-compatible persistence record for nodes +type StoredOperationalPeer = [NodeId, CommissionedNodeDetails]; export class MatterController { public static async create(options: { @@ -152,7 +119,7 @@ export class MatterController { scanners, netInterfaces, sessionClosedCallback, - adminVendorId = VendorId(DEFAULT_ADMIN_VENDOR_ID), + adminVendorId, adminFabricId = FabricId(DEFAULT_FABRIC_ID), adminFabricIndex = FabricIndex(DEFAULT_FABRIC_INDEX), caseAuthenticatedTags, @@ -172,7 +139,6 @@ export class MatterController { netInterfaces, certificateManager, fabric, - adminVendorId: fabric.rootVendorId, sessionClosedCallback, }); } else { @@ -182,7 +148,7 @@ export class MatterController { .setRootCert(certificateManager.rootCert) .setRootNodeId(rootNodeId) .setIdentityProtectionKey(ipkValue) - .setRootVendorId(adminVendorId); + .setRootVendorId(adminVendorId ?? DEFAULT_ADMIN_VENDOR_ID); fabricBuilder.setOperationalCert( certificateManager.generateNoc( fabricBuilder.publicKey, @@ -201,7 +167,6 @@ export class MatterController { netInterfaces, certificateManager, fabric, - adminVendorId, sessionClosedCallback, }); } @@ -245,7 +210,6 @@ export class MatterController { netInterfaces, certificateManager, fabric, - adminVendorId: fabric.rootVendorId, sessionClosedCallback, }); await controller.construction; @@ -256,27 +220,17 @@ export class MatterController { private readonly netInterfaces = new NetInterfaceSet(); private readonly channelManager = new ChannelManager(CONTROLLER_CONNECTIONS_PER_FABRIC_AND_NODE); private readonly exchangeManager: ExchangeManager; - private readonly paseClient; - private readonly caseClient; - private readonly commissionedNodes = new Map(); + private readonly peers: PeerSet; + private readonly commissioner: ControllerCommissioner; #construction: Construction; readonly sessionStorage: StorageContext; readonly fabricStorage?: StorageContext; - readonly nodesStorage: StorageContext; + readonly nodesStore: CommissionedNodeStore; private readonly scanners: ScannerSet; private readonly certificateManager: RootCertificateManager; private readonly fabric: Fabric; - private readonly adminVendorId: VendorId; private readonly sessionClosedCallback?: (peerNodeId: NodeId) => void; - readonly #runningNodeDiscoveries = new Map< - NodeId, - { - type: NodeDiscoveryType; - promises?: (() => Promise)[]; - timer?: Timer; - } - >(); get construction() { return this.#construction; @@ -290,7 +244,6 @@ export class MatterController { netInterfaces: NetInterfaceSet; certificateManager: RootCertificateManager; fabric: Fabric; - adminVendorId: VendorId; sessionClosedCallback?: (peerNodeId: NodeId) => void; }) { const { @@ -302,17 +255,14 @@ export class MatterController { certificateManager, fabric, sessionClosedCallback, - adminVendorId, } = options; this.sessionStorage = sessionStorage; this.fabricStorage = fabricStorage; - this.nodesStorage = nodesStorage; this.scanners = scanners; this.netInterfaces = netInterfaces; this.certificateManager = certificateManager; this.fabric = fabric; this.sessionClosedCallback = sessionClosedCallback; - this.adminVendorId = adminVendorId; const fabricManager = new FabricManager(); fabricManager.addFabric(fabric); @@ -324,12 +274,9 @@ export class MatterController { maxPathsPerInvoke: CONTROLLER_MAX_PATHS_PER_INVOKE, }, }); - this.paseClient = new PaseClient(this.sessionManager); - this.caseClient = new CaseClient(this.sessionManager); this.sessionManager.sessions.deleted.on(async session => { this.sessionClosedCallback?.(session.peerNodeId); }); - this.sessionManager.resubmissionStarted.on(this.#handleResubmissionStarted.bind(this)); this.exchangeManager = new ExchangeManager({ sessionManager: this.sessionManager, @@ -338,17 +285,29 @@ export class MatterController { }); this.exchangeManager.addProtocolHandler(new StatusReportOnlySecureChannelProtocol()); - this.#construction = Construction(this, async () => { - // If controller has a stored operational server address, use it, irrelevant what was passed in the constructor - if (await this.nodesStorage.has("commissionedNodes")) { - const commissionedNodes = - await this.nodesStorage.get<[NodeId, CommissionedNodeDetails][]>("commissionedNodes"); - this.commissionedNodes.clear(); - for (const [nodeId, details] of commissionedNodes) { - this.commissionedNodes.set(nodeId, details); - } - } + // Adapts the historical storage format for MatterController to OperationalPeer objects + this.nodesStore = new CommissionedNodeStore(nodesStorage, fabric); + + this.nodesStore.peers = this.peers = new PeerSet({ + sessions: this.sessionManager, + channels: this.channelManager, + exchanges: this.exchangeManager, + scanners: this.scanners, + netInterfaces: this.netInterfaces, + store: this.nodesStore, + }); + + this.commissioner = new ControllerCommissioner({ + peers: this.peers, + scanners: this.scanners, + netInterfaces: this.netInterfaces, + exchanges: this.exchangeManager, + sessions: this.sessionManager, + certificates: this.certificateManager, + }); + this.#construction = Construction(this, async () => { + await this.peers.construction.ready; await this.sessionManager.construction.ready; }); } @@ -394,267 +353,35 @@ export class MatterController { options: NodeCommissioningOptions, completeCommissioningCallback?: (peerNodeId: NodeId, discoveryData?: DiscoveryData) => Promise, ): Promise { - const { - commissioning: commissioningOptions = { - regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant - regulatoryCountryCode: "XX", - }, - discovery: { timeoutSeconds = 30 }, - passcode, - } = options; - const commissionableDevice = - "commissionableDevice" in options.discovery ? options.discovery.commissionableDevice : undefined; - let { - discovery: { discoveryCapabilities = {}, knownAddress }, - } = options; - let identifierData = "identifierData" in options.discovery ? options.discovery.identifierData : {}; - - if ( - this.scanners.hasScannerFor(ChannelType.UDP) && - this.netInterfaces.hasInterfaceFor(ChannelType.UDP, "::") !== undefined - ) { - discoveryCapabilities.onIpNetwork = true; // We always discover on network as defined by specs - } - if (commissionableDevice !== undefined) { - let { addresses } = commissionableDevice; - if (discoveryCapabilities.ble === true) { - discoveryCapabilities = { onIpNetwork: true, ble: addresses.some(address => address.type === "ble") }; - } else if (discoveryCapabilities.onIpNetwork === true) { - // do not use BLE if not specified, even if existing - addresses = addresses.filter(address => address.type !== "ble"); - } - addresses.sort(a => (a.type === "udp" ? -1 : 1)); // Sort addresses to use UDP first - knownAddress = addresses[0]; - if ("instanceId" in commissionableDevice && commissionableDevice.instanceId !== undefined) { - // it is an UDP discovery - identifierData = { instanceId: commissionableDevice.instanceId as string }; - } else { - identifierData = { longDiscriminator: commissionableDevice.D }; - } - } - - const scannersToUse = this.collectScanners(discoveryCapabilities); - - logger.info( - `Commissioning device with identifier ${Logger.toJSON(identifierData)} and ${ - scannersToUse.length - } scanners and knownAddress ${Logger.toJSON(knownAddress)}`, - ); - - // If we have a known address we try this first before we discover the device - let paseSecureChannel: MessageChannel | undefined; - let discoveryData: DiscoveryData | undefined; + const commissioningOptions: PeerCommissioningOptions = { + ...options.commissioning, + fabric: this.fabric, + discovery: options.discovery, + passcode: options.passcode, + }; - // If we have a last known address, try this first - if (knownAddress !== undefined) { - try { - paseSecureChannel = await this.initializePaseSecureChannel(knownAddress, passcode); - } catch (error) { - NoResponseTimeoutError.accept(error); - } + if (completeCommissioningCallback) { + commissioningOptions.performCaseCommissioning = async (peerAddress, discoveryData) => { + const result = await completeCommissioningCallback(peerAddress.nodeId, discoveryData); + if (!result) { + throw new RetransmissionLimitReachedError("Device could not be discovered"); + } + }; } - if (paseSecureChannel === undefined) { - const discoveredDevices = await ControllerDiscovery.discoverDeviceAddressesByIdentifier( - scannersToUse, - identifierData, - timeoutSeconds, - ); - const { result } = await ControllerDiscovery.iterateServerAddresses( - discoveredDevices, - NoResponseTimeoutError, - async () => - scannersToUse.flatMap(scanner => scanner.getDiscoveredCommissionableDevices(identifierData)), - async (address, device) => { - const channel = await this.initializePaseSecureChannel(address, passcode, device); - discoveryData = device; - return channel; - }, - ); + const address = await this.commissioner.commission(commissioningOptions); - // Pairing was successful, so store the address and assign the established secure channel - paseSecureChannel = result; - } + await this.fabricStorage?.set("fabric", this.fabric.toStorageObject()); - return await this.commissionDevice( - paseSecureChannel, - commissioningOptions, - discoveryData, - completeCommissioningCallback, - ); + return address.nodeId; } async disconnect(nodeId: NodeId) { - await this.sessionManager.removeAllSessionsForNode(nodeId, true); - await this.channelManager.removeAllNodeChannels(this.fabric, nodeId); + return this.peers.disconnect(this.fabric.addressOf(nodeId)); } async removeNode(nodeId: NodeId) { - logger.info(`Removing commissioned node ${nodeId} from controller.`); - await this.sessionManager.removeAllSessionsForNode(nodeId); - await this.sessionManager.removeResumptionRecord(nodeId); - await this.channelManager.removeAllNodeChannels(this.fabric, nodeId); - this.commissionedNodes.delete(nodeId); - await this.storeCommissionedNodes(); - } - - /** - * Method to start commission process with a PASE pairing. - * If this not successful and throws an RetransmissionLimitReachedError the address is invalid or the passcode - * is wrong. - */ - private async initializePaseSecureChannel( - address: ServerAddress, - passcode: number, - device?: CommissionableDevice, - ): Promise { - let paseChannel: Channel; - if (device !== undefined) { - logger.info(`Commissioning device`, MdnsScanner.discoveryDataDiagnostics(device)); - } - if (address.type === "udp") { - const { ip } = address; - - const isIpv6Address = isIPv6(ip); - const paseInterface = this.netInterfaces.interfaceFor(ChannelType.UDP, isIpv6Address ? "::" : "0.0.0.0"); - if (paseInterface === undefined) { - // mainly IPv6 address when IPv4 is disabled - throw new PairRetransmissionLimitReachedError( - `IPv${isIpv6Address ? "6" : "4"} interface not initialized. Cannot use ${ip} for commissioning.`, - ); - } - paseChannel = await paseInterface.openChannel(address); - } else { - const ble = this.netInterfaces.interfaceFor(ChannelType.BLE); - if (!ble) { - throw new PairRetransmissionLimitReachedError( - `BLE interface not initialized. Cannot use ${address.peripheralAddress} for commissioning.`, - ); - } - // TODO Have a Timeout mechanism here for connections - paseChannel = await ble.openChannel(address); - } - - // Do PASE paring - const unsecureSession = this.sessionManager.createInsecureSession({ - // Use the session parameters from MDNS announcements when available and rest is assumed to be fallbacks - sessionParameters: { - idleIntervalMs: device?.SII, - activeIntervalMs: device?.SAI, - activeThresholdMs: device?.SAT, - }, - isInitiator: true, - }); - const paseUnsecureMessageChannel = new MessageChannel(paseChannel, unsecureSession); - const paseExchange = this.exchangeManager.initiateExchangeWithChannel( - paseUnsecureMessageChannel, - SECURE_CHANNEL_PROTOCOL_ID, - ); - - let paseSecureSession; - try { - paseSecureSession = await this.paseClient.pair( - this.sessionManager.sessionParameters, - paseExchange, - passcode, - ); - } catch (e) { - // Close the exchange and rethrow - await paseExchange.close(); - throw e; - } - - await unsecureSession.destroy(); - return new MessageChannel(paseChannel, paseSecureSession); - } - - /** - * Method to commission a device with a PASE secure channel. It returns the NodeId of the commissioned device on - * success. - */ - private async commissionDevice( - paseSecureMessageChannel: MessageChannel, - commissioningOptions: ControllerCommissioningOptions, - discoveryData?: DiscoveryData, - completeCommissioningCallback?: (peerNodeId: NodeId, discoveryData?: DiscoveryData) => Promise, - ): Promise { - // TODO: Create the fabric only when needed before commissioning (to do when refactoring MatterController away) - // TODO also move certificateManager and other parts into that class to get rid of them here - // TODO Depending on the Error type during commissioning we can do a retry ... - /* - Whenever the Fail-Safe timer is armed, Commissioners and Administrators SHALL NOT consider any cluster - operation to have timed-out before waiting at least 30 seconds for a valid response from the cluster server. - Some commands and attributes with complex side-effects MAY require longer and have specific timing requirements - stated in their respective cluster specification. - - In concurrent connection commissioning flow, the failure of any of the steps 2 through 10 SHALL result in the - Commissioner and Commissionee returning to step 2 (device discovery and commissioning channel establishment) and - repeating each step. The failure of any of the steps 11 through 15 in concurrent connection commissioning flow - SHALL result in the Commissioner and Commissionee returning to step 11 (configuration of operational network - information). In the case of failure of any of the steps 11 through 15 in concurrent connection commissioning - flow, the Commissioner and Commissionee SHALL reuse the existing PASE-derived encryption keys over the - commissioning channel and all steps up to and including step 10 are considered to have been successfully - completed. - In non-concurrent connection commissioning flow, the failure of any of the steps 2 through 15 SHALL result in - the Commissioner and Commissionee returning to step 2 (device discovery and commissioning channel establishment) - and repeating each step. - - Commissioners that need to restart from step 2 MAY immediately expire the fail-safe by invoking the ArmFailSafe - command with an ExpiryLengthSeconds field set to 0. Otherwise, Commissioners will need to wait until the current - fail-safe timer has expired for the Commissionee to begin accepting PASE again. - In both concurrent connection commissioning flow and non-concurrent connection commissioning flow, the - Commissionee SHALL exit Commissioning Mode after 20 failed attempts. - */ - - const peerNodeId = commissioningOptions.nodeId ?? NodeId.randomOperationalNodeId(); - const commissioningManager = new ControllerCommissioner( - // Use the created secure session to do the commissioning - new InteractionClient(new ExchangeProvider(this.exchangeManager, paseSecureMessageChannel), peerNodeId), - this.certificateManager, - this.fabric, - commissioningOptions, - peerNodeId, - this.adminVendorId, - async () => { - // TODO Right now we always close after step 12 because we do not check for commissioning flow requirements - /* - In concurrent connection commissioning flow the commissioning channel SHALL terminate after - successful step 15 (CommissioningComplete command invocation). In non-concurrent connection - commissioning flow the commissioning channel SHALL terminate after successful step 12 (trigger - joining of operational network at Commissionee). The PASE-derived encryption keys SHALL be deleted - when commissioning channel terminates. The PASE session SHALL be terminated by both Commissioner and - Commissionee once the CommissioningComplete command is received by the Commissionee. - */ - await paseSecureMessageChannel.close(); // We reconnect using Case, so close PASE connection - - if (completeCommissioningCallback !== undefined) { - if (!(await completeCommissioningCallback(peerNodeId, discoveryData))) { - throw new RetransmissionLimitReachedError("Device could not be discovered"); - } - throw new CommissioningSuccessfullyFinished(); - } - // Look for the device broadcast over MDNS and do CASE pairing - return await this.connect(peerNodeId, { - discoveryType: NodeDiscoveryType.TimedDiscovery, - timeoutSeconds: 120, - discoveryData, - }); // Wait maximum 120s to find the operational device for commissioning process - }, - ); - - try { - await commissioningManager.executeCommissioning(); - } catch (error) { - if (this.commissionedNodes.has(peerNodeId)) { - // We might have added data for an operational address that we need to cleanup - this.commissionedNodes.delete(peerNodeId); - } - throw error; - } - - await this.fabricStorage?.set("fabric", this.fabric.toStorageObject()); - - return peerNodeId; + return this.peers.delete(this.fabric.addressOf(nodeId)); } /** @@ -676,357 +403,45 @@ export class MatterController { useExtendedFailSafeMessageResponseTimeout: true, }); if (errorCode !== GeneralCommissioning.CommissioningError.Ok) { - if (this.commissionedNodes.has(peerNodeId)) { - // We might have added data for an operational address that we need to cleanup - this.commissionedNodes.delete(peerNodeId); - } + // We might have added data for an operational address that we need to cleanup + await this.peers.delete(this.fabric.addressOf(peerNodeId)); throw new CommissioningError(`Commission error on commissioningComplete: ${errorCode}, ${debugText}`); } await this.fabricStorage?.set("fabric", this.fabric.toStorageObject()); } - #handleResubmissionStarted(peerNodeId?: NodeId) { - if (peerNodeId === undefined) { - return; - } - if (this.#runningNodeDiscoveries.has(peerNodeId)) { - // We already discover for this node, so we do not need to start a new discovery - return; - } - this.#runningNodeDiscoveries.set(peerNodeId, { type: NodeDiscoveryType.RetransmissionDiscovery }); - this.scanners - .scannerFor(ChannelType.UDP) - ?.findOperationalDevice(this.fabric, peerNodeId, RETRANSMISSION_DISCOVERY_TIMEOUT_MS, true) - .catch(error => { - logger.error(`Failed to discover device ${peerNodeId} after resubmission started.`, error); - }) - .finally(() => { - if (this.#runningNodeDiscoveries.get(peerNodeId)?.type === NodeDiscoveryType.RetransmissionDiscovery) { - this.#runningNodeDiscoveries.delete(peerNodeId); - } - }); - } - - private async reconnectKnownAddress( - peerNodeId: NodeId, - operationalAddress: ServerAddressIp, - discoveryData?: DiscoveryData, - expectedProcessingTimeMs?: number, - ): Promise { - const { ip, port } = operationalAddress; - try { - logger.debug( - `Resume device connection to configured server at ${ip}:${port}${expectedProcessingTimeMs !== undefined ? ` with expected processing time of ${expectedProcessingTimeMs}ms` : ""} ...`, - ); - const channel = await this.pair(peerNodeId, operationalAddress, discoveryData, expectedProcessingTimeMs); - await this.setOperationalDeviceData(peerNodeId, operationalAddress); - return channel; - } catch (error) { - if (error instanceof NoResponseTimeoutError) { - logger.debug( - `Failed to resume connection to node ${peerNodeId} connection with ${ip}:${port}, discover the device ...`, - error, - ); - // We remove all sessions, this also informs the PairedNode class - await this.sessionManager.removeAllSessionsForNode(peerNodeId); - return undefined; - } else { - throw error; - } - } - } - - private async connectOrDiscoverNode( - peerNodeId: NodeId, - operationalAddress?: ServerAddressIp, - discoveryOptions: DiscoveryOptions = {}, - ) { - const { - discoveryType: requestedDiscoveryType = NodeDiscoveryType.FullDiscovery, - timeoutSeconds, - discoveryData = this.commissionedNodes.get(peerNodeId)?.discoveryData, - } = discoveryOptions; - if (timeoutSeconds !== undefined && requestedDiscoveryType !== NodeDiscoveryType.TimedDiscovery) { - throw new ImplementationError("Cannot set timeout without timed discovery."); - } - if (requestedDiscoveryType === NodeDiscoveryType.RetransmissionDiscovery) { - throw new ImplementationError("Cannot set retransmission discovery type."); - } - - const mdnsScanner = this.scanners.scannerFor(ChannelType.UDP) as MdnsScanner | undefined; - if (!mdnsScanner) { - throw new ImplementationError("Cannot discover device without mDNS scanner."); - } - - const existingDiscoveryDetails = this.#runningNodeDiscoveries.get(peerNodeId) ?? { - type: NodeDiscoveryType.None, - }; - - // If we currently run another "lower" retransmission type we cancel it - if ( - existingDiscoveryDetails.type !== NodeDiscoveryType.None && - existingDiscoveryDetails.type < requestedDiscoveryType - ) { - mdnsScanner.cancelOperationalDeviceDiscovery(this.fabric, peerNodeId); - this.#runningNodeDiscoveries.delete(peerNodeId); - existingDiscoveryDetails.type = NodeDiscoveryType.None; - } - - const { type: runningDiscoveryType, promises } = existingDiscoveryDetails; - - // If we have a last known address try to reach the device directly when we are not already discovering - // In worst case parallel cases we do this step twice, but that's ok - if ( - operationalAddress !== undefined && - (runningDiscoveryType === NodeDiscoveryType.None || requestedDiscoveryType === NodeDiscoveryType.None) - ) { - const directReconnection = await this.reconnectKnownAddress(peerNodeId, operationalAddress, discoveryData); - if (directReconnection !== undefined) { - return directReconnection; - } - if (requestedDiscoveryType === NodeDiscoveryType.None) { - throw new DiscoveryError(`Node ${peerNodeId} is not reachable right now.`); - } - } - - if (promises !== undefined) { - if (runningDiscoveryType > requestedDiscoveryType) { - // We already run a "longer" discovery, so we know it is unreachable for now - throw new DiscoveryError( - `Node ${peerNodeId} is not reachable right now and discovery already running.`, - ); - } else { - // If we are already discovering this node, so we reuse promises - return await anyPromise(promises); - } - } - - const discoveryPromises = new Array<() => Promise>(); - let reconnectionPollingTimer: Timer | undefined; - - if (operationalAddress !== undefined) { - // Additionally to general discovery we also try to poll the formerly known operational address - if (requestedDiscoveryType === NodeDiscoveryType.FullDiscovery) { - const { promise, resolver, rejecter } = createPromise(); - - reconnectionPollingTimer = Time.getPeriodicTimer( - "Controller reconnect", - RECONNECTION_POLLING_INTERVAL_MS, - async () => { - try { - logger.debug(`Polling for device at ${serverAddressToString(operationalAddress)} ...`); - const result = await this.reconnectKnownAddress( - peerNodeId, - operationalAddress, - discoveryData, - ); - if (result !== undefined && reconnectionPollingTimer?.isRunning) { - reconnectionPollingTimer?.stop(); - mdnsScanner.cancelOperationalDeviceDiscovery(this.fabric, peerNodeId); - this.#runningNodeDiscoveries.delete(peerNodeId); - resolver(result); - } - } catch (error) { - if (reconnectionPollingTimer?.isRunning) { - reconnectionPollingTimer?.stop(); - mdnsScanner.cancelOperationalDeviceDiscovery(this.fabric, peerNodeId); - this.#runningNodeDiscoveries.delete(peerNodeId); - rejecter(error); - } - } - }, - ).start(); - - discoveryPromises.push(() => promise); - } - } - - discoveryPromises.push(async () => { - const scanResult = await ControllerDiscovery.discoverOperationalDevice( - this.fabric, - peerNodeId, - mdnsScanner, - timeoutSeconds, - timeoutSeconds === undefined, - ); - const { timer } = this.#runningNodeDiscoveries.get(peerNodeId) ?? {}; - timer?.stop(); - this.#runningNodeDiscoveries.delete(peerNodeId); - - const { result } = await ControllerDiscovery.iterateServerAddresses( - [scanResult], - NoResponseTimeoutError, - async () => { - const device = mdnsScanner.getDiscoveredOperationalDevice(this.fabric, peerNodeId); - return device !== undefined ? [device] : []; - }, - async (address, device) => { - const result = await this.pair(peerNodeId, address, device); - await this.setOperationalDeviceData(peerNodeId, address, { - ...discoveryData, - ...device, - }); - return result; - }, - ); - - return result; - }); - - this.#runningNodeDiscoveries.set(peerNodeId, { - type: requestedDiscoveryType, - promises: discoveryPromises, - timer: reconnectionPollingTimer, - }); - - return await anyPromise(discoveryPromises).finally(() => this.#runningNodeDiscoveries.delete(peerNodeId)); - } - - /** - * Resume a device connection and establish a CASE session that was previously paired with the controller. This - * method will try to connect to the device using the previously used server address (if set). If that fails, the - * device is discovered again using its operational instance details. - * It returns the operational MessageChannel on success. - */ - private async resume(peerNodeId: NodeId, discoveryOptions?: DiscoveryOptions) { - const operationalAddress = this.getLastOperationalAddress(peerNodeId); - - try { - return await this.connectOrDiscoverNode(peerNodeId, operationalAddress, discoveryOptions); - } catch (error) { - if ( - (error instanceof DiscoveryError || error instanceof NoResponseTimeoutError) && - this.commissionedNodes.has(peerNodeId) - ) { - logger.info(`Resume failed, remove all sessions for node ${peerNodeId}`); - // We remove all sessions, this also informs the PairedNode class - await this.sessionManager.removeAllSessionsForNode(peerNodeId); - } - throw error; - } - } - - /** Pair with an operational device (already commissioned) and establish a CASE session. */ - private async pair( - peerNodeId: NodeId, - operationalServerAddress: ServerAddressIp, - discoveryData?: DiscoveryData, - expectedProcessingTimeMs?: number, - ) { - const { ip, port } = operationalServerAddress; - // Do CASE pairing - const isIpv6Address = isIPv6(ip); - const operationalInterface = this.netInterfaces.interfaceFor(ChannelType.UDP, isIpv6Address ? "::" : "0.0.0.0"); - - if (operationalInterface === undefined) { - throw new PairRetransmissionLimitReachedError( - `IPv${ - isIpv6Address ? "6" : "4" - } interface not initialized for port ${port}. Cannot use ${ip} for pairing.`, - ); - } - - const operationalChannel = await operationalInterface.openChannel(operationalServerAddress); - const { sessionParameters } = this.findResumptionRecordByNodeId(peerNodeId) ?? {}; - const unsecureSession = this.sessionManager.createInsecureSession({ - // Use the session parameters from MDNS announcements when available and rest is assumed to be fallbacks - sessionParameters: { - idleIntervalMs: discoveryData?.SII ?? sessionParameters?.idleIntervalMs, - activeIntervalMs: discoveryData?.SAI ?? sessionParameters?.activeIntervalMs, - activeThresholdMs: discoveryData?.SAT ?? sessionParameters?.activeThresholdMs, - }, - isInitiator: true, - }); - const operationalUnsecureMessageExchange = new MessageChannel(operationalChannel, unsecureSession); - let operationalSecureSession; - try { - const exchange = this.exchangeManager.initiateExchangeWithChannel( - operationalUnsecureMessageExchange, - SECURE_CHANNEL_PROTOCOL_ID, - ); - - try { - operationalSecureSession = await this.caseClient.pair( - exchange, - this.fabric, - peerNodeId, - expectedProcessingTimeMs, - ); - } catch (e) { - await exchange.close(); - throw e; - } - } catch (e) { - NoResponseTimeoutError.accept(e); - - // Convert error - throw new PairRetransmissionLimitReachedError(e.message); - } - await unsecureSession.destroy(); - const channel = new MessageChannel(operationalChannel, operationalSecureSession); - await this.channelManager.setChannel(this.fabric, peerNodeId, channel); - return channel; - } - isCommissioned() { - return this.commissionedNodes.size > 0; + return this.peers.size > 0; } getCommissionedNodes() { - return Array.from(this.commissionedNodes.keys()); + return this.peers.map(peer => peer.address.nodeId); } getCommissionedNodesDetails() { - return Array.from(this.commissionedNodes.entries()).map( - ([nodeId, { operationalServerAddress, discoveryData, basicInformationData }]) => ({ - nodeId, - operationalAddress: operationalServerAddress - ? serverAddressToString(operationalServerAddress) - : undefined, + return this.peers.map(peer => { + const { address, operationalAddress, discoveryData, basicInformationData } = peer as CommissionedPeer; + return { + nodeId: address.nodeId, + operationalAddress: operationalAddress ? serverAddressToString(operationalAddress) : undefined, advertisedName: discoveryData?.DN, discoveryData, basicInformationData, - }), - ); - } - - private async setOperationalDeviceData( - nodeId: NodeId, - operationalServerAddress: ServerAddressIp, - discoveryData?: DiscoveryData, - ) { - const nodeDetails = this.commissionedNodes.get(nodeId) ?? {}; - nodeDetails.operationalServerAddress = operationalServerAddress; - if (discoveryData !== undefined) { - nodeDetails.discoveryData = { - ...nodeDetails.discoveryData, - ...discoveryData, }; - } - this.commissionedNodes.set(nodeId, nodeDetails); - await this.storeCommissionedNodes(); + }); } async enhanceCommissionedNodeDetails( nodeId: NodeId, data: { basicInformationData: Record }, ) { - const nodeDetails = this.commissionedNodes.get(nodeId); + const nodeDetails = this.peers.get(this.fabric.addressOf(nodeId)) as CommissionedPeer; if (nodeDetails === undefined) { throw new Error(`Node ${nodeId} is not commissioned.`); } const { basicInformationData } = data; nodeDetails.basicInformationData = basicInformationData; - this.commissionedNodes.set(nodeId, nodeDetails); - await this.storeCommissionedNodes(); - } - - private getLastOperationalAddress(nodeId: NodeId) { - return this.commissionedNodes.get(nodeId)?.operationalServerAddress; - } - - private async storeCommissionedNodes() { - await this.nodesStorage.set("commissionedNodes", Array.from(this.commissionedNodes.entries())); + await this.nodesStore.save(); } /** @@ -1034,59 +449,7 @@ export class MatterController { * Returns a InteractionClient on success. */ async connect(peerNodeId: NodeId, discoveryOptions: DiscoveryOptions) { - const { discoveryData } = discoveryOptions; - - let channel: MessageChannel; - try { - channel = this.channelManager.getChannel(this.fabric, peerNodeId); - } catch (error) { - NoChannelError.accept(error); - - channel = await this.resume(peerNodeId, discoveryOptions); - } - return new InteractionClient( - new ExchangeProvider(this.exchangeManager, channel, async () => { - if (!this.channelManager.hasChannel(this.fabric, peerNodeId)) { - throw new RetransmissionLimitReachedError(`Device ${peerNodeId} is currently not reachable.`); - } - await this.channelManager.removeAllNodeChannels(this.fabric, peerNodeId); - - const mdnsScanner = this.scanners.scannerFor(ChannelType.UDP) as MdnsScanner | undefined; - const discoveredAddresses = mdnsScanner?.getDiscoveredOperationalDevice(this.fabric, peerNodeId); - const lastKnownAddress = this.getLastOperationalAddress(peerNodeId); - - if ( - lastKnownAddress !== undefined && - discoveredAddresses !== undefined && - discoveredAddresses.addresses.some( - ({ ip, port }) => ip === lastKnownAddress.ip && port === lastKnownAddress.port, - ) - ) { - // We found the same address, so assume somehow cached response because we just tried to connect, - // and it failed, so clear list - discoveredAddresses.addresses.length = 0; - } - - // Try to use first result for one last try before we need to reconnect - const operationalAddress = discoveredAddresses?.addresses[0]; - if (operationalAddress === undefined) { - logger.info( - `Re-Discovering device failed (no address found), remove all sessions for node ${peerNodeId}`, - ); - // We remove all sessions, this also informs the PairedNode class - await this.sessionManager.removeAllSessionsForNode(peerNodeId); - throw new RetransmissionLimitReachedError(`No operational address found for node ${peerNodeId}`); - } - if ( - (await this.reconnectKnownAddress(peerNodeId, operationalAddress, discoveryData, 2_000)) === - undefined - ) { - throw new RetransmissionLimitReachedError(`Device ${peerNodeId} is not reachable.`); - } - return this.channelManager.getChannel(this.fabric, peerNodeId); - }), - peerNodeId, - ); + return this.peers.connect(this.fabric.addressOf(peerNodeId), discoveryOptions); } async getNextAvailableSessionId() { @@ -1098,7 +461,7 @@ export class MatterController { } findResumptionRecordByNodeId(nodeId: NodeId) { - return this.sessionManager.findResumptionRecordByNodeId(nodeId); + return this.sessionManager.findResumptionRecordByAddress(this.fabric.addressOf(nodeId)); } async saveResumptionRecord(resumptionRecord: ResumptionRecord) { @@ -1110,11 +473,7 @@ export class MatterController { } async close() { - const mdnsScanner = this.scanners.scannerFor(ChannelType.UDP) as MdnsScanner | undefined; - for (const [nodeId, { timer }] of this.#runningNodeDiscoveries.entries()) { - timer?.stop(); - mdnsScanner?.cancelOperationalDeviceDiscovery(this.fabric, nodeId, false); // This ends discovery without triggering promises - } + await this.peers.close(); await this.exchangeManager.close(); await this.sessionManager.close(); await this.channelManager.close(); @@ -1125,3 +484,57 @@ export class MatterController { return this.sessionManager.getActiveSessionInformation(); } } + +class CommissionedNodeStore extends PeerStore { + declare peers: PeerSet; + + constructor( + private nodesStorage: StorageContext, + private fabric: Fabric, + ) { + super(); + } + + async loadPeers() { + if (!(await this.nodesStorage.has("commissionedNodes"))) { + return []; + } + + const commissionedNodes = await this.nodesStorage.get("commissionedNodes"); + return commissionedNodes.map( + ([nodeId, { operationalServerAddress, discoveryData, basicInformationData }]) => + ({ + address: this.fabric.addressOf(nodeId), + operationalAddress: operationalServerAddress, + discoveryData, + basicInformationData, + }) satisfies CommissionedPeer, + ); + } + + async updatePeer() { + return this.save(); + } + + async deletePeer() { + return this.save(); + } + + async save() { + await this.nodesStorage.set( + "commissionedNodes", + this.peers.map(peer => { + const { + address, + operationalAddress: operationalServerAddress, + basicInformationData, + discoveryData, + } = peer as CommissionedPeer; + return [ + address.nodeId, + { operationalServerAddress, basicInformationData, discoveryData }, + ] satisfies StoredOperationalPeer; + }), + ); + } +} diff --git a/packages/matter.js/src/compat/protocol.ts b/packages/matter.js/src/compat/protocol.ts index 2e2184406d..3e830f287d 100644 --- a/packages/matter.js/src/compat/protocol.ts +++ b/packages/matter.js/src/compat/protocol.ts @@ -8,8 +8,7 @@ export { ChannelManager, ChannelNotConnectedError, CommissioningError, - CommissioningSuccessfullyFinished, - ControllerCommissioner, + ControllerCommissioningFlow as ControllerCommissioner, ControllerDiscovery, DiscoveryError, DuplicateMessageError, @@ -28,7 +27,7 @@ export { NoChannelError, PairRetransmissionLimitReachedError, UnexpectedMessageError, - type ControllerCommissioningOptions as CommissioningOptions, + type ControllingCommissioningFlowOptions as CommissioningOptions, type ExchangeSendOptions, type ProtocolHandler, } from "#protocol"; diff --git a/packages/matter.js/src/device/PairedNode.ts b/packages/matter.js/src/device/PairedNode.ts index 953beb9465..3ec411730a 100644 --- a/packages/matter.js/src/device/PairedNode.ts +++ b/packages/matter.js/src/device/PairedNode.ts @@ -14,7 +14,6 @@ import { MatterError, Time, } from "#general"; -import { NodeDiscoveryType } from "#MatterController.js"; import { AttributeClientValues, ChannelStatusResponseError, @@ -25,6 +24,7 @@ import { EndpointInterface, EndpointLoggingOptions, InteractionClient, + NodeDiscoveryType, PaseClient, logEndpoint, structureReadAttributeDataToClusterObject, diff --git a/packages/node/src/node/server/TransactionalInteractionServer.ts b/packages/node/src/node/server/TransactionalInteractionServer.ts index f38fe3cd08..76b872f0fa 100644 --- a/packages/node/src/node/server/TransactionalInteractionServer.ts +++ b/packages/node/src/node/server/TransactionalInteractionServer.ts @@ -79,8 +79,8 @@ export class TransactionalInteractionServer extends InteractionServer { structure, subscriptionOptions: endpoint.state.network.subscriptionOptions, maxPathsPerInvoke: endpoint.state.basicInformation.maxPathsPerInvoke, - initiateExchange: (fabric, nodeId, protocolId) => - endpoint.env.get(ExchangeManager).initiateExchange(fabric, nodeId, protocolId), + initiateExchange: (address, protocolId) => + endpoint.env.get(ExchangeManager).initiateExchange(address, protocolId), }); } diff --git a/packages/node/test/behavior/internal/ClusterServerBackingTest.ts b/packages/node/test/behavior/internal/ClusterServerBackingTest.ts index dff08a4a3b..8301f708be 100644 --- a/packages/node/test/behavior/internal/ClusterServerBackingTest.ts +++ b/packages/node/test/behavior/internal/ClusterServerBackingTest.ts @@ -31,7 +31,6 @@ import { ClusterId, CommandId, EndpointNumber, - FabricId, FabricIndex, NodeId, Status, @@ -301,9 +300,9 @@ describe("ClusterServerBacking", () => { let report: TypeFromSchema | undefined; // Mock ExchangeManager's "initiateExchange" method - node.env.get(ExchangeManager).initiateExchange = (fabric, nodeId) => { - expect(fabric.fabricId).equals(FabricId(1)); - expect(nodeId).equals(NodeId(0)); + node.env.get(ExchangeManager).initiateExchange = address => { + expect(address.fabricIndex).equals(FabricIndex(1)); + expect(address.nodeId).equals(NodeId(0)); return { async nextMessage() { diff --git a/packages/protocol/src/MatterDevice.ts b/packages/protocol/src/MatterDevice.ts index 1a5c2263e5..ae7c6a1054 100644 --- a/packages/protocol/src/MatterDevice.ts +++ b/packages/protocol/src/MatterDevice.ts @@ -19,9 +19,10 @@ import { TransportInterfaceSet, asyncNew, } from "#general"; +import { PeerAddress } from "#peer/PeerAddress.js"; import { DeviceAdvertiser } from "#protocol/DeviceAdvertiser.js"; import { CommissioningConfigProvider, DeviceCommissioner } from "#protocol/DeviceCommissioner.js"; -import { CommissioningOptions, FabricIndex, NodeId } from "#types"; +import { CommissioningOptions, FabricIndex } from "#types"; import { FailsafeContext } from "./common/FailsafeContext.js"; import { InstanceBroadcaster } from "./common/InstanceBroadcaster.js"; import { Fabric } from "./fabric/Fabric.js"; @@ -258,8 +259,8 @@ export class MatterDevice { return this.#fabricManager.findByIndex(fabricIndex); } - initiateExchange(fabric: Fabric, nodeId: NodeId, protocolId: number) { - return this.#exchangeManager.initiateExchange(fabric, nodeId, protocolId); + initiateExchange(address: PeerAddress, protocolId: number) { + return this.#exchangeManager.initiateExchange(address, protocolId); } getFabrics() { diff --git a/packages/protocol/src/certificate/CertificateManager.ts b/packages/protocol/src/certificate/CertificateManager.ts index a8aa4d6b26..4c0dfec352 100644 --- a/packages/protocol/src/certificate/CertificateManager.ts +++ b/packages/protocol/src/certificate/CertificateManager.ts @@ -577,8 +577,8 @@ function extensionsToAsn1(extensions: BaseCertificate["extensions"]) { return asn; } -export class CertificateManager { - static #assertCertificateDerSize(certBytes: Uint8Array) { +export namespace CertificateManager { + function assertCertificateDerSize(certBytes: Uint8Array) { if (certBytes.length > MAX_DER_CERTIFICATE_SIZE) { throw new ImplementationError( `Certificate to generate is too big: ${certBytes.length} bytes instead of max ${MAX_DER_CERTIFICATE_SIZE} bytes`, @@ -586,7 +586,7 @@ export class CertificateManager { } } - static #genericBuildAsn1Structure({ + function genericBuildAsn1Structure({ serialNumber, notBefore, notAfter, @@ -616,13 +616,13 @@ export class CertificateManager { }; } - static #genericCertToAsn1(cert: Unsigned) { - const certBytes = DerCodec.encode(this.#genericBuildAsn1Structure(cert)); - this.#assertCertificateDerSize(certBytes); + function genericCertToAsn1(cert: Unsigned) { + const certBytes = DerCodec.encode(genericBuildAsn1Structure(cert)); + assertCertificateDerSize(certBytes); return certBytes; } - static rootCertToAsn1(cert: Unsigned) { + export function rootCertToAsn1(cert: Unsigned) { const { extensions: { basicConstraints: { isCa }, @@ -631,10 +631,10 @@ export class CertificateManager { if (!isCa) { throw new CertificateError("Root certificate must be a CA."); } - return this.#genericCertToAsn1(cert); + return genericCertToAsn1(cert); } - static intermediateCaCertToAsn1(cert: Unsigned) { + export function intermediateCaCertToAsn1(cert: Unsigned) { const { extensions: { basicConstraints: { isCa }, @@ -643,10 +643,10 @@ export class CertificateManager { if (!isCa) { throw new CertificateError("Intermediate certificate must be a CA."); } - return this.#genericCertToAsn1(cert); + return genericCertToAsn1(cert); } - static nodeOperationalCertToAsn1(cert: Unsigned) { + export function nodeOperationalCertToAsn1(cert: Unsigned) { const { issuer: { icacId, rcacId }, extensions: { @@ -660,46 +660,49 @@ export class CertificateManager { throw new CertificateError("Node operational certificate must not be a CA."); } - return this.#genericCertToAsn1(cert); + return genericCertToAsn1(cert); } - static deviceAttestationCertToAsn1(cert: Unsigned, key: Key) { - const certificate = this.#genericBuildAsn1Structure(cert); + export function deviceAttestationCertToAsn1(cert: Unsigned, key: Key) { + const certificate = genericBuildAsn1Structure(cert); const certBytes = DerCodec.encode({ certificate, signAlgorithm: X962.EcdsaWithSHA256, signature: BitByteArray(Crypto.sign(key, DerCodec.encode(certificate), "der")), }); - this.#assertCertificateDerSize(certBytes); + assertCertificateDerSize(certBytes); return certBytes; } - static productAttestationIntermediateCertToAsn1( + export function productAttestationIntermediateCertToAsn1( cert: Unsigned, key: Key, ) { - const certificate = this.#genericBuildAsn1Structure(cert); + const certificate = genericBuildAsn1Structure(cert); const certBytes = DerCodec.encode({ certificate, signAlgorithm: X962.EcdsaWithSHA256, signature: BitByteArray(Crypto.sign(key, DerCodec.encode(certificate), "der")), }); - this.#assertCertificateDerSize(certBytes); + assertCertificateDerSize(certBytes); return certBytes; } - static productAttestationAuthorityCertToAsn1(cert: Unsigned, key: Key) { - const certificate = this.#genericBuildAsn1Structure(cert); + export function productAttestationAuthorityCertToAsn1( + cert: Unsigned, + key: Key, + ) { + const certificate = genericBuildAsn1Structure(cert); const certBytes = DerCodec.encode({ certificate, signAlgorithm: X962.EcdsaWithSHA256, signature: BitByteArray(Crypto.sign(key, DerCodec.encode(certificate), "der")), }); - this.#assertCertificateDerSize(certBytes); + assertCertificateDerSize(certBytes); return certBytes; } - static CertificationDeclarationToAsn1( + export function certificationDeclarationToAsn1( eContent: Uint8Array, subjectKeyIdentifier: Uint8Array, privateKey: JsonWebKey, @@ -720,7 +723,7 @@ export class CertificateManager { }; const certBytes = DerCodec.encode(Pkcs7.SignedData(certificate)); - this.#assertCertificateDerSize(certBytes); + assertCertificateDerSize(certBytes); return certBytes; } @@ -728,7 +731,9 @@ export class CertificateManager { * Validate general requirements a Matter certificate fields must fulfill. * Rules for this are listed in @see {@link MatterSpecification.v12.Core} §6.5.x */ - static validateGeneralCertificateFields(cert: RootCertificate | OperationalCertificate | IntermediateCertificate) { + export function validateGeneralCertificateFields( + cert: RootCertificate | OperationalCertificate | IntermediateCertificate, + ) { if (cert.serialNumber.length > 20) throw new CertificateError( `Serial number must not be longer then 20 octets. Current serial number has ${cert.serialNumber.length} octets.`, @@ -771,7 +776,7 @@ export class CertificateManager { * Verify requirements a Matter Root certificate must fulfill. * Rules for this are listed in @see {@link MatterSpecification.v12.Core} §6.5.x */ - static verifyRootCertificate(rootCert: RootCertificate) { + export function verifyRootCertificate(rootCert: RootCertificate) { CertificateManager.validateGeneralCertificateFields(rootCert); // The subject DN SHALL NOT encode any matter-node-id attribute. @@ -850,14 +855,14 @@ export class CertificateManager { ); } - Crypto.verify(PublicKey(rootCert.ellipticCurvePublicKey), this.rootCertToAsn1(rootCert), rootCert.signature); + Crypto.verify(PublicKey(rootCert.ellipticCurvePublicKey), rootCertToAsn1(rootCert), rootCert.signature); } /** * Verify requirements a Matter Node Operational certificate must fulfill. * Rules for this are listed in @see {@link MatterSpecification.v12.Core} §6.5.x */ - static verifyNodeOperationalCertificate( + export function verifyNodeOperationalCertificate( rootOrIcaCert: RootCertificate | IntermediateCertificate, nocCert: OperationalCertificate, ) { @@ -962,7 +967,7 @@ export class CertificateManager { Crypto.verify( PublicKey(rootOrIcaCert.ellipticCurvePublicKey), - this.nodeOperationalCertToAsn1(nocCert), + nodeOperationalCertToAsn1(nocCert), nocCert.signature, ); } @@ -971,7 +976,7 @@ export class CertificateManager { * Verify requirements a Matter Intermediate CA certificate must fulfill. * Rules for this are listed in @see {@link MatterSpecification.v12.Core} §6.5.x */ - static verifyIntermediateCaCertificate(rootCert: RootCertificate, icaCert: IntermediateCertificate) { + export function verifyIntermediateCaCertificate(rootCert: RootCertificate, icaCert: IntermediateCertificate) { CertificateManager.validateGeneralCertificateFields(icaCert); // The subject DN SHALL NOT encode any matter-node-id attribute. @@ -1078,14 +1083,10 @@ export class CertificateManager { ); } - Crypto.verify( - PublicKey(rootCert.ellipticCurvePublicKey), - this.intermediateCaCertToAsn1(icaCert), - icaCert.signature, - ); + Crypto.verify(PublicKey(rootCert.ellipticCurvePublicKey), intermediateCaCertToAsn1(icaCert), icaCert.signature); } - static createCertificateSigningRequest(key: Key) { + export function createCertificateSigningRequest(key: Key) { const request = { version: 0, subject: { organization: X520.OrganisationName("CSR") }, @@ -1100,7 +1101,7 @@ export class CertificateManager { }); } - static getPublicKeyFromCsr(csr: Uint8Array) { + export function getPublicKeyFromCsr(csr: Uint8Array) { const { [DerKey.Elements]: rootElements } = DerCodec.decode(csr); if (rootElements?.length !== 3) throw new CertificateError("Invalid CSR data"); const [requestNode, signAlgorithmNode, signatureNode] = rootElements; diff --git a/packages/protocol/src/certificate/CertificationDeclarationManager.ts b/packages/protocol/src/certificate/CertificationDeclarationManager.ts index 4c593bda55..b97dfad6f0 100644 --- a/packages/protocol/src/certificate/CertificationDeclarationManager.ts +++ b/packages/protocol/src/certificate/CertificationDeclarationManager.ts @@ -43,7 +43,7 @@ export class CertificationDeclarationManager { certificationType: provisional ? 1 : 0, // 0 = Test, 1 = Provisional/In certification, 2 = official }); - return CertificateManager.CertificationDeclarationToAsn1( + return CertificateManager.certificationDeclarationToAsn1( certificationElements, TestCMS_SignerSubjectKeyIdentifier, PrivateKey(TestCMS_SignerPrivateKey), diff --git a/packages/protocol/src/certificate/RootCertificateManager.ts b/packages/protocol/src/certificate/RootCertificateManager.ts index c42bf79fe2..453f618dc3 100644 --- a/packages/protocol/src/certificate/RootCertificateManager.ts +++ b/packages/protocol/src/certificate/RootCertificateManager.ts @@ -9,9 +9,12 @@ import { Bytes, Construction, Crypto, + Environment, + Environmental, Logger, PrivateKey, StorageContext, + StorageManager, Time, asyncNew, toHex, @@ -80,6 +83,13 @@ export class RootCertificateManager { }); } + [Environmental.create](env: Environment) { + const storage = env.get(StorageManager).createContext("certificates"); + const instance = new RootCertificateManager(storage); + env.set(RootCertificateManager, instance); + return instance; + } + get rootCert() { return this.rootCertBytes; } diff --git a/packages/protocol/src/common/FailsafeContext.ts b/packages/protocol/src/common/FailsafeContext.ts index 12e1dcdb66..450d4cb38a 100644 --- a/packages/protocol/src/common/FailsafeContext.ts +++ b/packages/protocol/src/common/FailsafeContext.ts @@ -266,7 +266,7 @@ export abstract class FailsafeContext { const fabricIndex = this.fabricIndex; fabric = this.#fabrics.getFabrics().find(fabric => fabric.fabricIndex === fabricIndex); if (fabric !== undefined) { - const session = this.#sessions.getSessionForNode(fabric, fabric.rootNodeId); + const session = this.#sessions.getSessionForNode(fabric.addressOf(fabric.rootNodeId)); if (session !== undefined && session.isSecure) { await session.close(false); } diff --git a/packages/protocol/src/common/Scanner.ts b/packages/protocol/src/common/Scanner.ts index e872b332b1..d7874de017 100644 --- a/packages/protocol/src/common/Scanner.ts +++ b/packages/protocol/src/common/Scanner.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BasicSet, ChannelType, ServerAddress, ServerAddressIp } from "#general"; -import { NodeId, VendorId } from "#types"; +import { BasicSet, ChannelType, Environment, Environmental, ServerAddress, ServerAddressIp } from "#general"; +import { DiscoveryCapabilitiesBitmap, NodeId, TypeFromPartialBitSchema, VendorId } from "#types"; import { Fabric } from "../fabric/Fabric.js"; /** @@ -165,4 +165,21 @@ export class ScannerSet extends BasicSet { hasScannerFor(type: ChannelType) { return this.scannerFor(type) !== undefined; } + + /** + * Select a set of scanners based on discovery capabilities. + */ + public select(discoveryCapabilities?: TypeFromPartialBitSchema) { + // Note we always scan via MDNS if available + return this.filter( + scanner => + scanner.type === ChannelType.UDP || (discoveryCapabilities?.ble && scanner.type === ChannelType.BLE), + ); + } + + [Environmental.create](env: Environment) { + const instance = new ScannerSet(); + env.set(ScannerSet, instance); + return instance; + } } diff --git a/packages/protocol/src/common/index.ts b/packages/protocol/src/common/index.ts index a8390b35a0..7b0aae5f6a 100644 --- a/packages/protocol/src/common/index.ts +++ b/packages/protocol/src/common/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from "../peer/PeerAddress.js"; export * from "./FailsafeContext.js"; export * from "./FailsafeTimer.js"; export * from "./InstanceBroadcaster.js"; diff --git a/packages/protocol/src/fabric/Fabric.ts b/packages/protocol/src/fabric/Fabric.ts index cf99a16c56..99311b7b04 100644 --- a/packages/protocol/src/fabric/Fabric.ts +++ b/packages/protocol/src/fabric/Fabric.ts @@ -4,6 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + CertificateManager, + TlvIntermediateCertificate, + TlvOperationalCertificate, + TlvRootCertificate, +} from "#certificate/CertificateManager.js"; import { GroupKeyManagement } from "#clusters/group-key-management"; import { BinaryKeyPair, @@ -20,13 +26,8 @@ import { PrivateKey, SupportedStorageTypes, } from "#general"; +import { PeerAddress } from "#peer/PeerAddress.js"; import { CaseAuthenticatedTag, Cluster, FabricId, FabricIndex, NodeId, TypeFromSchema, VendorId } from "#types"; -import { - CertificateManager, - TlvIntermediateCertificate, - TlvOperationalCertificate, - TlvRootCertificate, -} from "../certificate/CertificateManager.js"; import { SecureSession } from "../session/SecureSession.js"; const logger = Logger.get("Fabric"); @@ -347,6 +348,10 @@ export class Fabric { label: this.label, }; } + + addressOf(nodeId: NodeId) { + return PeerAddress({ fabricIndex: this.fabricIndex, nodeId }); + } } export class FabricBuilder { diff --git a/packages/protocol/src/fabric/FabricManager.ts b/packages/protocol/src/fabric/FabricManager.ts index 57a10b77f9..8686765419 100644 --- a/packages/protocol/src/fabric/FabricManager.ts +++ b/packages/protocol/src/fabric/FabricManager.ts @@ -19,6 +19,7 @@ import { StorageContext, StorageManager, } from "#general"; +import { PeerAddress } from "#peer/PeerAddress.js"; import { FabricIndex } from "#types"; import { Fabric, FabricJsonObject } from "./Fabric.js"; @@ -90,6 +91,17 @@ export class FabricManager { return this.#events; } + for(address: FabricIndex | PeerAddress) { + if (typeof address === "object") { + address = address.fabricIndex; + } + const fabric = this.#fabrics.get(address); + if (fabric === undefined) { + throw new FabricNotFoundError(`Cannot access fabric for unknown index ${address}`); + } + return fabric; + } + getNextFabricIndex() { this.#construction.assert(); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 872b5a4440..e7ed702d54 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -14,6 +14,7 @@ export * from "./fabric/index.js"; export * from "./interaction/index.js"; export * from "./MatterDevice.js"; export * from "./mdns/index.js"; +export * from "./peer/index.js"; export * from "./protocol/index.js"; export * from "./securechannel/index.js"; export * from "./session/index.js"; diff --git a/packages/protocol/src/interaction/InteractionClient.ts b/packages/protocol/src/interaction/InteractionClient.ts index d9a7b05866..c416ea39bf 100644 --- a/packages/protocol/src/interaction/InteractionClient.ts +++ b/packages/protocol/src/interaction/InteractionClient.ts @@ -6,6 +6,7 @@ import { ImplementationError, Logger, MatterFlowError, Time, Timer, UnexpectedDataError } from "#general"; import { Specification } from "#model"; +import { PeerAddress } from "#peer/PeerAddress.js"; import { Attribute, AttributeId, @@ -130,7 +131,7 @@ export class InteractionClient { constructor( private readonly exchangeProvider: ExchangeProvider, - readonly nodeId: NodeId, + readonly address: PeerAddress, ) { if (this.exchangeProvider.hasProtocolHandler(INTERACTION_PROTOCOL_ID)) { const client = this.exchangeProvider.getProtocolHandler(INTERACTION_PROTOCOL_ID); diff --git a/packages/protocol/src/interaction/InteractionServer.ts b/packages/protocol/src/interaction/InteractionServer.ts index e5a3186f01..0c6609b970 100644 --- a/packages/protocol/src/interaction/InteractionServer.ts +++ b/packages/protocol/src/interaction/InteractionServer.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Fabric } from "#fabric/Fabric.js"; import { Crypto, Diagnostic, InternalError, Logger, MatterFlowError } from "#general"; import { AttributeModel, ClusterModel, CommandModel, GLOBAL_IDS, MatterModel, Specification } from "#model"; +import { PeerAddress } from "#peer/PeerAddress.js"; import { SessionManager } from "#session/SessionManager.js"; import { ArraySchema, @@ -219,7 +219,7 @@ export interface InteractionContext { readonly structure: InteractionEndpointStructure; readonly subscriptionOptions?: Partial; readonly maxPathsPerInvoke?: number; - initiateExchange(fabric: Fabric, nodeId: NodeId, protocolId: number): MessageExchange; + initiateExchange(address: PeerAddress, protocolId: number): MessageExchange; } /** @@ -1043,8 +1043,7 @@ export class InteractionServer implements ProtocolHandler, InteractionRecipient this.#endpointStructure.getEndpoint(path.endpointId)!, ), - initiateExchange: (fabric, nodeId, protocolId) => - this.#context.initiateExchange(fabric, nodeId, protocolId), + initiateExchange: (address: PeerAddress, protocolId) => this.#context.initiateExchange(address, protocolId), }; const subscription = new ServerSubscription({ diff --git a/packages/protocol/src/interaction/ServerSubscription.ts b/packages/protocol/src/interaction/ServerSubscription.ts index 030d237bd9..c209b507aa 100644 --- a/packages/protocol/src/interaction/ServerSubscription.ts +++ b/packages/protocol/src/interaction/ServerSubscription.ts @@ -15,12 +15,12 @@ import { isObject, } from "#general"; import { Specification } from "#model"; +import { PeerAddress } from "#peer/PeerAddress.js"; import type { MessageExchange } from "#protocol/MessageExchange.js"; import { SecureSession } from "#session/SecureSession.js"; import { EventNumber, INTERACTION_PROTOCOL_ID, - NodeId, StatusCode, StatusResponseError, TlvAttributePath, @@ -33,7 +33,6 @@ import { } from "#types"; import { AnyAttributeServer, FabricScopedAttributeServer } from "../cluster/server/AttributeServer.js"; import { AnyEventServer, FabricSensitiveEventServer } from "../cluster/server/EventServer.js"; -import { type Fabric } from "../fabric/Fabric.js"; import { NoChannelError } from "../protocol/ChannelManager.js"; import { AttributeReportPayload, EventReportPayload } from "./AttributeDataEncoder.js"; import { EventStorageData } from "./EventHandler.js"; @@ -138,7 +137,7 @@ export interface ServerSubscriptionContext { event: AnyEventServer, eventFilters: TypeFromSchema[] | undefined, ): Promise[]>; - initiateExchange(fabric: Fabric, nodeId: NodeId, protocolId: number): MessageExchange; + initiateExchange(address: PeerAddress, protocolId: number): MessageExchange; } /** @@ -172,8 +171,7 @@ export class ServerSubscription extends Subscription { readonly #sendIntervalMs: number; private readonly minIntervalFloorMs: number; private readonly maxIntervalCeilingMs: number; - private readonly fabric: Fabric; - private readonly peerNodeId: NodeId; + private readonly peerAddress: PeerAddress; private sendingUpdateInProgress = false; private sendNextUpdateImmediately = false; @@ -194,8 +192,7 @@ export class ServerSubscription extends Subscription { this.#context = context; this.#structure = context.structure; - this.fabric = this.session.associatedFabric; - this.peerNodeId = this.session.peerNodeId; + this.peerAddress = this.session.peerAddress; this.minIntervalFloorMs = minIntervalFloor * 1000; this.maxIntervalCeilingMs = maxIntervalCeiling * 1000; @@ -839,7 +836,7 @@ export class ServerSubscription extends Subscription { logger.debug( `Sending subscription update message for ID ${this.id} with ${attributes.length} attributes and ${events.length} events`, ); - const exchange = this.#context.initiateExchange(this.fabric, this.peerNodeId, INTERACTION_PROTOCOL_ID); + const exchange = this.#context.initiateExchange(this.peerAddress, INTERACTION_PROTOCOL_ID); if (exchange === undefined) return; logger.debug( `Sending subscription changes for ID ${this.id}: ${attributes diff --git a/packages/protocol/src/peer/ControllerCommissioner.ts b/packages/protocol/src/peer/ControllerCommissioner.ts new file mode 100644 index 0000000000..db7a373ca6 --- /dev/null +++ b/packages/protocol/src/peer/ControllerCommissioner.ts @@ -0,0 +1,372 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RootCertificateManager } from "#certificate/RootCertificateManager.js"; +import { GeneralCommissioning } from "#clusters/general-commissioning"; +import { CommissionableDevice, CommissionableDeviceIdentifiers, DiscoveryData, ScannerSet } from "#common/Scanner.js"; +import { Fabric } from "#fabric/Fabric.js"; +import { + Channel, + ChannelType, + Environment, + Environmental, + isIPv6, + Logger, + NetInterfaceSet, + NoResponseTimeoutError, + ServerAddress, +} from "#general"; +import { MdnsScanner } from "#mdns/MdnsScanner.js"; +import { ControllerCommissioningFlow, ControllingCommissioningFlowOptions } from "#peer/ControllerCommissioningFlow.js"; +import { ControllerDiscovery, PairRetransmissionLimitReachedError } from "#peer/ControllerDiscovery.js"; +import { ExchangeManager, ExchangeProvider, MessageChannel } from "#protocol/ExchangeManager.js"; +import { PaseClient } from "#session/index.js"; +import { SessionManager } from "#session/SessionManager.js"; +import { DiscoveryCapabilitiesBitmap, NodeId, SECURE_CHANNEL_PROTOCOL_ID, TypeFromPartialBitSchema } from "#types"; +import { InteractionClient } from "../interaction/InteractionClient.js"; +import { PeerAddress } from "./PeerAddress.js"; +import { NodeDiscoveryType, PeerSet } from "./PeerSet.js"; + +const logger = Logger.get("PeerCommissioner"); + +/** + * Options needed to commission a new node + */ +export interface PeerCommissioningOptions extends Partial { + /** The fabric into which to commission. */ + fabric: Fabric; + + /** The node ID to assign (the commissioner assigns a random node ID if omitted) */ + nodeId?: NodeId; + + /** Discovery related options. */ + discovery: ( + | { + /** + * Device identifiers (Short or Long Discriminator, Product/Vendor-Ids, Device-type or a pre-discovered + * instance Id, or "nothing" to discover all commissionable matter devices) to use for discovery. + * If the property commissionableDevice is provided this property is ignored. + */ + identifierData: CommissionableDeviceIdentifiers; + } + | { + /** + * Commissionable device object returned by a discovery run. + * If this property is provided then identifierData and knownAddress are ignored. + */ + commissionableDevice: CommissionableDevice; + } + ) & { + /** + * Discovery capabilities to use for discovery. These are included in the QR code normally and defined if BLE + * is supported for initial commissioning. + */ + discoveryCapabilities?: TypeFromPartialBitSchema; + + /** + * Known address of the device to use for discovery. if this is set this will be tried first before discovering + * the device. + */ + knownAddress?: ServerAddress; + + /** Timeout in seconds for the discovery process. Default: 30 seconds */ + timeoutSeconds?: number; + }; + + /** Passcode to use for commissioning. */ + passcode: number; + + /** + * Commissioning completion callback + * + * This optional callback allows the caller to complete commissioning once PASE commissioning completes. If it does + * not throw, the commissioner considers commissioning complete. + */ + performCaseCommissioning?: (peerAddress: PeerAddress, discoveryData?: DiscoveryData) => Promise; +} + +/** + * Interfaces {@link ControllerCommissioner} with other components. + */ +export interface ControllerCommissionerContext { + peers: PeerSet; + scanners: ScannerSet; + netInterfaces: NetInterfaceSet; + sessions: SessionManager; + exchanges: ExchangeManager; + certificates: RootCertificateManager; +} + +/** + * Commissions other nodes onto a fabric. + */ +export class ControllerCommissioner { + #context: ControllerCommissionerContext; + #paseClient: PaseClient; + + constructor(context: ControllerCommissionerContext) { + this.#context = context; + this.#paseClient = new PaseClient(context.sessions); + } + + [Environmental.create](env: Environment) { + const instance = new ControllerCommissioner({ + peers: env.get(PeerSet), + scanners: env.get(ScannerSet), + netInterfaces: env.get(NetInterfaceSet), + sessions: env.get(SessionManager), + exchanges: env.get(ExchangeManager), + certificates: env.get(RootCertificateManager), + }); + env.set(ControllerCommissioner, instance); + return instance; + } + + async commission(options: PeerCommissioningOptions): Promise { + const { + discovery: { timeoutSeconds = 30 }, + passcode, + } = options; + + const commissioningOptions = { + regulatoryLocation: GeneralCommissioning.RegulatoryLocationType.Outdoor, // Set to the most restrictive if relevant + regulatoryCountryCode: "XX", + ...options, + }; + + const commissionableDevice = + "commissionableDevice" in options.discovery ? options.discovery.commissionableDevice : undefined; + let { + discovery: { discoveryCapabilities = {}, knownAddress }, + } = options; + let identifierData = "identifierData" in options.discovery ? options.discovery.identifierData : {}; + + if ( + this.#context.scanners.hasScannerFor(ChannelType.UDP) && + this.#context.netInterfaces.hasInterfaceFor(ChannelType.UDP, "::") !== undefined + ) { + discoveryCapabilities.onIpNetwork = true; // We always discover on network as defined by specs + } + if (commissionableDevice !== undefined) { + let { addresses } = commissionableDevice; + if (discoveryCapabilities.ble === true) { + discoveryCapabilities = { onIpNetwork: true, ble: addresses.some(address => address.type === "ble") }; + } else if (discoveryCapabilities.onIpNetwork === true) { + // do not use BLE if not specified, even if existing + addresses = addresses.filter(address => address.type !== "ble"); + } + addresses.sort(a => (a.type === "udp" ? -1 : 1)); // Sort addresses to use UDP first + knownAddress = addresses[0]; + if ("instanceId" in commissionableDevice && commissionableDevice.instanceId !== undefined) { + // it is an UDP discovery + identifierData = { instanceId: commissionableDevice.instanceId as string }; + } else { + identifierData = { longDiscriminator: commissionableDevice.D }; + } + } + + const scannersToUse = this.#context.scanners.select(discoveryCapabilities); + + logger.info( + `Commissioning device with identifier ${Logger.toJSON(identifierData)} and ${ + scannersToUse.length + } scanners and knownAddress ${Logger.toJSON(knownAddress)}`, + ); + + // If we have a known address we try this first before we discover the device + let paseSecureChannel: MessageChannel | undefined; + let discoveryData: DiscoveryData | undefined; + + // If we have a last known address, try this first + if (knownAddress !== undefined) { + try { + paseSecureChannel = await this.#initializePaseSecureChannel(knownAddress, passcode); + } catch (error) { + NoResponseTimeoutError.accept(error); + } + } + if (paseSecureChannel === undefined) { + const discoveredDevices = await ControllerDiscovery.discoverDeviceAddressesByIdentifier( + scannersToUse, + identifierData, + timeoutSeconds, + ); + + const { result } = await ControllerDiscovery.iterateServerAddresses( + discoveredDevices, + NoResponseTimeoutError, + async () => + scannersToUse.flatMap(scanner => scanner.getDiscoveredCommissionableDevices(identifierData)), + async (address, device) => { + const channel = await this.#initializePaseSecureChannel(address, passcode, device); + discoveryData = device; + return channel; + }, + ); + + // Pairing was successful, so store the address and assign the established secure channel + paseSecureChannel = result; + } + + return await this.#commissionDiscoveredNode(paseSecureChannel, commissioningOptions, discoveryData); + } + + /** + * Method to start commission process with a PASE pairing. + * If this not successful and throws an RetransmissionLimitReachedError the address is invalid or the passcode + * is wrong. + */ + async #initializePaseSecureChannel( + address: ServerAddress, + passcode: number, + device?: CommissionableDevice, + ): Promise { + let paseChannel: Channel; + if (device !== undefined) { + logger.info(`Commissioning device`, MdnsScanner.discoveryDataDiagnostics(device)); + } + if (address.type === "udp") { + const { ip } = address; + + const isIpv6Address = isIPv6(ip); + const paseInterface = this.#context.netInterfaces.interfaceFor( + ChannelType.UDP, + isIpv6Address ? "::" : "0.0.0.0", + ); + if (paseInterface === undefined) { + // mainly IPv6 address when IPv4 is disabled + throw new PairRetransmissionLimitReachedError( + `IPv${isIpv6Address ? "6" : "4"} interface not initialized. Cannot use ${ip} for commissioning.`, + ); + } + paseChannel = await paseInterface.openChannel(address); + } else { + const ble = this.#context.netInterfaces.interfaceFor(ChannelType.BLE); + if (!ble) { + throw new PairRetransmissionLimitReachedError( + `BLE interface not initialized. Cannot use ${address.peripheralAddress} for commissioning.`, + ); + } + // TODO Have a Timeout mechanism here for connections + paseChannel = await ble.openChannel(address); + } + + // Do PASE paring + const unsecureSession = this.#context.sessions.createInsecureSession({ + // Use the session parameters from MDNS announcements when available and rest is assumed to be fallbacks + sessionParameters: { + idleIntervalMs: device?.SII, + activeIntervalMs: device?.SAI, + activeThresholdMs: device?.SAT, + }, + isInitiator: true, + }); + const paseUnsecureMessageChannel = new MessageChannel(paseChannel, unsecureSession); + const paseExchange = this.#context.exchanges.initiateExchangeWithChannel( + paseUnsecureMessageChannel, + SECURE_CHANNEL_PROTOCOL_ID, + ); + + let paseSecureSession; + try { + paseSecureSession = await this.#paseClient.pair( + this.#context.sessions.sessionParameters, + paseExchange, + passcode, + ); + } catch (e) { + // Close the exchange and rethrow + await paseExchange.close(); + throw e; + } + + await unsecureSession.destroy(); + return new MessageChannel(paseChannel, paseSecureSession); + } + + /** + * Method to commission a device with a PASE secure channel. It returns the NodeId of the commissioned device on + * success. + */ + async #commissionDiscoveredNode( + paseSecureMessageChannel: MessageChannel, + commissioningOptions: PeerCommissioningOptions & ControllingCommissioningFlowOptions, + discoveryData?: DiscoveryData, + ): Promise { + const { fabric, performCaseCommissioning } = commissioningOptions; + + // TODO: Create the fabric only when needed before commissioning (to do when refactoring MatterController away) + // TODO also move certificateManager and other parts into that class to get rid of them here + // TODO Depending on the Error type during commissioning we can do a retry ... + /* + Whenever the Fail-Safe timer is armed, Commissioners and Administrators SHALL NOT consider any cluster + operation to have timed-out before waiting at least 30 seconds for a valid response from the cluster server. + Some commands and attributes with complex side-effects MAY require longer and have specific timing requirements + stated in their respective cluster specification. + + In concurrent connection commissioning flow, the failure of any of the steps 2 through 10 SHALL result in the + Commissioner and Commissionee returning to step 2 (device discovery and commissioning channel establishment) and + repeating each step. The failure of any of the steps 11 through 15 in concurrent connection commissioning flow + SHALL result in the Commissioner and Commissionee returning to step 11 (configuration of operational network + information). In the case of failure of any of the steps 11 through 15 in concurrent connection commissioning + flow, the Commissioner and Commissionee SHALL reuse the existing PASE-derived encryption keys over the + commissioning channel and all steps up to and including step 10 are considered to have been successfully + completed. + In non-concurrent connection commissioning flow, the failure of any of the steps 2 through 15 SHALL result in + the Commissioner and Commissionee returning to step 2 (device discovery and commissioning channel establishment) + and repeating each step. + + Commissioners that need to restart from step 2 MAY immediately expire the fail-safe by invoking the ArmFailSafe + command with an ExpiryLengthSeconds field set to 0. Otherwise, Commissioners will need to wait until the current + fail-safe timer has expired for the Commissionee to begin accepting PASE again. + In both concurrent connection commissioning flow and non-concurrent connection commissioning flow, the + Commissionee SHALL exit Commissioning Mode after 20 failed attempts. + */ + + const address = fabric.addressOf(commissioningOptions.nodeId ?? NodeId.randomOperationalNodeId()); + const commissioningManager = new ControllerCommissioningFlow( + // Use the created secure session to do the commissioning + new InteractionClient(new ExchangeProvider(this.#context.exchanges, paseSecureMessageChannel), address), + this.#context.certificates, + fabric, + commissioningOptions, + async address => { + // TODO Right now we always close after step 12 because we do not check for commissioning flow requirements + /* + In concurrent connection commissioning flow the commissioning channel SHALL terminate after + successful step 15 (CommissioningComplete command invocation). In non-concurrent connection + commissioning flow the commissioning channel SHALL terminate after successful step 12 (trigger + joining of operational network at Commissionee). The PASE-derived encryption keys SHALL be deleted + when commissioning channel terminates. The PASE session SHALL be terminated by both Commissioner and + Commissionee once the CommissioningComplete command is received by the Commissionee. + */ + await paseSecureMessageChannel.close(); // We reconnect using Case, so close PASE connection + + if (performCaseCommissioning !== undefined) { + await performCaseCommissioning(address, discoveryData); + return; + } + + // Look for the device broadcast over MDNS and do CASE pairing + return await this.#context.peers.connect(address, { + discoveryType: NodeDiscoveryType.TimedDiscovery, + timeoutSeconds: 120, + discoveryData, + }); // Wait maximum 120s to find the operational device for commissioning process + }, + ); + + try { + await commissioningManager.executeCommissioning(); + } catch (error) { + // We might have added data for an operational address that we need to cleanup + await this.#context.peers.delete(address); + throw error; + } + + return address; + } +} diff --git a/packages/protocol/src/protocol/ControllerCommissioner.ts b/packages/protocol/src/peer/ControllerCommissioningFlow.ts similarity index 71% rename from packages/protocol/src/protocol/ControllerCommissioner.ts rename to packages/protocol/src/peer/ControllerCommissioningFlow.ts index 4cb12e6b6b..f996c99d15 100644 --- a/packages/protocol/src/protocol/ControllerCommissioner.ts +++ b/packages/protocol/src/peer/ControllerCommissioningFlow.ts @@ -15,7 +15,6 @@ import { ClusterId, ClusterType, EndpointNumber, - NodeId, StatusResponseError, TypeFromPartialBitSchema, TypeFromSchema, @@ -28,31 +27,44 @@ import { ClusterClientObj } from "../cluster/client/ClusterClientTypes.js"; import { TlvCertSigningRequest } from "../common/OperationalCredentialsTypes.js"; import { Fabric } from "../fabric/Fabric.js"; import { InteractionClient } from "../interaction/InteractionClient.js"; +import { PeerAddress } from "./PeerAddress.js"; const logger = Logger.get("ControllerCommissioner"); /** * User specific options for the Commissioning process */ -export type ControllerCommissioningOptions = { - /** Regulatory Location (Indoor/Outdoor) where the device is used. */ +export type ControllingCommissioningFlowOptions = { + /** + * The regulatory location (indoor or outdoor) where the device is used. + */ regulatoryLocation: GeneralCommissioning.RegulatoryLocationType; - /** Country Code where the device is used. */ + /** + * The country where the device is used. + */ regulatoryCountryCode: string; - /** Wifi network credentials to commission the device to. */ + /** + * The vendor ID we present as a commissioner. + */ + adminVendorId?: VendorId; + + /** + * Required credentials if the device is to connect to a wifi network provide the credentials here. + */ wifiNetwork?: { wifiSsid: string; wifiCredentials: string; }; - /** Thread network credentials to commission the device to. */ + /** + * If the device should connect to a thread network. + */ threadNetwork?: { networkName: string; operationalDataset: string; }; - nodeId?: NodeId; }; /** Types representation of a general commissioning response. */ @@ -66,6 +78,7 @@ enum CommissioningStepResultCode { Success, Failure, Skipped, + Stop, } /** @@ -118,70 +131,149 @@ export class CommissioningError extends MatterError {} /** Error that throws when Commissioning fails but process can be continued. */ class RecoverableCommissioningError extends CommissioningError {} +const DEFAULT_FAILSAFE_TIME_MS = 60_000; // 60 seconds + /** - * Special Error instance used to detect if the commissioning was successfully finished externally and the device is - * now operational. + * The operative connection callback may return this value to skip PASE commissioning. */ -export class CommissioningSuccessfullyFinished extends MatterError {} - -const DEFAULT_FAILSAFE_TIME_MS = 60_000; // 60 seconds +export const SKIP_CASE_COMMISSIONING = Symbol("skip-pase-commissioning"); +export type SKIP_PASE_COMMISSIONING = typeof SKIP_CASE_COMMISSIONING; /** * Class to abstract the Device commission flow in a step wise way as defined in Specs. The specs are not 100% */ -export class ControllerCommissioner { - private readonly commissioningSteps = new Array(); - private readonly commissioningStepResults = new Map(); - private readonly clusterClients = new Map(); - private commissioningStartedTime: number | undefined; - private commissioningExpiryTime: number | undefined; - private lastFailSafeTime: number | undefined; - private lastBreadcrumb = 1; - private collectedCommissioningData: CollectedCommissioningData = {}; - private failSafeTimeMs = DEFAULT_FAILSAFE_TIME_MS; +export class ControllerCommissioningFlow { + #interactionClient: InteractionClient; + readonly #certificateManager: RootCertificateManager; + readonly #fabric: Fabric; + readonly #transitionToCase: (peerAddress: PeerAddress) => Promise; + readonly #commissioningOptions: ControllingCommissioningFlowOptions; + readonly #commissioningSteps = new Array(); + readonly #commissioningStepResults = new Map(); + readonly #clusterClients = new Map(); + #commissioningStartedTime: number | undefined; + #commissioningExpiryTime: number | undefined; + #lastFailSafeTime: number | undefined; + #lastBreadcrumb = 1; + #collectedCommissioningData: CollectedCommissioningData = {}; + #failSafeTimeMs = DEFAULT_FAILSAFE_TIME_MS; constructor( /** InteractionClient for the initiated PASE session */ - private interactionClient: InteractionClient, + interactionClient: InteractionClient, /** CertificateManager of the controller. */ - private readonly certificateManager: RootCertificateManager, + certificateManager: RootCertificateManager, /** Fabric of the controller. */ - private readonly fabric: Fabric, + fabric: Fabric, /** Commissioning options for the commissioning process. */ - private readonly commissioningOptions: ControllerCommissioningOptions, + commissioningOptions: ControllingCommissioningFlowOptions, - /** NodeId to assign to the device to commission. */ - private readonly nodeId: NodeId, - - /** Administrator/Controller VendorId */ - private readonly adminVendorId: VendorId, - - /** - * Callback to operative discover and connect to the device and establish a CASE session with the device. - * The callback should return an InteractionClient to use to finish the commissioning process, or throw one of the following errors: - * * CommissioningSuccessfullyFinished: This special error can be used to notify that the commissioning completion was done by own logic and the device is now operational. The commissioner will not do any further steps. - * * CommissioningError: This error will stop the commissioning process in an error state. - * * other errors: Any other error will be logged and the commissioning process should be restarted. - */ - private readonly doOperativeDeviceConnectionCallback: () => Promise, + /** Callback that establishes CASE connection or handles final commissioning */ + transitionToCase: (peerAddress: PeerAddress) => Promise, ) { + this.#interactionClient = interactionClient; + this.#certificateManager = certificateManager; + this.#fabric = fabric; + this.#transitionToCase = transitionToCase; + this.#commissioningOptions = commissioningOptions; logger.debug(`Commissioning options: ${Logger.toJSON(commissioningOptions)}`); - this.initializeCommissioningSteps(); + this.#initializeCommissioningSteps(); + } + + /** + * Execute the commissioning process in the defined order. The steps are sorted before execution based on the step + * number and sub step number. + * If >50% of the failsafe time has passed, the failsafe timer is re-armed (50% of 60s default are 30s and each + * action is allowed to take 30s at minimum based on specs). + */ + async executeCommissioning() { + this.#sortSteps(); + + for (const step of this.#commissioningSteps) { + logger.info(`Executing commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name}`); + try { + const result = await step.stepLogic(); + this.#setCommissioningStepResult(step, result); + + if (this.#lastFailSafeTime !== undefined) { + const timeSinceLastArmFailsafe = Time.nowMs() - this.#lastFailSafeTime; + if (this.#commissioningExpiryTime !== undefined && Time.nowMs() > this.#commissioningExpiryTime) { + logger.error( + `Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name} succeeded, but commissioning took too long in general!`, + ); + throw new CommissioningError(`Commissioning took too long!`); + } + /** + * Commissioner SHALL re-arm the Fail-safe timer on the Commissionee to the desired commissioning + * timeout within 60 seconds of the completion of PASE session establishment, using the ArmFailSafe + * command (see Section 11.9.6.2, “ArmFailSafe Command”) + */ + if (timeSinceLastArmFailsafe > this.#failSafeTimeMs / 2) { + logger.info( + `After Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${ + step.name + } succeeded, ${Math.floor( + timeSinceLastArmFailsafe / 1000, + )}s elapsed since last arm failsafe, re-arming failsafe`, + ); + await this.#armFailsafe(); + } + } + + if (result.code === CommissioningStepResultCode.Stop) { + break; + } + } catch (error) { + if (error instanceof RecoverableCommissioningError) { + logger.error( + `Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name} failed with recoverable error: ${error.message} ... Continuing with process`, + ); + } else if (error instanceof CommissioningError || error instanceof StatusResponseError) { + logger.error( + `Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name} failed with error: ${error.message} ... Aborting commissioning`, + ); + // TODO In concurrent connection commissioning flow, the failure of any of the steps 2 through 10 + // SHALL result in the Commissioner and Commissionee returning to step 2 (device discovery and + // commissioning channel establishment) and repeating each step. The failure of any of the steps + // 11 through 15 in concurrent connection commissioning flow SHALL result in the Commissioner and + // Commissionee returning to step 11 (configuration of operational network information). In the + // case of failure of any of the steps 11 through 15 in concurrent connection commissioning flow, + // the Commissioner and Commissionee SHALL reuse the existing PASE-derived encryption keys over + // the commissioning channel and all steps up to and including step 10 are considered to have + // been successfully completed. + + // Commissioners that need to restart from step 2 MAY immediately expire the fail-safe by invoking + // the ArmFailSafe command with an ExpiryLengthSeconds field set to 0. Otherwise, Commissioners + // will need to wait until the current fail-safe timer has expired for the Commissionee to begin + // accepting PASE again. + await this.#resetFailsafeTimer(); + + StatusResponseError.accept(error); + + // Convert error + const commError = new CommissioningError(error.message); + commError.stack = error.stack; + throw commError; + } else { + throw error; + } + } + } } /** * Helper method to create ClusterClients. If not feature specific and for the Root Endpoint they are also reused. */ - private getClusterClient( + #getClusterClient( cluster: T, endpointId = EndpointNumber(0), isFeatureSpecific = false, ): ClusterClientObj { if (!isFeatureSpecific && endpointId === 0) { - const clusterClient = this.clusterClients.get(cluster.id); + const clusterClient = this.#clusterClients.get(cluster.id); if (clusterClient !== undefined) { logger.debug( `Returning existing cluster client for cluster ${cluster.name} (endpoint ${endpointId}, isFeatureSpecific ${isFeatureSpecific})`, @@ -192,203 +284,126 @@ export class ControllerCommissioner { logger.debug( `Creating new cluster client for cluster ${cluster.name} (endpoint ${endpointId}, isFeatureSpecific ${isFeatureSpecific})`, ); - const client = ClusterClient(cluster, endpointId, this.interactionClient); - this.clusterClients.set(cluster.id, client); + const client = ClusterClient(cluster, endpointId, this.#interactionClient); + this.#clusterClients.set(cluster.id, client); return client; } /** * Initialize commissioning steps and add them in the default order */ - private initializeCommissioningSteps() { - this.commissioningSteps.push({ + #initializeCommissioningSteps() { + this.#commissioningSteps.push({ stepNumber: 0, subStepNumber: 1, name: "GetInitialData", - stepLogic: () => this.getInitialData(), + stepLogic: () => this.#getInitialData(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 3, subStepNumber: 1, name: "GeneralCommissioning.ArmFailsafe", - stepLogic: () => this.armFailsafe(), + stepLogic: () => this.#armFailsafe(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 5, subStepNumber: 1, name: "GeneralCommissioning.ConfigureRegulatoryInformation", - stepLogic: () => this.configureRegulatoryInformation(), + stepLogic: () => this.#configureRegulatoryInformation(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 5, subStepNumber: 2, name: "TimeSynchronization.SynchronizeTime", - stepLogic: () => this.synchronizeTime(), + stepLogic: () => this.#synchronizeTime(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 6, subStepNumber: 1, name: "OperationalCredentials.DeviceAttestation", - stepLogic: () => this.deviceAttestation(), + stepLogic: () => this.#deviceAttestation(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 7, subStepNumber: 1, name: "OperationalCredentials.Certificates", - stepLogic: () => this.certificates(), + stepLogic: () => this.#certificates(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 10, subStepNumber: 1, name: "AccessControl", - stepLogic: () => this.configureAccessControlLists(), + stepLogic: () => this.#configureAccessControlLists(), }); // Care about Network commissioning only when we are on BLE, because else we are already on IP network - if (this.interactionClient.channelType === ChannelType.BLE) { - this.commissioningSteps.push({ + if (this.#interactionClient.channelType === ChannelType.BLE) { + this.#commissioningSteps.push({ stepNumber: 11, subStepNumber: 1, name: "NetworkCommissioning.Validate", - stepLogic: () => this.validateNetwork(), + stepLogic: () => this.#validateNetwork(), }); - if (this.commissioningOptions.wifiNetwork !== undefined) { - this.commissioningSteps.push({ + if (this.#commissioningOptions.wifiNetwork !== undefined) { + this.#commissioningSteps.push({ stepNumber: 11, subStepNumber: 2, name: "NetworkCommissioning.Wifi", - stepLogic: () => this.configureNetworkWifi(), + stepLogic: () => this.#configureNetworkWifi(), }); } - if (this.commissioningOptions.threadNetwork !== undefined) { - this.commissioningSteps.push({ + if (this.#commissioningOptions.threadNetwork !== undefined) { + this.#commissioningSteps.push({ stepNumber: 11, subStepNumber: 3, name: "NetworkCommissioning.Thread", - stepLogic: () => this.configureNetworkThread(), + stepLogic: () => this.#configureNetworkThread(), }); } } else { logger.info( - `Skipping NetworkCommissioning steps because the device is already on IP network (${this.interactionClient.channelType})`, + `Skipping NetworkCommissioning steps because the device is already on IP network (${this.#interactionClient.channelType})`, ); } - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 12, subStepNumber: 1, name: "Reconnect", - stepLogic: () => this.reconnectWithDevice(), + stepLogic: () => this.#reconnectWithDevice(), }); - this.commissioningSteps.push({ + this.#commissioningSteps.push({ stepNumber: 15, subStepNumber: 1, name: "GeneralCommissioning.Complete", - stepLogic: () => this.completeCommissioning(), + stepLogic: () => this.#completeCommissioning(), }); } - /** - * Execute the commissioning process in the defined order. The steps are sorted before execution based on the step - * number and sub step number. - * If >50% of the failsafe time has passed, the failsafe timer is re-armed (50% of 60s default are 30s and each - * action is allowed to take 30s at minimum based on specs). - */ - async executeCommissioning() { - this.sortSteps(); - - for (const step of this.commissioningSteps) { - logger.info(`Executing commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name}`); - try { - const result = await step.stepLogic(); - this.setCommissioningStepResult(step, result); - if (this.lastFailSafeTime !== undefined) { - const timeSinceLastArmFailsafe = Time.nowMs() - this.lastFailSafeTime; - if (this.commissioningExpiryTime !== undefined && Time.nowMs() > this.commissioningExpiryTime) { - logger.error( - `Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name} succeeded, but commissioning took too long in general!`, - ); - throw new CommissioningError(`Commissioning took too long!`); - } - /** - * Commissioner SHALL re-arm the Fail-safe timer on the Commissionee to the desired commissioning - * timeout within 60 seconds of the completion of PASE session establishment, using the ArmFailSafe - * command (see Section 11.9.6.2, “ArmFailSafe Command”) - */ - if (timeSinceLastArmFailsafe > this.failSafeTimeMs / 2) { - logger.info( - `After Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${ - step.name - } succeeded, ${Math.floor( - timeSinceLastArmFailsafe / 1000, - )}s elapsed since last arm failsafe, re-arming failsafe`, - ); - await this.armFailsafe(); - } - } - } catch (error) { - if (error instanceof RecoverableCommissioningError) { - logger.error( - `Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name} failed with recoverable error: ${error.message} ... Continuing with process`, - ); - } else if (error instanceof CommissioningError || error instanceof StatusResponseError) { - logger.error( - `Commissioning step ${step.stepNumber}.${step.subStepNumber}: ${step.name} failed with error: ${error.message} ... Aborting commissioning`, - ); - // TODO In concurrent connection commissioning flow, the failure of any of the steps 2 through 10 - // SHALL result in the Commissioner and Commissionee returning to step 2 (device discovery and - // commissioning channel establishment) and repeating each step. The failure of any of the steps - // 11 through 15 in concurrent connection commissioning flow SHALL result in the Commissioner and - // Commissionee returning to step 11 (configuration of operational network information). In the - // case of failure of any of the steps 11 through 15 in concurrent connection commissioning flow, - // the Commissioner and Commissionee SHALL reuse the existing PASE-derived encryption keys over - // the commissioning channel and all steps up to and including step 10 are considered to have - // been successfully completed. - - // Commissioners that need to restart from step 2 MAY immediately expire the fail-safe by invoking - // the ArmFailSafe command with an ExpiryLengthSeconds field set to 0. Otherwise, Commissioners - // will need to wait until the current fail-safe timer has expired for the Commissionee to begin - // accepting PASE again. - await this.resetFailsafeTimer(); - - StatusResponseError.accept(error); - - // Convert error - const commError = new CommissioningError(error.message); - commError.stack = error.stack; - throw commError; - } - - CommissioningSuccessfullyFinished.accept(error); - break; - } - } - } - - private sortSteps() { - this.commissioningSteps.sort((a, b) => { + #sortSteps() { + this.#commissioningSteps.sort((a, b) => { if (a.stepNumber !== b.stepNumber) return a.stepNumber - b.stepNumber; return a.subStepNumber - b.subStepNumber; }); } - private setCommissioningStepResult(step: CommissioningStep, result: CommissioningStepResult) { - this.commissioningStepResults.set(`${step.stepNumber}-${step.subStepNumber}`, result); + #setCommissioningStepResult(step: CommissioningStep, result: CommissioningStepResult) { + this.#commissioningStepResults.set(`${step.stepNumber}-${step.subStepNumber}`, result); } getCommissioningStepResult(stepNumber: number, subStepNumber: number) { - return this.commissioningStepResults.get(`${stepNumber}-${subStepNumber}`); + return this.#commissioningStepResults.get(`${stepNumber}-${subStepNumber}`); } /** Helper method to check for errorCode/debugTest responses and throw error on failure */ - private ensureOperationalCredentialsSuccess( + #ensureOperationalCredentialsSuccess( context: string, { statusCode, debugText, fabricIndex }: TypeFromSchema, ) { @@ -407,10 +422,7 @@ export class ControllerCommissioner { } /** Helper method to check for errorCode/debugTest responses and throw error on failure */ - private ensureGeneralCommissioningSuccess( - context: string, - { errorCode, debugText }: CommissioningSuccessFailureResponse, - ) { + #ensureGeneralCommissioningSuccess(context: string, { errorCode, debugText }: CommissioningSuccessFailureResponse) { logger.debug(`Commissioning step ${context} returned ${errorCode}, ${debugText}`); if (errorCode === GeneralCommissioning.CommissioningError.Ok) return; @@ -420,12 +432,12 @@ export class ControllerCommissioner { /** * Initial Step to receive some common data used by other steps */ - private async getInitialData() { - const descriptorClient = this.getClusterClient(Descriptor.Cluster); - this.collectedCommissioningData.rootPartsList = await descriptorClient.getPartsListAttribute(); - this.collectedCommissioningData.rootServerList = await descriptorClient.getServerListAttribute(); + async #getInitialData() { + const descriptorClient = this.#getClusterClient(Descriptor.Cluster); + this.#collectedCommissioningData.rootPartsList = await descriptorClient.getPartsListAttribute(); + this.#collectedCommissioningData.rootServerList = await descriptorClient.getServerListAttribute(); - const networkData = await this.interactionClient.getMultipleAttributes({ + const networkData = await this.#interactionClient.getMultipleAttributes({ attributes: [ { clusterId: NetworkCommissioning.Complete.id, @@ -455,16 +467,16 @@ export class ControllerCommissioner { networkStatus.push({ endpointId, value }); } } - this.collectedCommissioningData.networkFeatures = networkFeatures; - this.collectedCommissioningData.networkStatus = networkStatus; + this.#collectedCommissioningData.networkFeatures = networkFeatures; + this.#collectedCommissioningData.networkStatus = networkStatus; - const basicInfoClient = this.getClusterClient(BasicInformation.Cluster); - this.collectedCommissioningData.vendorId = await basicInfoClient.getVendorIdAttribute(); - this.collectedCommissioningData.productId = await basicInfoClient.getProductIdAttribute(); - this.collectedCommissioningData.productName = await basicInfoClient.getProductNameAttribute(); + const basicInfoClient = this.#getClusterClient(BasicInformation.Cluster); + this.#collectedCommissioningData.vendorId = await basicInfoClient.getVendorIdAttribute(); + this.#collectedCommissioningData.productId = await basicInfoClient.getProductIdAttribute(); + this.#collectedCommissioningData.productName = await basicInfoClient.getProductNameAttribute(); - const generalCommissioningClient = this.getClusterClient(GeneralCommissioning.Cluster); - this.collectedCommissioningData.supportsConcurrentConnection = + const generalCommissioningClient = this.#getClusterClient(GeneralCommissioning.Cluster); + this.#collectedCommissioningData.supportsConcurrentConnection = await generalCommissioningClient.getSupportsConcurrentConnectionAttribute(); /* @@ -479,7 +491,7 @@ export class ControllerCommissioner { return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -492,36 +504,36 @@ export class ControllerCommissioner { * reading BasicCommissioningInfo attribute (see Section 11.9.5.2, “BasicCommissioningInfo Attribute”) prior to * invoking the ArmFailSafe command. */ - private async armFailsafe() { - const client = this.getClusterClient(GeneralCommissioning.Cluster); - if (this.collectedCommissioningData.basicCommissioningInfo === undefined) { + async #armFailsafe() { + const client = this.#getClusterClient(GeneralCommissioning.Cluster); + if (this.#collectedCommissioningData.basicCommissioningInfo === undefined) { const basicCommissioningInfo = await client.getBasicCommissioningInfoAttribute(); - this.collectedCommissioningData.basicCommissioningInfo = basicCommissioningInfo; - this.failSafeTimeMs = basicCommissioningInfo.failSafeExpiryLengthSeconds * 1000; - this.commissioningStartedTime = Time.nowMs(); - this.commissioningExpiryTime = - this.commissioningStartedTime + basicCommissioningInfo.maxCumulativeFailsafeSeconds * 1000; + this.#collectedCommissioningData.basicCommissioningInfo = basicCommissioningInfo; + this.#failSafeTimeMs = basicCommissioningInfo.failSafeExpiryLengthSeconds * 1000; + this.#commissioningStartedTime = Time.nowMs(); + this.#commissioningExpiryTime = + this.#commissioningStartedTime + basicCommissioningInfo.maxCumulativeFailsafeSeconds * 1000; } - this.ensureGeneralCommissioningSuccess( + this.#ensureGeneralCommissioningSuccess( "armFailSafe", await client.armFailSafe({ - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, expiryLengthSeconds: - this.collectedCommissioningData.basicCommissioningInfo?.failSafeExpiryLengthSeconds, + this.#collectedCommissioningData.basicCommissioningInfo?.failSafeExpiryLengthSeconds, }), ); - this.lastFailSafeTime = Time.nowMs(); + this.#lastFailSafeTime = Time.nowMs(); return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } - private async resetFailsafeTimer() { + async #resetFailsafeTimer() { try { - const client = this.getClusterClient(GeneralCommissioning.Cluster); + const client = this.#getClusterClient(GeneralCommissioning.Cluster); await client.armFailSafe({ - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, expiryLengthSeconds: 0, }); } catch (error) { @@ -537,21 +549,21 @@ export class ControllerCommissioner { * The regulatory information is configured using SetRegulatoryConfig (see Section 11.9.6.4, * “SetRegulatoryConfig Command”). */ - private async configureRegulatoryInformation() { - if (this.collectedCommissioningData.networkFeatures === undefined) { + async #configureRegulatoryInformation() { + if (this.#collectedCommissioningData.networkFeatures === undefined) { throw new CommissioningError("No network features collected. This should never happen."); } // Read the infos for all Network Commissioning clusters - const hasRadioNetwork = this.collectedCommissioningData.networkFeatures.some( + const hasRadioNetwork = this.#collectedCommissioningData.networkFeatures.some( ({ value: { wiFiNetworkInterface, threadNetworkInterface } }) => wiFiNetworkInterface || threadNetworkInterface, ); if (hasRadioNetwork) { - const client = this.getClusterClient(GeneralCommissioning.Cluster); + const client = this.#getClusterClient(GeneralCommissioning.Cluster); let locationCapability = await client.getLocationCapabilityAttribute(); if (locationCapability === GeneralCommissioning.RegulatoryLocationType.IndoorOutdoor) { - locationCapability = this.commissioningOptions.regulatoryLocation; + locationCapability = this.#commissioningOptions.regulatoryLocation; } else { logger.debug( `Device does not support a configurable regulatory location type. Location is set to "${ @@ -559,10 +571,10 @@ export class ControllerCommissioner { }"`, ); } - let countryCode = this.commissioningOptions.regulatoryCountryCode; + let countryCode = this.#commissioningOptions.regulatoryCountryCode; const regulatoryResult = await client.setRegulatoryConfig( { - breadcrumb: this.lastBreadcrumb++, + breadcrumb: this.#lastBreadcrumb++, newRegulatoryConfig: locationCapability, countryCode, }, @@ -576,11 +588,11 @@ export class ControllerCommissioner { `Device does not support a configurable country code. Use "XX" instead of "${countryCode}"`, ); countryCode = "XX"; - this.ensureGeneralCommissioningSuccess( + this.#ensureGeneralCommissioningSuccess( "setRegulatoryConfig", await client.setRegulatoryConfig( { - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, newRegulatoryConfig: locationCapability, countryCode, }, @@ -588,16 +600,16 @@ export class ControllerCommissioner { ), ); } else { - this.ensureGeneralCommissioningSuccess("setRegulatoryConfig", regulatoryResult); + this.#ensureGeneralCommissioningSuccess("setRegulatoryConfig", regulatoryResult); } return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -609,10 +621,10 @@ export class ControllerCommissioner { * (see Section 11.16.8.6, “TimeZone Attribute”) and DstOffset attribute (see Section 11.16.8.7, * “DSTOffset Attribute”), respectively. */ - private async synchronizeTime() { + async #synchronizeTime() { if ( - this.collectedCommissioningData.rootServerList !== undefined && - this.collectedCommissioningData.rootServerList.find( + this.#collectedCommissioningData.rootServerList !== undefined && + this.#collectedCommissioningData.rootServerList.find( clusterId => clusterId === TimeSynchronizationCluster.id, ) ) { @@ -621,7 +633,7 @@ export class ControllerCommissioner { } return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -630,8 +642,8 @@ export class ControllerCommissioner { * Commissioner SHALL establish the authenticity of the Commissionee as a certified Matter device (see Section * 6.2.3, “Device Attestation Procedure”). */ - private async deviceAttestation() { - const operationalCredentialsClusterClient = this.getClusterClient(OperationalCredentials.Cluster); + async #deviceAttestation() { + const operationalCredentialsClusterClient = this.#getClusterClient(OperationalCredentials.Cluster); const { certificate: deviceAttestation } = await operationalCredentialsClusterClient.certificateChainRequest( { certificateType: OperationalCredentials.CertificateChainType.DacCertificate, @@ -665,7 +677,7 @@ export class ControllerCommissioner { } return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; // TODO consider Distributed Compliance Ledger Info about Commissioning Flow @@ -683,8 +695,8 @@ export class ControllerCommissioner { * 9: Commissioner SHALL install operational credentials (see Figure 38, “Node Operational Credentials flow”) on * the Commissionee using the AddTrustedRootCertificate and AddNOC commands. */ - private async certificates() { - const operationalCredentialsClusterClient = this.getClusterClient(OperationalCredentials.Cluster); + async #certificates() { + const operationalCredentialsClusterClient = this.#getClusterClient(OperationalCredentials.Cluster); const { nocsrElements, attestationSignature: csrSignature } = await operationalCredentialsClusterClient.csrRequest( { csrNonce: Crypto.getRandomData(32) }, @@ -700,24 +712,24 @@ export class ControllerCommissioner { await operationalCredentialsClusterClient.addTrustedRootCertificate( { - rootCaCertificate: this.certificateManager.rootCert, + rootCaCertificate: this.#certificateManager.rootCert, }, { useExtendedFailSafeMessageResponseTimeout: true }, ); - const peerOperationalCert = this.certificateManager.generateNoc( + const peerOperationalCert = this.#certificateManager.generateNoc( operationalPublicKey, - this.fabric.fabricId, - this.nodeId, + this.#fabric.fabricId, + this.#interactionClient.address.nodeId, ); - this.ensureOperationalCredentialsSuccess( + this.#ensureOperationalCredentialsSuccess( "addNoc", await operationalCredentialsClusterClient.addNoc( { nocValue: peerOperationalCert, icacValue: new Uint8Array(0), - ipkValue: this.fabric.identityProtectionKey, - adminVendorId: this.adminVendorId, - caseAdminSubject: this.fabric.rootNodeId, + ipkValue: this.#fabric.identityProtectionKey, + adminVendorId: this.#fabric.rootVendorId, + caseAdminSubject: this.#fabric.rootNodeId, }, { useExtendedFailSafeMessageResponseTimeout: true }, ), @@ -725,7 +737,7 @@ export class ControllerCommissioner { return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -736,12 +748,12 @@ export class ControllerCommissioner { * privilege over CASE authentication type for the Node ID provided with the command is not sufficient to express * its desired access control policies. */ - private async configureAccessControlLists() { + async #configureAccessControlLists() { // Standard entry is sufficient in our case return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -757,26 +769,26 @@ export class ControllerCommissioner { * 12: The Commissioner SHALL trigger the Commissionee to connect to the operational network using ConnectNetwork * command (see Section 11.8.7.9, “ConnectNetwork Command”) unless the Commissionee is already on the desired operational network. */ - private async validateNetwork() { + async #validateNetwork() { if ( - this.collectedCommissioningData.networkFeatures === undefined || - this.collectedCommissioningData.networkStatus === undefined + this.#collectedCommissioningData.networkFeatures === undefined || + this.#collectedCommissioningData.networkStatus === undefined ) { throw new CommissioningError("No network features or status collected. This should never happen."); } if ( - this.commissioningOptions.wifiNetwork === undefined && - this.commissioningOptions.threadNetwork === undefined + this.#commissioningOptions.wifiNetwork === undefined && + this.#commissioningOptions.threadNetwork === undefined ) { // Check if we have no networkCommissioning cluster or an Ethernet one const anyEthernetInterface = - this.collectedCommissioningData.networkFeatures.length === 0 || - this.collectedCommissioningData.networkFeatures.some( + this.#collectedCommissioningData.networkFeatures.length === 0 || + this.#collectedCommissioningData.networkFeatures.some( ({ value: { ethernetNetworkInterface } }) => ethernetNetworkInterface === true, ); const anyInterfaceConnected = - this.collectedCommissioningData.networkStatus.length === 0 || - this.collectedCommissioningData.networkStatus.some(({ value }) => + this.#collectedCommissioningData.networkStatus.length === 0 || + this.#collectedCommissioningData.networkStatus.some(({ value }) => value.some(({ connected }) => connected), ); if (!anyEthernetInterface && !anyInterfaceConnected) { @@ -788,26 +800,26 @@ export class ControllerCommissioner { return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } - private async configureNetworkWifi() { - if (this.commissioningOptions.wifiNetwork === undefined) { + async #configureNetworkWifi() { + if (this.#commissioningOptions.wifiNetwork === undefined) { logger.debug("WiFi network is not configured"); return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } if ( - this.collectedCommissioningData.networkFeatures !== undefined && - this.collectedCommissioningData.networkStatus !== undefined + this.#collectedCommissioningData.networkFeatures !== undefined && + this.#collectedCommissioningData.networkStatus !== undefined ) { - const rootNetworkFeatures = this.collectedCommissioningData.networkFeatures.find( + const rootNetworkFeatures = this.#collectedCommissioningData.networkFeatures.find( ({ endpointId }) => endpointId === 0, )?.value; - const rootNetworkStatus = this.collectedCommissioningData.networkStatus.find( + const rootNetworkStatus = this.#collectedCommissioningData.networkStatus.find( ({ endpointId }) => endpointId === 0, )?.value; @@ -819,32 +831,32 @@ export class ControllerCommissioner { logger.debug("Commissionee does not support any WiFi network interface"); return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } if (rootNetworkStatus !== undefined && rootNetworkStatus.length > 0 && rootNetworkStatus[0].connected) { logger.debug("Commissionee is already connected to the WiFi network"); - this.collectedCommissioningData.successfullyConnectedToNetwork = true; + this.#collectedCommissioningData.successfullyConnectedToNetwork = true; return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } } logger.debug("Configuring WiFi network ..."); - const networkCommissioningClusterClient = this.getClusterClient( + const networkCommissioningClusterClient = this.#getClusterClient( NetworkCommissioning.Cluster.with("WiFiNetworkInterface"), EndpointNumber(0), true, ); - const ssid = Bytes.fromString(this.commissioningOptions.wifiNetwork.wifiSsid); - const credentials = Bytes.fromString(this.commissioningOptions.wifiNetwork.wifiCredentials); + const ssid = Bytes.fromString(this.#commissioningOptions.wifiNetwork.wifiSsid); + const credentials = Bytes.fromString(this.#commissioningOptions.wifiNetwork.wifiCredentials); const { networkingStatus, wiFiScanResults, debugText } = await networkCommissioningClusterClient.scanNetworks( { ssid, - breadcrumb: this.lastBreadcrumb++, + breadcrumb: this.#lastBreadcrumb++, }, { useExtendedFailSafeMessageResponseTimeout: true }, ); @@ -853,7 +865,7 @@ export class ControllerCommissioner { } if (wiFiScanResults === undefined || wiFiScanResults.length === 0) { throw new CommissioningError( - `Commissionee did not return any WiFi networks for the requested SSID ${this.commissioningOptions.wifiNetwork.wifiSsid}`, + `Commissionee did not return any WiFi networks for the requested SSID ${this.#commissioningOptions.wifiNetwork.wifiSsid}`, ); } @@ -865,7 +877,7 @@ export class ControllerCommissioner { { ssid, credentials, - breadcrumb: this.lastBreadcrumb++, + breadcrumb: this.#lastBreadcrumb++, }, { useExtendedFailSafeMessageResponseTimeout: true }, ); @@ -876,7 +888,7 @@ export class ControllerCommissioner { throw new CommissioningError(`Commissionee did not return network index`); } logger.debug( - `Commissionee added WiFi network ${this.commissioningOptions.wifiNetwork.wifiSsid} with network index ${networkIndex}`, + `Commissionee added WiFi network ${this.#commissioningOptions.wifiNetwork.wifiSsid} with network index ${networkIndex}`, ); const updatedNetworks = await networkCommissioningClusterClient.getNetworksAttribute(); @@ -885,22 +897,22 @@ export class ControllerCommissioner { } const { networkId, connected } = updatedNetworks[networkIndex]; if (connected) { - this.collectedCommissioningData.successfullyConnectedToNetwork = true; + this.#collectedCommissioningData.successfullyConnectedToNetwork = true; logger.debug( `Commissionee is already connected to WiFi network ${ - this.commissioningOptions.wifiNetwork.wifiSsid + this.#commissioningOptions.wifiNetwork.wifiSsid } (networkId ${Bytes.toHex(networkId)})`, ); return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } const connectResult = await networkCommissioningClusterClient.connectNetwork( { networkId: networkId, - breadcrumb: this.lastBreadcrumb++, + breadcrumb: this.#lastBreadcrumb++, }, { useExtendedFailSafeMessageResponseTimeout: true }, ); @@ -908,42 +920,42 @@ export class ControllerCommissioner { if (connectResult.networkingStatus !== NetworkCommissioning.NetworkCommissioningStatus.Success) { throw new CommissioningError(`Commissionee failed to connect to WiFi network: ${connectResult.debugText}`); } - this.collectedCommissioningData.successfullyConnectedToNetwork = true; + this.#collectedCommissioningData.successfullyConnectedToNetwork = true; logger.debug( `Commissionee successfully connected to WiFi network ${ - this.commissioningOptions.wifiNetwork.wifiSsid + this.#commissioningOptions.wifiNetwork.wifiSsid } (networkId ${Bytes.toHex(networkId)})`, ); return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } - private async configureNetworkThread() { - if (this.collectedCommissioningData.successfullyConnectedToNetwork) { + async #configureNetworkThread() { + if (this.#collectedCommissioningData.successfullyConnectedToNetwork) { logger.debug("Node is already connected to a network. Skipping Thread configuration."); return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } - if (this.commissioningOptions.threadNetwork === undefined) { + if (this.#commissioningOptions.threadNetwork === undefined) { logger.debug("Thread network is not configured"); return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } if ( - this.collectedCommissioningData.networkFeatures !== undefined && - this.collectedCommissioningData.networkStatus !== undefined + this.#collectedCommissioningData.networkFeatures !== undefined && + this.#collectedCommissioningData.networkStatus !== undefined ) { - const rootNetworkFeatures = this.collectedCommissioningData.networkFeatures.find( + const rootNetworkFeatures = this.#collectedCommissioningData.networkFeatures.find( ({ endpointId }) => endpointId === 0, )?.value; - const rootNetworkStatus = this.collectedCommissioningData.networkStatus.find( + const rootNetworkStatus = this.#collectedCommissioningData.networkStatus.find( ({ endpointId }) => endpointId === 0, )?.value; @@ -955,27 +967,27 @@ export class ControllerCommissioner { logger.debug("Commissionee does not support any Thread network interface"); return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } if (rootNetworkStatus !== undefined && rootNetworkStatus.length > 0 && rootNetworkStatus[0].connected) { logger.debug("Commissionee is already connected to the Thread network"); return { code: CommissioningStepResultCode.Skipped, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } } logger.debug("Configuring Thread network ..."); - const networkCommissioningClusterClient = this.getClusterClient( + const networkCommissioningClusterClient = this.#getClusterClient( NetworkCommissioning.Cluster.with("ThreadNetworkInterface"), EndpointNumber(0), true, ); const { networkingStatus, threadScanResults, debugText } = await networkCommissioningClusterClient.scanNetworks( - { breadcrumb: this.lastBreadcrumb++ }, + { breadcrumb: this.#lastBreadcrumb++ }, { useExtendedFailSafeMessageResponseTimeout: true }, ); if (networkingStatus !== NetworkCommissioning.NetworkCommissioningStatus.Success) { @@ -983,22 +995,22 @@ export class ControllerCommissioner { } if (threadScanResults === undefined || threadScanResults.length === 0) { throw new CommissioningError( - `Commissionee did not return any Thread networks for the requested Network ${this.commissioningOptions.threadNetwork.networkName}`, + `Commissionee did not return any Thread networks for the requested Network ${this.#commissioningOptions.threadNetwork.networkName}`, ); } const wantedNetworkFound = threadScanResults.find( - ({ networkName }) => networkName === this.commissioningOptions.threadNetwork?.networkName, + ({ networkName }) => networkName === this.#commissioningOptions.threadNetwork?.networkName, ); if (wantedNetworkFound === undefined) { throw new CommissioningError( `Commissionee did not return the requested Network ${ - this.commissioningOptions.threadNetwork.networkName + this.#commissioningOptions.threadNetwork.networkName }: ${Logger.toJSON(threadScanResults)}`, ); } logger.debug( `Commissionee found wanted Thread network ${ - this.commissioningOptions.threadNetwork.networkName + this.#commissioningOptions.threadNetwork.networkName }: ${Logger.toJSON(wantedNetworkFound)}`, ); @@ -1008,8 +1020,8 @@ export class ControllerCommissioner { networkIndex, } = await networkCommissioningClusterClient.addOrUpdateThreadNetwork( { - operationalDataset: Bytes.fromHex(this.commissioningOptions.threadNetwork.operationalDataset), - breadcrumb: this.lastBreadcrumb++, + operationalDataset: Bytes.fromHex(this.#commissioningOptions.threadNetwork.operationalDataset), + breadcrumb: this.#lastBreadcrumb++, }, { useExtendedFailSafeMessageResponseTimeout: true }, ); @@ -1020,7 +1032,7 @@ export class ControllerCommissioner { throw new CommissioningError(`Commissionee did not return network index`); } logger.debug( - `Commissionee added Thread network ${this.commissioningOptions.threadNetwork.networkName} with network index ${networkIndex}`, + `Commissionee added Thread network ${this.#commissioningOptions.threadNetwork.networkName} with network index ${networkIndex}`, ); const updatedNetworks = await networkCommissioningClusterClient.getNetworksAttribute(); @@ -1031,19 +1043,19 @@ export class ControllerCommissioner { if (connected) { logger.debug( `Commissionee is already connected to Thread network ${ - this.commissioningOptions.threadNetwork.networkName + this.#commissioningOptions.threadNetwork.networkName } (networkId ${Bytes.toHex(networkId)})`, ); return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } const connectResult = await networkCommissioningClusterClient.connectNetwork( { networkId: networkId, - breadcrumb: this.lastBreadcrumb++, + breadcrumb: this.#lastBreadcrumb++, }, { useExtendedFailSafeMessageResponseTimeout: true }, ); @@ -1055,13 +1067,13 @@ export class ControllerCommissioner { } logger.debug( `Commissionee successfully connected to Thread network ${ - this.commissioningOptions.threadNetwork.networkName + this.#commissioningOptions.threadNetwork.networkName } (networkId ${Bytes.toHex(networkId)})`, ); return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -1074,15 +1086,26 @@ export class ControllerCommissioner { * (CASE)”) session with the Commissionee over the operational network. * */ - private async reconnectWithDevice() { + async #reconnectWithDevice() { logger.debug("Reconnecting with device ..."); - this.interactionClient = await this.doOperativeDeviceConnectionCallback(); + const transitionResult = await this.#transitionToCase(this.#interactionClient.address); + + if (transitionResult === undefined) { + logger.debug("CASE commissioning handled externally, terminating commissioning flow"); + return { + code: CommissioningStepResultCode.Stop, + breadcrumb: this.#lastBreadcrumb, + }; + } + + this.#interactionClient = transitionResult; + this.#clusterClients.clear(); + logger.debug("Successfully reconnected with device ..."); - this.clusterClients.clear(); return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } @@ -1093,9 +1116,9 @@ export class ControllerCommissioner { * “CommissioningComplete Command”). A success response after invocation of the CommissioningComplete command ends * the commissioning process. */ - private async completeCommissioning() { - const generalCommissioningClusterClient = this.getClusterClient(GeneralCommissioning.Cluster); - this.ensureGeneralCommissioningSuccess( + async #completeCommissioning() { + const generalCommissioningClusterClient = this.#getClusterClient(GeneralCommissioning.Cluster); + this.#ensureGeneralCommissioningSuccess( "commissioningComplete", await generalCommissioningClusterClient.commissioningComplete(undefined, { useExtendedFailSafeMessageResponseTimeout: true, @@ -1104,7 +1127,7 @@ export class ControllerCommissioner { return { code: CommissioningStepResultCode.Success, - breadcrumb: this.lastBreadcrumb, + breadcrumb: this.#lastBreadcrumb, }; } } diff --git a/packages/protocol/src/protocol/ControllerDiscovery.ts b/packages/protocol/src/peer/ControllerDiscovery.ts similarity index 98% rename from packages/protocol/src/protocol/ControllerDiscovery.ts rename to packages/protocol/src/peer/ControllerDiscovery.ts index fbdd1b4766..cea4692eb9 100644 --- a/packages/protocol/src/protocol/ControllerDiscovery.ts +++ b/packages/protocol/src/peer/ControllerDiscovery.ts @@ -16,8 +16,8 @@ import { } from "../common/Scanner.js"; import { Fabric } from "../fabric/Fabric.js"; import { MdnsScanner } from "../mdns/MdnsScanner.js"; -import { CommissioningError } from "../protocol/ControllerCommissioner.js"; -import { RetransmissionLimitReachedError } from "./MessageExchange.js"; +import { RetransmissionLimitReachedError } from "../protocol/MessageExchange.js"; +import { CommissioningError } from "./ControllerCommissioningFlow.js"; const logger = Logger.get("ControllerDiscovery"); diff --git a/packages/protocol/src/peer/OperationalPeer.ts b/packages/protocol/src/peer/OperationalPeer.ts new file mode 100644 index 0000000000..843e243eb5 --- /dev/null +++ b/packages/protocol/src/peer/OperationalPeer.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DiscoveryData } from "#common/Scanner.js"; +import { ServerAddressIp } from "#general"; +import { PeerAddress } from "./PeerAddress.js"; + +/** + * Operational information for a single peer. + * + * For our purposes a "peer" is another node commissioned to a fabric to which we have access. + */ + +export interface OperationalPeer { + /** + * The logical address of the peer. + */ + address: PeerAddress; + + /** + * A physical address the peer may be accessed at, if known. + */ + operationalAddress?: ServerAddressIp; + + /** + * Additional information collected while locating the peer. + */ + discoveryData?: DiscoveryData; +} diff --git a/packages/protocol/src/peer/PeerAddress.ts b/packages/protocol/src/peer/PeerAddress.ts new file mode 100644 index 0000000000..a66b774217 --- /dev/null +++ b/packages/protocol/src/peer/PeerAddress.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FabricIndex, NodeId } from "@matter.js/types"; + +/** + * This is the "logical" address of a peer node consisting of a fabric and node ID. + */ +export interface PeerAddress { + fabricIndex: FabricIndex; + nodeId: NodeId; +} + +const interned = Symbol("interned-logical-address"); + +const internedAddresses = new Map>(); + +/** + * Obtain a canonical instance of a logical address. + * + * This allows for identification based on object comparison. Interned addresses render to a string in the format + * "@:" + */ +export function PeerAddress(address: PeerAddress): PeerAddress { + if (interned in address) { + return address; + } + + let internedFabric = internedAddresses.get(address.fabricIndex); + if (internedFabric === undefined) { + internedAddresses.set(address.fabricIndex, (internedFabric = new Map())); + } + + let internedAddress = internedFabric.get(address.nodeId); + if (internedAddress) { + return internedAddress; + } + + internedFabric.set( + address.nodeId, + (internedAddress = { + ...address, + + [interned]: true, + + toString() { + const nodeStr = this.nodeId > 0xffff ? `0x${this.nodeId.toString(16)}` : this.nodeId; + return `peer@${this.fabricIndex}:${nodeStr}`; + }, + } as PeerAddress), + ); + + return internedAddress; +} + +/** + * A collection of items keyed by logical address. + */ +export class PeerAddressMap extends Map { + override delete(key: PeerAddress) { + return super.delete(PeerAddress(key)); + } + + override has(key: PeerAddress) { + return super.has(PeerAddress(key)); + } + + override set(key: PeerAddress, value: T) { + return super.set(PeerAddress(key), value); + } + + override get(key: PeerAddress) { + return super.get(PeerAddress(key)); + } +} diff --git a/packages/protocol/src/peer/PeerSet.ts b/packages/protocol/src/peer/PeerSet.ts new file mode 100644 index 0000000000..9ce15efbee --- /dev/null +++ b/packages/protocol/src/peer/PeerSet.ts @@ -0,0 +1,648 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DiscoveryData, ScannerSet } from "#common/Scanner.js"; +import { + anyPromise, + BasicSet, + ChannelType, + Construction, + createPromise, + Environment, + Environmental, + ImmutableSet, + ImplementationError, + isIPv6, + Logger, + NetInterfaceSet, + NoResponseTimeoutError, + ObservableSet, + ServerAddressIp, + serverAddressToString, + Time, + Timer, +} from "#general"; +import { InteractionClient } from "#interaction/InteractionClient.js"; +import { MdnsScanner } from "#mdns/MdnsScanner.js"; +import { PeerAddress, PeerAddressMap } from "#peer/PeerAddress.js"; +import { CaseClient, Session } from "#session/index.js"; +import { SessionManager } from "#session/SessionManager.js"; +import { SECURE_CHANNEL_PROTOCOL_ID } from "@matter.js/types"; +import { ChannelManager, NoChannelError } from "../protocol/ChannelManager.js"; +import { ExchangeManager, ExchangeProvider, MessageChannel } from "../protocol/ExchangeManager.js"; +import { RetransmissionLimitReachedError } from "../protocol/MessageExchange.js"; +import { ControllerDiscovery, DiscoveryError, PairRetransmissionLimitReachedError } from "./ControllerDiscovery.js"; +import { OperationalPeer } from "./OperationalPeer.js"; +import { PeerStore } from "./PeerStore.js"; + +const logger = Logger.get("PeerSet"); + +const RECONNECTION_POLLING_INTERVAL_MS = 600_000; // 10 minutes +const RETRANSMISSION_DISCOVERY_TIMEOUT_MS = 5_000; + +/** + * Types of discovery that may be performed when connecting operationally. + */ +export enum NodeDiscoveryType { + /** No discovery is done, in calls means that only known addresses are tried. */ + None = 0, + + /** Retransmission discovery means that we ignore known addresses and start a query for 5s. */ + RetransmissionDiscovery = 1, + + /** Timed discovery means that the device is discovered for a defined timeframe, including known addresses. */ + TimedDiscovery = 2, + + /** Full discovery means that the device is discovered until it is found, excluding known addresses. */ + FullDiscovery = 3, +} + +/** + * Configuration for discovering when establishing a peer connection. + */ +export interface DiscoveryOptions { + discoveryType?: NodeDiscoveryType; + timeoutSeconds?: number; + discoveryData?: DiscoveryData; +} + +interface RunningDiscovery { + type: NodeDiscoveryType; + promises?: (() => Promise)[]; + timer?: Timer; +} + +/** + * API for establishing a connection to a peer. + */ +export interface PeerConnector { + connect(address: PeerAddress, discoveryOptions: DiscoveryOptions): Promise; +} + +/** + * Interfaces {@link PeerSet} with other components. + */ +export interface PeerSetContext { + sessions: SessionManager; + channels: ChannelManager; + exchanges: ExchangeManager; + scanners: ScannerSet; + netInterfaces: NetInterfaceSet; + store: PeerStore; +} + +/** + * Manages operational connections to peers on shared fabric. + */ +export class PeerSet implements ImmutableSet, ObservableSet, PeerConnector { + readonly #sessions: SessionManager; + readonly #channels: ChannelManager; + readonly #exchanges: ExchangeManager; + readonly #scanners: ScannerSet; + readonly #netInterfaces: NetInterfaceSet; + readonly #caseClient: CaseClient; + readonly #peers = new BasicSet(); + readonly #peersByAddress = new PeerAddressMap(); + readonly #runningPeerDiscoveries = new PeerAddressMap(); + readonly #construction: Construction; + readonly #store: PeerStore; + + constructor(context: PeerSetContext) { + const { sessions, channels, exchanges, scanners, netInterfaces, store } = context; + + this.#sessions = sessions; + this.#channels = channels; + this.#exchanges = exchanges; + this.#scanners = scanners; + this.#netInterfaces = netInterfaces; + this.#store = store; + this.#caseClient = new CaseClient(this.#sessions); + + this.#peers.added.on(peer => { + peer.address = PeerAddress(peer.address); + this.#peersByAddress.set(peer.address, peer); + }); + + this.#peers.deleted.on(peer => { + this.#peersByAddress.delete(peer.address); + }); + + this.#sessions.resubmissionStarted.on(this.#handleResubmissionStarted.bind(this)); + + this.#construction = Construction(this, async () => { + for (const peer of await this.#store.loadPeers()) { + this.#peers.add(peer); + } + }); + } + + get added() { + return this.#peers.added; + } + + get deleted() { + return this.#peers.deleted; + } + + has(item: PeerAddress | OperationalPeer) { + if ("address" in item) { + return this.#peers.has(item); + } + return this.#peersByAddress.has(item); + } + + get size() { + return this.#peers.size; + } + + find(predicate: (item: OperationalPeer) => boolean | undefined) { + return this.#peers.find(predicate); + } + + filter(predicate: (item: OperationalPeer) => boolean | undefined) { + return this.#peers.filter(predicate); + } + + map(mapper: (item: OperationalPeer) => T) { + return this.#peers.map(mapper); + } + + [Symbol.iterator]() { + return this.#peers[Symbol.iterator](); + } + + get construction() { + return this.#construction; + } + + [Environmental.create](env: Environment) { + const instance = new PeerSet({ + sessions: env.get(SessionManager), + channels: env.get(ChannelManager), + exchanges: env.get(ExchangeManager), + scanners: env.get(ScannerSet), + netInterfaces: env.get(NetInterfaceSet), + store: env.get(PeerStore), + }); + env.set(PeerSet, instance); + return instance; + } + + get peers() { + return this.#peers; + } + + /** + * Connect to a node on a fabric. + */ + async connect(address: PeerAddress, discoveryOptions: DiscoveryOptions): Promise { + const { discoveryData } = discoveryOptions; + + address = PeerAddress(address); + + let channel: MessageChannel; + try { + channel = this.#channels.getChannel(address); + } catch (error) { + NoChannelError.accept(error); + + channel = await this.#resume(address, discoveryOptions); + } + + return new InteractionClient( + new ExchangeProvider(this.#exchanges, channel, async () => { + if (!this.#channels.hasChannel(address)) { + throw new RetransmissionLimitReachedError( + `Device ${PeerAddress(address)} is currently not reachable.`, + ); + } + await this.#channels.removeAllNodeChannels(address); + + // Try to use first result for one last try before we need to reconnect + const operationalAddress = this.#knownOperationalAddressFor(address); + if (operationalAddress === undefined) { + logger.info( + `Re-discovering device failed (no address found), remove all sessions for ${PeerAddress(address)}`, + ); + // We remove all sessions, this also informs the PairedNode class + await this.#sessions.removeAllSessionsForNode(address); + throw new RetransmissionLimitReachedError( + `No operational address found for ${PeerAddress(address)}`, + ); + } + if ( + (await this.#reconnectKnownAddress(address, operationalAddress, discoveryData, 2_000)) === undefined + ) { + throw new RetransmissionLimitReachedError(`${PeerAddress(address)} is not reachable.`); + } + return this.#channels.getChannel(address); + }), + address, + ); + } + + /** + * Retrieve a peer by address. + */ + get(peer: PeerAddress | OperationalPeer) { + if ("address" in peer) { + return this.#peersByAddress.get(peer.address); + } + return this.#peersByAddress.get(peer); + } + + /** + * Terminate any active peer connection. + */ + async disconnect(peer: PeerAddress | OperationalPeer) { + const address = this.get(peer)?.address; + if (address === undefined) { + return; + } + + await this.#sessions.removeAllSessionsForNode(address, true); + await this.#channels.removeAllNodeChannels(address); + } + + /** + * Forget a known peer. + */ + async delete(peer: PeerAddress | OperationalPeer) { + const actual = this.get(peer); + if (actual === undefined) { + return; + } + + logger.info(`Removing ${actual.address}`); + this.#peers.delete(actual); + await this.#store.deletePeer(actual.address); + await this.disconnect(actual); + await this.#sessions.removeResumptionRecord(actual.address); + } + + async close() { + const mdnsScanner = this.#scanners.scannerFor(ChannelType.UDP) as MdnsScanner | undefined; + for (const [address, { timer }] of this.#runningPeerDiscoveries.entries()) { + timer?.stop(); + + // This ends discovery without triggering promises + mdnsScanner?.cancelOperationalDeviceDiscovery(this.#sessions.fabricFor(address), address.nodeId, false); + } + } + + /** + * Resume a device connection and establish a CASE session that was previously paired with the controller. This + * method will try to connect to the device using the previously used server address (if set). If that fails, the + * device is discovered again using its operational instance details. + * It returns the operational MessageChannel on success. + */ + async #resume(address: PeerAddress, discoveryOptions?: DiscoveryOptions) { + const operationalAddress = this.#knownOperationalAddressFor(address); + + try { + return await this.#connectOrDiscoverNode(address, operationalAddress, discoveryOptions); + } catch (error) { + if ( + (error instanceof DiscoveryError || error instanceof NoResponseTimeoutError) && + this.#peersByAddress.has(address) + ) { + logger.info(`Resume failed, remove all sessions for ${PeerAddress(address)}`); + // We remove all sessions, this also informs the PairedNode class + await this.#sessions.removeAllSessionsForNode(address); + } + throw error; + } + } + + async #connectOrDiscoverNode( + address: PeerAddress, + operationalAddress?: ServerAddressIp, + discoveryOptions: DiscoveryOptions = {}, + ) { + address = PeerAddress(address); + const { + discoveryType: requestedDiscoveryType = NodeDiscoveryType.FullDiscovery, + timeoutSeconds, + discoveryData = this.#peersByAddress.get(address)?.discoveryData, + } = discoveryOptions; + if (timeoutSeconds !== undefined && requestedDiscoveryType !== NodeDiscoveryType.TimedDiscovery) { + throw new ImplementationError("Cannot set timeout without timed discovery."); + } + if (requestedDiscoveryType === NodeDiscoveryType.RetransmissionDiscovery) { + throw new ImplementationError("Cannot set retransmission discovery type."); + } + + const mdnsScanner = this.#scanners.scannerFor(ChannelType.UDP) as MdnsScanner | undefined; + if (!mdnsScanner) { + throw new ImplementationError("Cannot discover device without mDNS scanner."); + } + + const existingDiscoveryDetails = this.#runningPeerDiscoveries.get(address) ?? { + type: NodeDiscoveryType.None, + }; + + // If we currently run another "lower" retransmission type we cancel it + if ( + existingDiscoveryDetails.type !== NodeDiscoveryType.None && + existingDiscoveryDetails.type < requestedDiscoveryType + ) { + mdnsScanner.cancelOperationalDeviceDiscovery(this.#sessions.fabricFor(address), address.nodeId); + this.#runningPeerDiscoveries.delete(address); + existingDiscoveryDetails.type = NodeDiscoveryType.None; + } + + const { type: runningDiscoveryType, promises } = existingDiscoveryDetails; + + // If we have a last known address try to reach the device directly when we are not already discovering + // In worst case parallel cases we do this step twice, but that's ok + if ( + operationalAddress !== undefined && + (runningDiscoveryType === NodeDiscoveryType.None || requestedDiscoveryType === NodeDiscoveryType.None) + ) { + const directReconnection = await this.#reconnectKnownAddress(address, operationalAddress, discoveryData); + if (directReconnection !== undefined) { + return directReconnection; + } + if (requestedDiscoveryType === NodeDiscoveryType.None) { + throw new DiscoveryError(`${address} is not reachable right now.`); + } + } + + if (promises !== undefined) { + if (runningDiscoveryType > requestedDiscoveryType) { + // We already run a "longer" discovery, so we know it is unreachable for now + throw new DiscoveryError(`${address} is not reachable right now and discovery already running.`); + } else { + // If we are already discovering this node, so we reuse promises + return await anyPromise(promises); + } + } + + const discoveryPromises = new Array<() => Promise>(); + let reconnectionPollingTimer: Timer | undefined; + + if (operationalAddress !== undefined) { + // Additionally to general discovery we also try to poll the formerly known operational address + if (requestedDiscoveryType === NodeDiscoveryType.FullDiscovery) { + const { promise, resolver, rejecter } = createPromise(); + + reconnectionPollingTimer = Time.getPeriodicTimer( + "Controller reconnect", + RECONNECTION_POLLING_INTERVAL_MS, + async () => { + try { + logger.debug(`Polling for device at ${serverAddressToString(operationalAddress)} ...`); + const result = await this.#reconnectKnownAddress( + address, + operationalAddress, + discoveryData, + ); + if (result !== undefined && reconnectionPollingTimer?.isRunning) { + reconnectionPollingTimer?.stop(); + mdnsScanner.cancelOperationalDeviceDiscovery( + this.#sessions.fabricFor(address), + address.nodeId, + ); + this.#runningPeerDiscoveries.delete(address); + resolver(result); + } + } catch (error) { + if (reconnectionPollingTimer?.isRunning) { + reconnectionPollingTimer?.stop(); + mdnsScanner.cancelOperationalDeviceDiscovery( + this.#sessions.fabricFor(address), + address.nodeId, + ); + this.#runningPeerDiscoveries.delete(address); + rejecter(error); + } + } + }, + ).start(); + + discoveryPromises.push(() => promise); + } + } + + discoveryPromises.push(async () => { + const scanResult = await ControllerDiscovery.discoverOperationalDevice( + this.#sessions.fabricFor(address), + address.nodeId, + mdnsScanner, + timeoutSeconds, + timeoutSeconds === undefined, + ); + const { timer } = this.#runningPeerDiscoveries.get(address) ?? {}; + timer?.stop(); + this.#runningPeerDiscoveries.delete(address); + + const { result } = await ControllerDiscovery.iterateServerAddresses( + [scanResult], + NoResponseTimeoutError, + async () => { + const device = mdnsScanner.getDiscoveredOperationalDevice( + this.#sessions.fabricFor(address), + address.nodeId, + ); + return device !== undefined ? [device] : []; + }, + async (operationalAddress, peer) => { + const result = await this.#pair(address, operationalAddress, peer); + await this.#addOrUpdatePeer(address, operationalAddress, { + ...discoveryData, + ...peer, + }); + return result; + }, + ); + + return result; + }); + + this.#runningPeerDiscoveries.set(address, { + type: requestedDiscoveryType, + promises: discoveryPromises, + timer: reconnectionPollingTimer, + }); + + return await anyPromise(discoveryPromises).finally(() => this.#runningPeerDiscoveries.delete(address)); + } + + async #reconnectKnownAddress( + address: PeerAddress, + operationalAddress: ServerAddressIp, + discoveryData?: DiscoveryData, + expectedProcessingTimeMs?: number, + ): Promise { + address = PeerAddress(address); + + const { ip, port } = operationalAddress; + try { + logger.debug( + `Resuming connection to ${PeerAddress(address)} at ${ip}:${port}${ + expectedProcessingTimeMs !== undefined + ? ` with expected processing time of ${expectedProcessingTimeMs}ms` + : "" + }`, + ); + const channel = await this.#pair(address, operationalAddress, discoveryData, expectedProcessingTimeMs); + await this.#addOrUpdatePeer(address, operationalAddress); + return channel; + } catch (error) { + if (error instanceof NoResponseTimeoutError) { + logger.debug( + `Failed to resume connection to ${address} connection with ${ip}:${port}, discover the node:`, + error, + ); + // We remove all sessions, this also informs the PairedNode class + await this.#sessions.removeAllSessionsForNode(address); + return undefined; + } else { + throw error; + } + } + } + + /** Pair with an operational device (already commissioned) and establish a CASE session. */ + async #pair( + address: PeerAddress, + operationalServerAddress: ServerAddressIp, + discoveryData?: DiscoveryData, + expectedProcessingTimeMs?: number, + ) { + const { ip, port } = operationalServerAddress; + // Do CASE pairing + const isIpv6Address = isIPv6(ip); + const operationalInterface = this.#netInterfaces.interfaceFor( + ChannelType.UDP, + isIpv6Address ? "::" : "0.0.0.0", + ); + + if (operationalInterface === undefined) { + throw new PairRetransmissionLimitReachedError( + `IPv${ + isIpv6Address ? "6" : "4" + } interface not initialized for port ${port}. Cannot use ${ip} for pairing.`, + ); + } + + const operationalChannel = await operationalInterface.openChannel(operationalServerAddress); + const { sessionParameters } = this.#sessions.findResumptionRecordByAddress(address) ?? {}; + const unsecureSession = this.#sessions.createInsecureSession({ + // Use the session parameters from MDNS announcements when available and rest is assumed to be fallbacks + sessionParameters: { + idleIntervalMs: discoveryData?.SII ?? sessionParameters?.idleIntervalMs, + activeIntervalMs: discoveryData?.SAI ?? sessionParameters?.activeIntervalMs, + activeThresholdMs: discoveryData?.SAT ?? sessionParameters?.activeThresholdMs, + }, + isInitiator: true, + }); + const operationalUnsecureMessageExchange = new MessageChannel(operationalChannel, unsecureSession); + let operationalSecureSession; + try { + const exchange = this.#exchanges.initiateExchangeWithChannel( + operationalUnsecureMessageExchange, + SECURE_CHANNEL_PROTOCOL_ID, + ); + + try { + operationalSecureSession = await this.#caseClient.pair( + exchange, + this.#sessions.fabricFor(address), + address.nodeId, + expectedProcessingTimeMs, + ); + } catch (e) { + await exchange.close(); + throw e; + } + } catch (e) { + NoResponseTimeoutError.accept(e); + + // Convert error + throw new PairRetransmissionLimitReachedError(e.message); + } + await unsecureSession.destroy(); + const channel = new MessageChannel(operationalChannel, operationalSecureSession); + await this.#channels.setChannel(address, channel); + return channel; + } + + /** + * Obtain an operational address for a logical address from cache. + */ + #knownOperationalAddressFor(address: PeerAddress) { + const mdnsScanner = this.#scanners.scannerFor(ChannelType.UDP) as MdnsScanner | undefined; + const discoveredAddresses = mdnsScanner?.getDiscoveredOperationalDevice( + this.#sessions.fabricFor(address), + address.nodeId, + ); + const lastKnownAddress = this.#getLastOperationalAddress(address); + + if ( + lastKnownAddress !== undefined && + discoveredAddresses !== undefined && + discoveredAddresses.addresses.some( + ({ ip, port }) => ip === lastKnownAddress.ip && port === lastKnownAddress.port, + ) + ) { + // We found the same address, so assume somehow cached response because we just tried to connect, + // and it failed, so clear list + discoveredAddresses.addresses.length = 0; + } + + // Try to use first result for one last try before we need to reconnect + return discoveredAddresses?.addresses[0]; + } + + async #addOrUpdatePeer( + address: PeerAddress, + operationalServerAddress: ServerAddressIp, + discoveryData?: DiscoveryData, + ) { + let peer = this.#peersByAddress.get(address); + if (peer === undefined) { + peer = { address }; + this.#peers.add(peer); + } + peer.operationalAddress = operationalServerAddress; + if (discoveryData !== undefined) { + peer.discoveryData = { + ...peer.discoveryData, + ...discoveryData, + }; + } + await this.#store.updatePeer(peer); + } + + #getLastOperationalAddress(address: PeerAddress) { + return this.#peersByAddress.get(address)?.operationalAddress; + } + + #handleResubmissionStarted(session: Session) { + const { associatedFabric: fabric, peerNodeId: nodeId } = session; + if (fabric === undefined || nodeId === undefined) { + return; + } + const address = fabric.addressOf(nodeId); + if (this.#runningPeerDiscoveries.has(address)) { + // We already discover for this node, so we do not need to start a new discovery + return; + } + this.#runningPeerDiscoveries.set(address, { type: NodeDiscoveryType.RetransmissionDiscovery }); + this.#scanners + .scannerFor(ChannelType.UDP) + ?.findOperationalDevice(fabric, nodeId, RETRANSMISSION_DISCOVERY_TIMEOUT_MS, true) + .catch(error => { + logger.error(`Failed to discover ${address} after resubmission started.`, error); + }) + .finally(() => { + if (this.#runningPeerDiscoveries.get(address)?.type === NodeDiscoveryType.RetransmissionDiscovery) { + this.#runningPeerDiscoveries.delete(address); + } + }); + } +} diff --git a/packages/protocol/src/peer/PeerStore.ts b/packages/protocol/src/peer/PeerStore.ts new file mode 100644 index 0000000000..695dffaaa1 --- /dev/null +++ b/packages/protocol/src/peer/PeerStore.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MaybePromise } from "@matter.js/general"; +import { OperationalPeer } from "./OperationalPeer.js"; +import { PeerAddress } from "./PeerAddress.js"; +import type { PeerSet } from "./PeerSet.js"; + +/** + * The interface {@link PeerSet} uses for persisting operational information. + */ +export abstract class PeerStore { + abstract loadPeers(): MaybePromise>; + abstract updatePeer(peer: OperationalPeer): MaybePromise; + abstract deletePeer(address: PeerAddress): MaybePromise; +} diff --git a/packages/protocol/src/peer/index.ts b/packages/protocol/src/peer/index.ts new file mode 100644 index 0000000000..1512ae4b91 --- /dev/null +++ b/packages/protocol/src/peer/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./ControllerCommissioner.js"; +export * from "./ControllerCommissioningFlow.js"; +export * from "./ControllerDiscovery.js"; +export * from "./OperationalPeer.js"; +export * from "./PeerAddress.js"; +export * from "./PeerSet.js"; +export * from "./PeerStore.js"; diff --git a/packages/protocol/src/protocol/ChannelManager.ts b/packages/protocol/src/protocol/ChannelManager.ts index ff4b6f8a0a..2833d98581 100644 --- a/packages/protocol/src/protocol/ChannelManager.ts +++ b/packages/protocol/src/protocol/ChannelManager.ts @@ -4,9 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Channel, Logger, MatterError } from "#general"; -import { NodeId } from "#types"; -import { Fabric } from "../fabric/Fabric.js"; +import { Channel, Environment, Environmental, Logger, MatterError } from "#general"; +import { PeerAddress, PeerAddressMap } from "#peer/PeerAddress.js"; import { SecureSession } from "../session/SecureSession.js"; import { Session } from "../session/Session.js"; import { MessageChannel } from "./ExchangeManager.js"; @@ -16,7 +15,7 @@ const logger = Logger.get("ChannelManager"); export class NoChannelError extends MatterError {} export class ChannelManager { - readonly #channels = new Map(); + readonly #channels = new PeerAddressMap(); readonly #paseChannels = new Map(); #caseSessionsPerFabricAndNode: number; @@ -25,12 +24,14 @@ export class ChannelManager { this.#caseSessionsPerFabricAndNode = caseSessionsPerFabricAndNode; } - set caseSessionsPerFabricAndNode(count: number) { - this.#caseSessionsPerFabricAndNode = count; + [Environmental.create](env: Environment) { + const instance = new ChannelManager(); + env.set(ChannelManager, this); + return instance; } - #getChannelKey(fabric: Fabric, nodeId: NodeId) { - return `${fabric.fabricIndex}/${nodeId}`; + set caseSessionsPerFabricAndNode(count: number) { + this.#caseSessionsPerFabricAndNode = count; } #findLeastActiveChannel(channels: MessageChannel[]) { @@ -43,12 +44,11 @@ export class ChannelManager { return oldest; } - async setChannel(fabric: Fabric, nodeId: NodeId, channel: MessageChannel) { - channel.closeCallback = async () => this.removeChannel(fabric, nodeId, channel.session); - const channelsKey = this.#getChannelKey(fabric, nodeId); - const currentChannels = this.#channels.get(channelsKey) ?? []; + async setChannel(address: PeerAddress, channel: MessageChannel) { + channel.closeCallback = async () => this.removeChannel(address, channel.session); + const currentChannels = this.#channels.get(address) ?? []; currentChannels.push(channel); - this.#channels.set(channelsKey, currentChannels); + this.#channels.set(address, currentChannels); if (currentChannels.length > this.#caseSessionsPerFabricAndNode) { const oldestChannel = this.#findLeastActiveChannel(currentChannels); @@ -57,25 +57,23 @@ export class ChannelManager { if (channel.session.id !== oldSession.id) { await oldSession.destroy(false, false); } - logger.info( - `Close oldest channel for fabric ${fabric.fabricIndex} node ${nodeId} (from session ${oldSession.id})`, - ); + logger.info(`Close oldest channel for fabric ${PeerAddress(address)} (from session ${oldSession.id})`); await oldestChannel.close(); } } - hasChannel(fabric: Fabric, nodeId: NodeId) { - return this.#channels.get(this.#getChannelKey(fabric, nodeId))?.length; + hasChannel(address: PeerAddress) { + return !!this.#channels.get(address)?.length; } - getChannel(fabric: Fabric, nodeId: NodeId, session?: Session) { - let results = this.#channels.get(this.#getChannelKey(fabric, nodeId)) ?? []; + getChannel(address: PeerAddress, session?: Session) { + let results = this.#channels.get(address) ?? []; if (session !== undefined) { results = results.filter(channel => channel.session.id === session.id); } if (results.length === 0) throw new NoChannelError( - `Can't find a channel to node ${nodeId}${session !== undefined ? ` and session ${session.id}` : ""}`, + `Can't find a channel to ${PeerAddress(address)}${session !== undefined ? ` session ${session.id}` : ""}`, ); return results[results.length - 1]; // Return the latest added channel (or the one belonging to the session requested) } @@ -91,22 +89,20 @@ export class ChannelManager { if (fabric === undefined) { return this.#paseChannels.get(session); } - return this.getChannel(fabric, nodeId, session); + return this.getChannel(fabric.addressOf(nodeId), session); } return this.#paseChannels.get(session); } - async removeAllNodeChannels(fabric: Fabric, nodeId: NodeId) { - const channelsKey = this.#getChannelKey(fabric, nodeId); - const channelsToRemove = this.#channels.get(channelsKey) ?? []; + async removeAllNodeChannels(address: PeerAddress) { + const channelsToRemove = this.#channels.get(address) ?? []; for (const channel of channelsToRemove) { await channel.close(); } } - async removeChannel(fabric: Fabric, nodeId: NodeId, session: Session) { - const channelsKey = this.#getChannelKey(fabric, nodeId); - const fabricChannels = this.#channels.get(channelsKey) ?? []; + async removeChannel(address: PeerAddress, session: Session) { + const fabricChannels = this.#channels.get(address) ?? []; const channelEntryIndex = fabricChannels.findIndex( ({ session: entrySession }) => entrySession.id === session.id, ); @@ -119,7 +115,7 @@ export class ChannelManager { return; } await channelEntry.close(); - this.#channels.set(channelsKey, fabricChannels); + this.#channels.set(address, fabricChannels); } private getOrCreateAsPaseChannel(byteArrayChannel: Channel, session: Session) { @@ -144,17 +140,16 @@ export class ChannelManager { } // Try to get + const address = fabric.addressOf(nodeId); try { - return this.getChannel(fabric, nodeId, session); + return this.getChannel(address, session); } catch (e) { NoChannelError.accept(e); } // Need to create - const result = new MessageChannel(byteArrayChannel, session, async () => - this.removeChannel(fabric, nodeId, session), - ); - await this.setChannel(fabric, nodeId, result); + const result = new MessageChannel(byteArrayChannel, session, async () => this.removeChannel(address, session)); + await this.setChannel(address, result); return result; } diff --git a/packages/protocol/src/protocol/DeviceAdvertiser.ts b/packages/protocol/src/protocol/DeviceAdvertiser.ts index c253b651f6..3a76115feb 100644 --- a/packages/protocol/src/protocol/DeviceAdvertiser.ts +++ b/packages/protocol/src/protocol/DeviceAdvertiser.ts @@ -152,7 +152,7 @@ export class DeviceAdvertiser { if (fabrics.length) { let fabricsWithoutSessions = 0; for (const fabric of fabrics) { - const session = this.#context.sessions.getSessionForNode(fabric, fabric.rootNodeId); + const session = this.#context.sessions.getSessionForNode(fabric.addressOf(fabric.rootNodeId)); if (session === undefined || !session.isSecure || session.subscriptions.size === 0) { fabricsWithoutSessions++; logger.debug("Announcing", Diagnostic.dict({ fabric: fabric.fabricId })); diff --git a/packages/protocol/src/protocol/DeviceCommissioner.ts b/packages/protocol/src/protocol/DeviceCommissioner.ts index efdd8f9d77..06a63279a1 100644 --- a/packages/protocol/src/protocol/DeviceCommissioner.ts +++ b/packages/protocol/src/protocol/DeviceCommissioner.ts @@ -22,7 +22,7 @@ import { import { SecureChannelProtocol } from "#securechannel/SecureChannelProtocol.js"; import { PaseServer, SessionManager } from "#session/index.js"; import { CommissioningOptions, StatusCode, StatusResponseError } from "#types"; -import type { ControllerCommissioner } from "./ControllerCommissioner.js"; +import type { ControllerCommissioningFlow } from "../peer/ControllerCommissioningFlow.js"; import { DeviceAdvertiser } from "./DeviceAdvertiser.js"; const logger = Logger.get("DeviceCommissioner"); @@ -48,7 +48,7 @@ export interface DeviceCommissionerContext { /** * Implements commissioning for devices. * - * Note this implements commissioning for a *local* device; use {@link ControllerCommissioner} to commission a *remote* + * Note this implements commissioning for a *local* device; use {@link ControllerCommissioningFlow} to commission a *remote* * device. */ export class DeviceCommissioner { diff --git a/packages/protocol/src/protocol/ExchangeManager.ts b/packages/protocol/src/protocol/ExchangeManager.ts index b1fa09a6b0..fada4d13f2 100644 --- a/packages/protocol/src/protocol/ExchangeManager.ts +++ b/packages/protocol/src/protocol/ExchangeManager.ts @@ -19,9 +19,9 @@ import { TransportInterfaceSet, UdpInterface, } from "#general"; +import { PeerAddress } from "#peer/PeerAddress.js"; import { INTERACTION_PROTOCOL_ID, NodeId, SECURE_CHANNEL_PROTOCOL_ID, SecureMessageType } from "#types"; import { Message, MessageCodec, SessionType } from "../codec/MessageCodec.js"; -import { Fabric } from "../fabric/Fabric.js"; import { SecureChannelMessenger } from "../securechannel/SecureChannelMessenger.js"; import { SecureSession } from "../session/SecureSession.js"; import { Session } from "../session/Session.js"; @@ -162,8 +162,8 @@ export class ExchangeManager { this.#protocols.set(protocol.getId(), protocol); } - initiateExchange(fabric: Fabric, nodeId: NodeId, protocolId: number) { - return this.initiateExchangeWithChannel(this.#channelManager.getChannel(fabric, nodeId), protocolId); + initiateExchange(address: PeerAddress, protocolId: number) { + return this.initiateExchangeWithChannel(this.#channelManager.getChannel(address), protocolId); } initiateExchangeWithChannel(channel: MessageChannel, protocolId: number) { @@ -402,7 +402,7 @@ export class ExchangeManager { return { channel, localSessionParameters: this.#sessionManager.sessionParameters, - resubmissionStarted: nodeId => this.#sessionManager.resubmissionStarted.emit(nodeId), + resubmissionStarted: () => this.#sessionManager.resubmissionStarted.emit(channel.session), }; } diff --git a/packages/protocol/src/protocol/MessageExchange.ts b/packages/protocol/src/protocol/MessageExchange.ts index 0b64ed1a88..68a617d9a0 100644 --- a/packages/protocol/src/protocol/MessageExchange.ts +++ b/packages/protocol/src/protocol/MessageExchange.ts @@ -109,7 +109,7 @@ export const MATTER_MESSAGE_OVERHEAD = 26 + 12 + CRYPTO_AEAD_MIC_LENGTH_BYTES; */ export interface MessageExchangeContext { channel: MessageChannel; - resubmissionStarted(nodeId?: NodeId): void; + resubmissionStarted(): void; localSessionParameters: SessionParameters; } @@ -539,7 +539,7 @@ export class MessageExchange { this.session.notifyActivity(false); if (this.#retransmissionCounter === 1) { - this.context.resubmissionStarted(this.session.nodeId); + this.context.resubmissionStarted(); } const resubmissionBackoffTime = this.#getResubmissionBackOffTime(this.#retransmissionCounter); logger.debug( diff --git a/packages/protocol/src/protocol/NodeDiscoverer.ts b/packages/protocol/src/protocol/NodeDiscoverer.ts deleted file mode 100644 index 0a56155835..0000000000 --- a/packages/protocol/src/protocol/NodeDiscoverer.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @license - * Copyright 2022-2024 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Scanner } from "#common/Scanner.js"; -import { Fabric } from "#fabric/Fabric.js"; -import { Channel, Environment, Environmental, NotImplementedError, TransportInterfaceSet } from "#general"; -import { Session } from "#session/Session.js"; -import { SessionManager } from "#session/SessionManager.js"; -import { NodeId } from "#types"; - -/** - * Interfaces {@link NodeDiscoverer} with other components. - */ -export interface NodeDiscovererContext { - sessions: SessionManager; - transportInterfaces: TransportInterfaceSet; -} - -/** - * Connection generated after peer discovery. - */ -export interface PeerConnection { - session: Session; - channel: Channel; -} - -/** - * Performs discovery of other nodes. - * - * TODO - this is placeholder destination for node discovery logic - */ -export class NodeDiscoverer { - readonly #transportInterfaces: TransportInterfaceSet; - readonly #sessions: SessionManager; - readonly #scanners = new Set(); - - constructor(context: NodeDiscovererContext) { - const { transportInterfaces, sessions } = context; - - this.#sessions = sessions; - this.#transportInterfaces = transportInterfaces; - } - - [Environmental.create](env: Environment) { - const instance = new NodeDiscoverer({ - transportInterfaces: env.get(TransportInterfaceSet), - sessions: env.get(SessionManager), - }); - env.set(NodeDiscoverer, instance); - return instance; - } - - /** - * Interfaces to the transports this NodeFinder supports. This set may be modified. - */ - get transportInterfaces() { - return this.#transportInterfaces; - } - - /** - * The scanners this NodeFinder supports. - */ - get scanners() { - return this.#scanners; - } - - /** TODO - remove, just to make codeql happy */ - get sessions() { - return this.#sessions; - } - - /** - * Find a node for operational (non-commissioning) purposes. - */ - async connectToPeer(_fabric: Fabric, _nodeId: NodeId, _timeoutSeconds = 5): Promise { - throw new NotImplementedError("NodeDiscoverer is a WIP"); - } -} diff --git a/packages/protocol/src/protocol/index.ts b/packages/protocol/src/protocol/index.ts index d0a30a167b..aae0457aed 100644 --- a/packages/protocol/src/protocol/index.ts +++ b/packages/protocol/src/protocol/index.ts @@ -5,13 +5,10 @@ */ export * from "./ChannelManager.js"; -export * from "./ControllerCommissioner.js"; -export * from "./ControllerDiscovery.js"; export * from "./DeviceAdvertiser.js"; export * from "./DeviceCommissioner.js"; export * from "./ExchangeManager.js"; export * from "./MessageCounter.js"; export * from "./MessageExchange.js"; export * from "./MessageReceptionState.js"; -export * from "./NodeDiscoverer.js"; export * from "./ProtocolHandler.js"; diff --git a/packages/protocol/src/session/SecureSession.ts b/packages/protocol/src/session/SecureSession.ts index ce98b5df42..d7ef2f2eb4 100644 --- a/packages/protocol/src/session/SecureSession.ts +++ b/packages/protocol/src/session/SecureSession.ts @@ -16,7 +16,8 @@ import { MatterFlowError, } from "#general"; import { Subscription } from "#interaction/Subscription.js"; -import { CaseAuthenticatedTag, NodeId, StatusCode, StatusResponseError } from "#types"; +import { PeerAddress } from "#peer/PeerAddress.js"; +import { CaseAuthenticatedTag, FabricIndex, NodeId, StatusCode, StatusResponseError } from "#types"; import { DecodedMessage, DecodedPacket, Message, MessageCodec, Packet } from "../codec/MessageCodec.js"; import { Fabric } from "../fabric/Fabric.js"; import { MessageCounter } from "../protocol/MessageCounter.js"; @@ -312,6 +313,26 @@ export class SecureSession extends Session { } } + /** + * The peer node's address. + */ + get peerAddress() { + return PeerAddress({ + fabricIndex: this.#fabric?.fabricIndex ?? FabricIndex.NO_FABRIC, + nodeId: this.#peerNodeId, + }); + } + + /** + * Indicates whether a peer matches a specific address. + */ + peerIs(address: PeerAddress) { + return ( + (this.#fabric?.fabricIndex ?? FabricIndex.NO_FABRIC) === address.fabricIndex && + this.#peerNodeId === address.nodeId + ); + } + private generateNonce(securityFlags: number, messageId: number, nodeId: NodeId) { const writer = new DataWriter(Endian.Little); writer.writeUInt8(securityFlags); diff --git a/packages/protocol/src/session/SessionManager.ts b/packages/protocol/src/session/SessionManager.ts index 477117b43f..03388be6c6 100644 --- a/packages/protocol/src/session/SessionManager.ts +++ b/packages/protocol/src/session/SessionManager.ts @@ -23,6 +23,7 @@ import { } from "#general"; import { Subscription } from "#interaction/Subscription.js"; import { Specification } from "#model"; +import { PeerAddress, PeerAddressMap } from "#peer/PeerAddress.js"; import { CaseAuthenticatedTag, DEFAULT_MAX_PATHS_PER_INVOKE, FabricId, FabricIndex, NodeId } from "#types"; import { Fabric } from "../fabric/Fabric.js"; import { MessageCounter } from "../protocol/MessageCounter.js"; @@ -33,6 +34,7 @@ import { FALLBACK_INTERACTIONMODEL_REVISION, FALLBACK_MAX_PATHS_PER_INVOKE, FALLBACK_SPECIFICATION_VERSION, + Session, SESSION_ACTIVE_INTERVAL_MS, SESSION_ACTIVE_THRESHOLD_MS, SESSION_IDLE_INTERVAL_MS, @@ -109,11 +111,11 @@ export class SessionManager { readonly #insecureSessions = new Map(); readonly #sessions = new BasicSet(); #nextSessionId = Crypto.getRandomUInt16(); - #resumptionRecords = new Map(); + #resumptionRecords = new PeerAddressMap(); readonly #globalUnencryptedMessageCounter = new MessageCounter(); readonly #subscriptionsChanged = Observable<[session: SecureSession, subscription: Subscription]>(); readonly #sessionParameters; - readonly #resubmissionStarted = new Observable<[nodeId?: NodeId]>(); + readonly #resubmissionStarted = new Observable<[session: Session]>(); readonly #construction: Construction; readonly #observers = new ObserverGroup(); readonly #subscriptionUpdateMutex = new Mutex(this); @@ -124,7 +126,7 @@ export class SessionManager { // When fabric is removed, also remove the resumption record this.#observers.on(context.fabrics.events.deleted, async fabric => - this.removeResumptionRecord(fabric.rootNodeId), + this.removeResumptionRecord(fabric.addressOf(fabric.rootNodeId)), ); this.#construction = Construction(this, () => this.#initialize()); @@ -183,6 +185,13 @@ export class SessionManager { return this.#resubmissionStarted; } + /** + * Convenience function for accessing a fabric by address. + */ + fabricFor(address: FabricIndex | PeerAddress) { + return this.#context.fabrics.for(address); + } + /** * @deprecated */ @@ -276,10 +285,10 @@ export class SessionManager { return session; } - async removeResumptionRecord(peerNodeId: NodeId) { + async removeResumptionRecord(address: PeerAddress) { await this.#construction; - this.#resumptionRecords.delete(peerNodeId); + this.#resumptionRecords.delete(address); await this.storeResumptionRecords(); } @@ -332,24 +341,24 @@ export class SessionManager { ) as SecureSession; } - getSessionForNode(fabric: Fabric, nodeId: NodeId) { + getSessionForNode(address: PeerAddress) { this.#construction.assert(); //TODO: It can have multiple sessions for one node ... return [...this.#sessions].find(session => { if (!session.isSecure) return false; const secureSession = session; - return secureSession.fabric?.fabricId === fabric.fabricId && secureSession.peerNodeId === nodeId; + return secureSession.peerIs(address); }); } - async removeAllSessionsForNode(nodeId: NodeId, sendClose = false) { + async removeAllSessionsForNode(address: PeerAddress, sendClose = false) { await this.#construction; for (const session of this.#sessions) { if (!session.isSecure) continue; const secureSession = session; - if (secureSession.peerNodeId === nodeId) { + if (secureSession.peerIs(address)) { await secureSession.destroy(sendClose, false); } } @@ -380,24 +389,24 @@ export class SessionManager { return [...this.#resumptionRecords.values()].find(record => Bytes.areEqual(record.resumptionId, resumptionId)); } - findResumptionRecordByNodeId(nodeId: NodeId) { + findResumptionRecordByAddress(address: PeerAddress) { this.#construction.assert(); - return this.#resumptionRecords.get(nodeId); + return this.#resumptionRecords.get(address); } async saveResumptionRecord(resumptionRecord: ResumptionRecord) { await this.#construction; - this.#resumptionRecords.set(resumptionRecord.peerNodeId, resumptionRecord); + this.#resumptionRecords.set(resumptionRecord.fabric.addressOf(resumptionRecord.peerNodeId), resumptionRecord); await this.storeResumptionRecords(); } async updateFabricForResumptionRecords(fabric: Fabric) { await this.#construction; - const record = this.#resumptionRecords.get(fabric.rootNodeId); + const record = this.#resumptionRecords.get(fabric.addressOf(fabric.rootNodeId)); if (record === undefined) { throw new MatterFlowError("Resumption record not found. Should never happen."); } - this.#resumptionRecords.set(fabric.rootNodeId, { ...record, fabric }); + this.#resumptionRecords.set(fabric.addressOf(fabric.rootNodeId), { ...record, fabric }); await this.storeResumptionRecords(); } @@ -407,11 +416,11 @@ export class SessionManager { "resumptionRecords", [...this.#resumptionRecords].map( ([ - nodeId, + address, { sharedSecret, resumptionId, peerNodeId, fabric, sessionParameters, caseAuthenticatedTags }, ]) => ({ - nodeId, + nodeId: address.nodeId, sharedSecret, resumptionId, fabricId: fabric.fabricId, @@ -462,7 +471,7 @@ export class SessionManager { logger.error("fabric not found for resumption record", fabricId); return; } - this.#resumptionRecords.set(nodeId, { + this.#resumptionRecords.set(fabric.addressOf(nodeId), { sharedSecret, resumptionId, fabric, diff --git a/packages/protocol/src/session/case/CaseClient.ts b/packages/protocol/src/session/case/CaseClient.ts index 11cfd8c10a..279523f770 100644 --- a/packages/protocol/src/session/case/CaseClient.ts +++ b/packages/protocol/src/session/case/CaseClient.ts @@ -45,7 +45,7 @@ export class CaseClient { // Send sigma1 let sigma1Bytes; - let resumptionRecord = this.#sessions.findResumptionRecordByNodeId(peerNodeId); + let resumptionRecord = this.#sessions.findResumptionRecordByAddress(fabric.addressOf(peerNodeId)); if (resumptionRecord !== undefined) { const { sharedSecret, resumptionId } = resumptionRecord; const resumeKey = await Crypto.hkdf(