From 76d8de44d4728ff97360e2f623796bcd43f7e25a Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Sun, 21 Jan 2024 21:47:30 +0100 Subject: [PATCH] fix: use separate S2 keys for ZWLR (#6620) --- docs/api/driver.md | 14 +- docs/getting-started/long-range.md | 7 +- packages/cc/src/cc/Security2CC.ts | 124 ++++++++++-------- packages/host/src/ZWaveHost.ts | 4 +- packages/host/src/mocks.ts | 1 + packages/testing/src/MockController.ts | 1 + .../zwave-js/src/lib/controller/Controller.ts | 30 +++-- packages/zwave-js/src/lib/driver/Driver.ts | 73 +++++++++-- .../src/lib/driver/MessageGenerators.ts | 4 +- .../zwave-js/src/lib/driver/ZWaveOptions.ts | 14 +- packages/zwave-js/src/lib/node/Node.ts | 94 ++++++------- 11 files changed, 232 insertions(+), 134 deletions(-) diff --git a/docs/api/driver.md b/docs/api/driver.md index 0975d8602be1..41798e482be9 100644 --- a/docs/api/driver.md +++ b/docs/api/driver.md @@ -828,15 +828,23 @@ interface ZWaveOptions extends ZWaveHostOptions { }; /** - * Specify the security keys to use for encryption. Each one must be a Buffer of exactly 16 bytes. + * Specify the security keys to use for encryption (Z-Wave Classic). Each one must be a Buffer of exactly 16 bytes. */ securityKeys?: { - S2_Unauthenticated?: Buffer; - S2_Authenticated?: Buffer; S2_AccessControl?: Buffer; + S2_Authenticated?: Buffer; + S2_Unauthenticated?: Buffer; S0_Legacy?: Buffer; }; + /** + * Specify the security keys to use for encryption (Z-Wave Long Range). Each one must be a Buffer of exactly 16 bytes. + */ + securityKeysLongRange?: { + S2_AccessControl?: Buffer; + S2_Authenticated?: Buffer; + }; + /** * Defines the callbacks that are necessary to trigger user interaction during S2 inclusion. * If not given, nodes won't be included using S2, unless matching provisioning entries exists. diff --git a/docs/getting-started/long-range.md b/docs/getting-started/long-range.md index 519b3b0b991a..8a71b1ad458f 100644 --- a/docs/getting-started/long-range.md +++ b/docs/getting-started/long-range.md @@ -5,7 +5,8 @@ Z-Wave Long Range (ZWLR) is an addition to Z-Wave, that allows for a massively i There are a few things applications need to be aware of to support Long Range using Z-Wave JS. 1. ZWLR node IDs start at 256. This can be used to distinguish between ZWLR and classic Z-Wave nodes. -2. ZWLR inclusion works exclusively through [Smart Start](getting-started/security-s2#smartstart). +1. ZWLR has only two security classes, S2 Access Control and S2 Authenticated. Both must use a different security key than their Z-Wave Classic counterparts. To configure them, use the `securityKeysLongRange` property of the [`ZWaveOptions`](../api/driver#zwaveoptions) +1. ZWLR inclusion works exclusively through [Smart Start](../getting-started/security-s2#smartstart). \ - ZWLR nodes advertise support for Long Range in the `supportedProtocols` field of the `QRProvisioningInformation` object (see [here](api/utils#other-qr-codes)). When this field is present, the user **MUST** have the choice between the advertised protocols. Currently this means deciding between including the node via Z-Wave Classic (mesh) or Z-Wave Long Range (no mesh).\ - To include a node via ZWLR, set the `protocol` field of the `PlannedProvisioningEntry` to `Protocols.ZWaveLongRange` when [provisioning the node](api/controller#provisionsmartstartnode). + ZWLR nodes advertise support for Long Range in the `supportedProtocols` field of the `QRProvisioningInformation` object (see [here](../api/utils#other-qr-codes)). When this field is present, the user **MUST** have the choice between the advertised protocols. Currently this means deciding between including the node via Z-Wave Classic (mesh) or Z-Wave Long Range (no mesh).\ + To include a node via ZWLR, set the `protocol` field of the `PlannedProvisioningEntry` to `Protocols.ZWaveLongRange` when [provisioning the node](../api/controller#provisionsmartstartnode). diff --git a/packages/cc/src/cc/Security2CC.ts b/packages/cc/src/cc/Security2CC.ts index afde5e81acd3..1f2b3382d4f4 100644 --- a/packages/cc/src/cc/Security2CC.ts +++ b/packages/cc/src/cc/Security2CC.ts @@ -4,6 +4,7 @@ import { type MessageOrCCLogEntry, MessagePriority, type MessageRecord, + type MulticastDestination, type S2SecurityClass, SECURITY_S2_AUTH_TAG_LENGTH, SPANState, @@ -41,6 +42,7 @@ import { pick, } from "@zwave-js/shared/safe"; import { wait } from "alcalzone-shared/async"; +import { isArray } from "alcalzone-shared/typeguards"; import { CCAPI } from "../lib/API"; import { type CCCommandOptions, @@ -116,21 +118,35 @@ function getAuthenticationData( return ret; } +function getSecurityManager( + host: ZWaveHost, + destination: MulticastDestination | number, +): SecurityManager2 | undefined { + const longRange = isLongRangeNodeId( + isArray(destination) ? destination[0]! : destination, + ); + return longRange + ? host.securityManagerLR + : host.securityManager2; +} + /** Validates that a sequence number is not a duplicate and updates the SPAN table if it is accepted. Returns the previous sequence number if there is one. */ function validateSequenceNumber( this: Security2CC, sequenceNumber: number, ): number | undefined { + const securityManager = getSecurityManager(this.host, this.nodeId); + validatePayload.withReason( `Duplicate command (sequence number ${sequenceNumber})`, )( - !this.host.securityManager2!.isDuplicateSinglecast( + !securityManager!.isDuplicateSinglecast( this.nodeId as number, sequenceNumber, ), ); // Not a duplicate, store it - return this.host.securityManager2!.storeSequenceNumber( + return securityManager!.storeSequenceNumber( this.nodeId as number, sequenceNumber, ); @@ -143,7 +159,7 @@ function assertSecurity(this: Security2CC, options: CommandClassOptions): void { `Secure commands (S2) can only be ${verb} when the controller's node id is known!`, ZWaveErrorCodes.Driver_NotReady, ); - } else if (!this.host.securityManager2) { + } else if (!getSecurityManager(this.host, this.nodeId)) { throw new ZWaveError( `Secure commands (S2) can only be ${verb} when the network keys are configured!`, ZWaveErrorCodes.Driver_NoSecurity, @@ -185,14 +201,19 @@ export class Security2CCAPI extends CCAPI { this.assertPhysicalEndpoint(this.endpoint); - if (!this.applHost.securityManager2) { + const securityManager = getSecurityManager( + this.applHost, + this.endpoint.nodeId, + ); + + if (!securityManager) { throw new ZWaveError( `Nonces can only be sent if secure communication is set up!`, ZWaveErrorCodes.Driver_NoSecurity, ); } - const receiverEI = this.applHost.securityManager2.generateNonce( + const receiverEI = securityManager.generateNonce( this.endpoint.nodeId, ); @@ -221,7 +242,7 @@ export class Security2CCAPI extends CCAPI { } catch (e) { if (isTransmissionError(e)) { // The nonce could not be sent, invalidate it - this.applHost.securityManager2.deleteNonce( + securityManager.deleteNonce( this.endpoint.nodeId, ); return false; @@ -515,6 +536,7 @@ export class Security2CC extends CommandClass { ).withOptions({ priority: MessagePriority.NodeQuery, }); + const securityManager = getSecurityManager(applHost, node.id); // Only on the highest security class the response includes the supported commands const secClass = node.getHighestSecurityClass(); @@ -556,7 +578,7 @@ export class Security2CC extends CommandClass { // If no key is configured for this security class, skip it if ( - !this.host.securityManager2?.hasKeysForSecurityClass(secClass) + !securityManager?.hasKeysForSecurityClass(secClass) ) { applHost.controllerLog.logNode(node.id, { endpoint: endpoint.index, @@ -883,12 +905,6 @@ export type MulticastContext = testCCResponseForMessageEncapsulation, ) export class Security2CCMessageEncapsulation extends Security2CC { - // Define the securityManager as existing - // We check it in the constructor - declare host: ZWaveHost & { - securityManager2: SecurityManager2; - }; - public constructor( host: ZWaveHost, options: @@ -899,6 +915,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { // Make sure that we can send/receive secure commands assertSecurity.call(this, options); + this.securityManager = getSecurityManager(host, this.nodeId)!; if (gotDeserializationOptions(options)) { validatePayload(this.payload.length >= 2); @@ -973,7 +990,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { | ReturnType | undefined; if (ctx.isMulticast) { - mpanState = this.host.securityManager2.getPeerMPAN( + mpanState = this.securityManager.getPeerMPAN( sendingNodeId, ctx.groupId, ); @@ -987,7 +1004,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { // When a node receives a singlecast message after a multicast group was marked out of sync, // it must forget about the group. if (ctx.groupId == undefined) { - this.host.securityManager2.resetOutOfSyncMPANs( + this.securityManager.resetOutOfSyncMPANs( sendingNodeId, ); } @@ -1017,7 +1034,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { // For incoming multicast commands, make sure we have an MPAN if (mpanState?.type !== MPANState.MPAN) { // If we don't, mark the MPAN as out of sync, so we can respond accordingly on the singlecast followup - this.host.securityManager2.storePeerMPAN( + this.securityManager.storePeerMPAN( sendingNodeId, ctx.groupId, { type: MPANState.OutOfSync }, @@ -1035,7 +1052,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { ); } else { // Decrypt payload and verify integrity - const spanState = this.host.securityManager2.getSPANState( + const spanState = this.securityManager.getSPANState( sendingNodeId, ); @@ -1086,7 +1103,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { if (!authOK || !plaintext) { if (ctx.isMulticast) { // Mark the MPAN as out of sync - this.host.securityManager2.storePeerMPAN( + this.securityManager.storePeerMPAN( sendingNodeId, ctx.groupId, { type: MPANState.OutOfSync }, @@ -1101,7 +1118,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { } } else if (!ctx.isMulticast && ctx.groupId != undefined) { // After reception of a singlecast followup, the MPAN state must be increased - this.host.securityManager2.tryIncrementPeerMPAN( + this.securityManager.tryIncrementPeerMPAN( sendingNodeId, ctx.groupId, ); @@ -1117,7 +1134,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { if (!ctx.isMulticast) { const mpanExtension = this.getMPANExtension(); if (mpanExtension) { - this.host.securityManager2.storePeerMPAN( + this.securityManager.storePeerMPAN( sendingNodeId, mpanExtension.groupId, { @@ -1173,6 +1190,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { } } + private securityManager: SecurityManager2; public readonly securityClass?: SecurityClass; // Only used for testing/debugging purposes @@ -1195,11 +1213,11 @@ export class Security2CCMessageEncapsulation extends Security2CC { public get sequenceNumber(): number { if (this._sequenceNumber == undefined) { if (this.isSinglecast()) { - this._sequenceNumber = this.host.securityManager2 + this._sequenceNumber = this.securityManager .nextSequenceNumber(this.nodeId); } else { const groupId = this.getDestinationIDTX(); - return this.host.securityManager2.nextMulticastSequenceNumber( + return this.securityManager.nextMulticastSequenceNumber( groupId, ); } @@ -1274,7 +1292,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { if (!this.isSinglecast()) return; const receiverNodeId: number = this.nodeId; - const spanState = this.host.securityManager2.getSPANState( + const spanState = this.securityManager.getSPANState( receiverNodeId, ); if ( @@ -1289,15 +1307,15 @@ export class Security2CCMessageEncapsulation extends Security2CC { } else if (spanState.type === SPANState.RemoteEI) { // We have the receiver's EI, generate our input and send it over // With both, we can create an SPAN - const senderEI = this.host.securityManager2.generateNonce( + const senderEI = this.securityManager.generateNonce( undefined, ); const receiverEI = spanState.receiverEI; // While bootstrapping a node, the controller only sends commands encrypted // with the temporary key - if (this.host.securityManager2.tempKeys.has(receiverNodeId)) { - this.host.securityManager2.initializeTempSPAN( + if (this.securityManager.tempKeys.has(receiverNodeId)) { + this.securityManager.initializeTempSPAN( receiverNodeId, senderEI, receiverEI, @@ -1312,7 +1330,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { ZWaveErrorCodes.Security2CC_NoSPAN, ); } - this.host.securityManager2.initializeSPAN( + this.securityManager.initializeSPAN( receiverNodeId, securityClass, senderEI, @@ -1381,18 +1399,18 @@ export class Security2CCMessageEncapsulation extends Security2CC { // Singlecast: // Generate a nonce for encryption, and remember it to attempt decryption // of potential in-flight messages from the target node. - iv = this.host.securityManager2.nextNonce(this.nodeId, true); + iv = this.securityManager.nextNonce(this.nodeId, true); const { keyCCM } = // Prefer the overridden security class if it was given this.securityClass != undefined - ? this.host.securityManager2.getKeysForSecurityClass( + ? this.securityManager.getKeysForSecurityClass( this.securityClass, ) - : this.host.securityManager2.getKeysForNode(this.nodeId); + : this.securityManager.getKeysForNode(this.nodeId); key = keyCCM; } else { // Multicast: - const keyAndIV = this.host.securityManager2.getMulticastKeyAndIV( + const keyAndIV = this.securityManager.getMulticastKeyAndIV( destinationTag, ); key = keyAndIV.key; @@ -1477,7 +1495,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { // TODO: This is ugly, we should probably do this in the constructor or so let securityClass = this.securityClass; if (securityClass == undefined) { - const spanState = this.host.securityManager2.getSPANState( + const spanState = this.securityManager.getSPANState( this.nodeId, ); if (spanState.type === SPANState.SPAN) { @@ -1510,7 +1528,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { }, ): DecryptionResult { const decryptWithNonce = (nonce: Buffer) => { - const { keyCCM: key } = this.host.securityManager2.getKeysForNode( + const { keyCCM: key } = this.securityManager.getKeysForNode( sendingNodeId, ); @@ -1522,7 +1540,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { }; }; const getNonceAndDecrypt = () => { - const iv = this.host.securityManager2.nextNonce(sendingNodeId); + const iv = this.securityManager.nextNonce(sendingNodeId); return decryptWithNonce(iv); }; @@ -1566,12 +1584,12 @@ export class Security2CCMessageEncapsulation extends Security2CC { const receiverEI = spanState.receiverEI; // How we do this depends on whether we know the security class of the other node - const isBootstrappingNode = this.host.securityManager2.tempKeys.has( + const isBootstrappingNode = this.securityManager.tempKeys.has( sendingNodeId, ); if (isBootstrappingNode) { // We're currently bootstrapping the node, it might be using a temporary key - this.host.securityManager2.initializeTempSPAN( + this.securityManager.initializeTempSPAN( sendingNodeId, senderEI, receiverEI, @@ -1587,7 +1605,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { } // Reset the SPAN state and try with the recently granted security class - this.host.securityManager2.setSPANState( + this.securityManager.setSPANState( sendingNodeId, spanState, ); @@ -1614,7 +1632,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { for (const secClass of possibleSecurityClasses) { // Skip security classes we don't have keys for if ( - !this.host.securityManager2.hasKeysForSecurityClass( + !this.securityManager.hasKeysForSecurityClass( secClass, ) ) { @@ -1622,7 +1640,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { } // Initialize an SPAN with that security class - this.host.securityManager2.initializeSPAN( + this.securityManager.initializeSPAN( sendingNodeId, secClass, senderEI, @@ -1649,7 +1667,7 @@ export class Security2CCMessageEncapsulation extends Security2CC { }; } else { // Reset the SPAN state and try with the next security class - this.host.securityManager2.setSPANState( + this.securityManager.setSPANState( sendingNodeId, spanState, ); @@ -1672,11 +1690,11 @@ export class Security2CCMessageEncapsulation extends Security2CC { authData: Buffer, authTag: Buffer, ): DecryptionResult { - const iv = this.host.securityManager2.nextPeerMPAN( + const iv = this.securityManager.nextPeerMPAN( sendingNodeId, groupId, ); - const { keyCCM: key } = this.host.securityManager2.getKeysForNode( + const { keyCCM: key } = this.securityManager.getKeysForNode( sendingNodeId, ); return { @@ -1704,12 +1722,6 @@ export type Security2CCNonceReportOptions = @CCCommand(Security2Command.NonceReport) export class Security2CCNonceReport extends Security2CC { - // Define the securityManager as existing - // We check it in the constructor - declare host: ZWaveHost & { - securityManager2: SecurityManager2; - }; - public constructor( host: ZWaveHost, options: @@ -1720,6 +1732,7 @@ export class Security2CCNonceReport extends Security2CC { // Make sure that we can send/receive secure commands assertSecurity.call(this, options); + this.securityManager = getSecurityManager(host, this.nodeId)!; if (gotDeserializationOptions(options)) { validatePayload(this.payload.length >= 2); @@ -1738,7 +1751,7 @@ export class Security2CCNonceReport extends Security2CC { // In that case we also need to store it, so the next sent command // can use it for encryption - this.host.securityManager2.storeRemoteEI( + this.securityManager.storeRemoteEI( this.nodeId as number, this.receiverEI, ); @@ -1750,6 +1763,7 @@ export class Security2CCNonceReport extends Security2CC { } } + private securityManager: SecurityManager2; private _sequenceNumber: number | undefined; /** * Return the sequence number of this command. @@ -1759,7 +1773,7 @@ export class Security2CCNonceReport extends Security2CC { */ public get sequenceNumber(): number { if (this._sequenceNumber == undefined) { - this._sequenceNumber = this.host.securityManager2 + this._sequenceNumber = this.securityManager .nextSequenceNumber( this.nodeId as number, ); @@ -1804,17 +1818,12 @@ export class Security2CCNonceGet extends Security2CC { // TODO: A node sending this command MUST accept a delay up to + // 250 ms before receiving the Security 2 Nonce Report Command. - // Define the securityManager as existing - // We check it in the constructor - declare host: ZWaveHost & { - securityManager2: SecurityManager2; - }; - public constructor(host: ZWaveHost, options: CCCommandOptions) { super(host, options); // Make sure that we can send/receive secure commands assertSecurity.call(this, options); + this.securityManager = getSecurityManager(host, this.nodeId)!; if (gotDeserializationOptions(options)) { validatePayload(this.payload.length >= 1); @@ -1826,6 +1835,7 @@ export class Security2CCNonceGet extends Security2CC { } } + private securityManager: SecurityManager2; private _sequenceNumber: number | undefined; /** * Return the sequence number of this command. @@ -1835,7 +1845,7 @@ export class Security2CCNonceGet extends Security2CC { */ public get sequenceNumber(): number { if (this._sequenceNumber == undefined) { - this._sequenceNumber = this.host.securityManager2 + this._sequenceNumber = this.securityManager .nextSequenceNumber( this.nodeId as number, ); diff --git a/packages/host/src/ZWaveHost.ts b/packages/host/src/ZWaveHost.ts index c71c38a675c2..6455c7bc71e1 100644 --- a/packages/host/src/ZWaveHost.ts +++ b/packages/host/src/ZWaveHost.ts @@ -29,8 +29,10 @@ export interface ZWaveHost { /** Management of Security S0 keys and nonces */ securityManager: SecurityManager | undefined; - /** Management of Security S2 keys and nonces */ + /** Management of Security S2 keys and nonces (Z-Wave Classic) */ securityManager2: SecurityManager2 | undefined; + /** Management of Security S2 keys and nonces (Z-Wave Long Range) */ + securityManagerLR: SecurityManager2 | undefined; /** * Retrieves the maximum version of a command class that can be used to communicate with a node. diff --git a/packages/host/src/mocks.ts b/packages/host/src/mocks.ts index 623c144c64c4..ca42be0f4850 100644 --- a/packages/host/src/mocks.ts +++ b/packages/host/src/mocks.ts @@ -44,6 +44,7 @@ export function createTestingHost( isControllerNode: (nodeId) => nodeId === ret.ownNodeId, securityManager: undefined, securityManager2: undefined, + securityManagerLR: undefined, getDeviceConfig: undefined, controllerLog: new Proxy({} as any, { get() { diff --git a/packages/testing/src/MockController.ts b/packages/testing/src/MockController.ts index b68bcbb3bae0..e78ac2b927a3 100644 --- a/packages/testing/src/MockController.ts +++ b/packages/testing/src/MockController.ts @@ -56,6 +56,7 @@ export class MockController { homeId: options.homeId ?? 0x7e571000, securityManager: undefined, securityManager2: undefined, + securityManagerLR: undefined, // nodes: this.nodes as any, getNextCallbackId: () => 1, getNextSupervisionSessionId: (nodeId) => { diff --git a/packages/zwave-js/src/lib/controller/Controller.ts b/packages/zwave-js/src/lib/controller/Controller.ts index 41ea6ac2edfe..8e9f8fb77d06 100644 --- a/packages/zwave-js/src/lib/controller/Controller.ts +++ b/packages/zwave-js/src/lib/controller/Controller.ts @@ -755,6 +755,14 @@ export class ZWaveController ); } + const firstNodeIsLR = isLongRangeNodeId(nodeIDs[0]); + if (nodeIDs.some((id) => isLongRangeNodeId(id) !== firstNodeIsLR)) { + throw new ZWaveError( + "Cannot create a multicast group with mixed Z-Wave Classic and Z-Wave Long Range nodes", + ZWaveErrorCodes.Argument_Invalid, + ); + } + const nodes = nodeIDs.map((id) => this._nodes.getOrThrow(id)); return new VirtualNode(undefined, this.driver, nodes); } @@ -2817,7 +2825,11 @@ supported CCs: ${ } }; - if (!this.driver.securityManager2) { + const securityManager = node.protocol === Protocols.ZWaveLongRange + ? this.driver.securityManagerLR + : this.driver.securityManager2; + + if (!securityManager) { // Remember that the node was NOT granted any S2 security classes unGrantSecurityClasses(); return SecurityBootstrapFailure.NoKeysConfigured; @@ -2889,8 +2901,8 @@ supported CCs: ${ const deleteTempKey = () => { // Whatever happens, no further communication needs the temporary key - this.driver.securityManager2?.deleteNonce(node.id); - this.driver.securityManager2?.tempKeys.delete(node.id); + securityManager.deleteNonce(node.id); + securityManager.tempKeys.delete(node.id); }; // Allow canceling the bootstrapping process @@ -3146,8 +3158,8 @@ supported CCs: ${ const tempKeys = deriveTempKeys( computePRK(sharedSecret, publicKey, nodePublicKey), ); - this.driver.securityManager2.deleteNonce(node.id); - this.driver.securityManager2.tempKeys.set(node.id, { + securityManager.deleteNonce(node.id); + securityManager.tempKeys.set(node.id, { keyCCM: tempKeys.tempKeyCCM, personalizationString: tempKeys.tempPersonalizationString, }); @@ -3261,7 +3273,7 @@ supported CCs: ${ const securityClass = keyRequest.requestedKey; // Ensure it was received encrypted with the temporary key if ( - !this.driver.securityManager2.hasUsedSecurityClass( + !securityManager.hasUsedSecurityClass( node.id, SecurityClass.Temporary, ) @@ -3287,7 +3299,7 @@ supported CCs: ${ // Send the node the requested key await api.sendNetworkKey( securityClass, - this.driver.securityManager2.getKeysForSecurityClass( + securityManager.getKeysForSecurityClass( securityClass, ).pnk, ); @@ -3317,7 +3329,7 @@ supported CCs: ${ } if ( - !this.driver.securityManager2.hasUsedSecurityClass( + !securityManager.hasUsedSecurityClass( node.id, securityClass, ) @@ -3336,7 +3348,7 @@ supported CCs: ${ // so our logic to use the highest security class for decryption might be problematic. Therefore delete the // security class for now. node.securityClasses.delete(securityClass); - this.driver.securityManager2.deleteNonce(node.id); + securityManager.deleteNonce(node.id); await api.confirmKeyVerification(); } diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index ddc73c38baf4..bd56bebd2e12 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -59,6 +59,7 @@ import { type MaybeNotKnown, MessagePriority, type MessageRecord, + type MulticastDestination, NodeIDType, RFRegion, SPANState, @@ -84,6 +85,7 @@ import { deserializeCacheValue, getCCName, highResTimestamp, + isLongRangeNodeId, isMissingControllerACK, isMissingControllerCallback, isMissingControllerResponse, @@ -807,6 +809,25 @@ export class Driver extends TypedEventEmitter return this._securityManager2; } + private _securityManagerLR: SecurityManager2 | undefined; + /** + * **!!! INTERNAL !!!** + * + * Not intended to be used by applications + */ + public get securityManagerLR(): SecurityManager2 | undefined { + return this._securityManagerLR; + } + + /** @internal */ + public getSecurityManager2( + destination: number | MulticastDestination, + ): SecurityManager2 | undefined { + const nodeId = isArray(destination) ? destination[0] : destination; + const isLongRange = isLongRangeNodeId(nodeId); + return isLongRange ? this.securityManagerLR : this.securityManager2; + } + /** * **!!! INTERNAL !!!** * @@ -1551,6 +1572,33 @@ export class Driver extends TypedEventEmitter ); } + if ( + this._options.securityKeysLongRange?.S2_AccessControl + || this._options.securityKeysLongRange?.S2_Authenticated + ) { + this.driverLog.print( + "At least one network key for Z-Wave Long Range configured, enabling security manager...", + ); + this._securityManagerLR = new SecurityManager2(); + if (this._options.securityKeysLongRange?.S2_AccessControl) { + this._securityManagerLR.setKey( + SecurityClass.S2_AccessControl, + this._options.securityKeysLongRange.S2_AccessControl, + ); + } + if (this._options.securityKeysLongRange?.S2_Authenticated) { + this._securityManagerLR.setKey( + SecurityClass.S2_Authenticated, + this._options.securityKeysLongRange.S2_Authenticated, + ); + } + } else { + this.driverLog.print( + "No network key for Z-Wave Long Range configured, communication won't work!", + "warn", + ); + } + // in any case we need to emit the driver ready event here this._controllerInterviewed = true; this.driverLog.print("driver ready"); @@ -2127,6 +2175,7 @@ export class Driver extends TypedEventEmitter // Remove the node from all security manager instances this.securityManager?.deleteAllNoncesForReceiver(node.id); this.securityManager2?.deleteNonce(node.id); + this.securityManagerLR?.deleteNonce(node.id); this.rejectAllTransactionsForNode( node.id, @@ -2196,6 +2245,7 @@ export class Driver extends TypedEventEmitter // Reset nonces etc. to prevent false-positive duplicates after the update this.securityManager?.deleteAllNoncesForReceiver(node.id); this.securityManager2?.deleteNonce(node.id); + this.securityManagerLR?.deleteNonce(node.id); // waitTime should always be defined, but just to be sure const waitTime = result.waitTime ?? 5; @@ -2433,7 +2483,7 @@ export class Driver extends TypedEventEmitter if (securityClassIsS2(securityClass)) { // Use secure communication if the CC is supported. This avoids silly things like S2-encapsulated pings return ( - !!this.securityManager2 + !!this.getSecurityManager2(nodeId) && (isBasicCC || (endpoint ?? node).supportsCC(ccId)) ); } @@ -3316,15 +3366,16 @@ export class Driver extends TypedEventEmitter // With the MGRP extension present const node = this.getNodeUnsafe(msg); + if (!node) return false; const groupId = encapS2.getMulticastGroupId(); + if (groupId == undefined) return false; + const securityManager = this.getSecurityManager2(node.id); if ( - node - && groupId != undefined // but where we don't have an MPAN stored - && this.securityManager2?.getPeerMPAN( - msg.command.nodeId as number, - groupId, - ).type !== MPANState.MPAN + securityManager?.getPeerMPAN( + msg.command.nodeId as number, + groupId, + ).type !== MPANState.MPAN ) { return true; } @@ -3375,11 +3426,12 @@ export class Driver extends TypedEventEmitter if (this.controller.bootstrappingS2NodeId === nodeId) { // The node is currently being bootstrapped. - if (this.securityManager2?.tempKeys.has(nodeId)) { + const securityManager = this.getSecurityManager2(nodeId); + if (securityManager?.tempKeys.has(nodeId)) { // The DSK has been verified, so we should be able to decode this command. // If this is the first attempt, we need to request a nonce first if ( - this.securityManager2.getSPANState(nodeId).type + securityManager.getSPANState(nodeId).type === SPANState.None ) { this.controllerLog.logNode(nodeId, { @@ -4593,8 +4645,9 @@ ${handlers.length} left`, if (node?.supportsCC(CommandClasses["Security 2"])) { // ... the node supports S2 and has a valid security class const nodeSecClass = node.getHighestSecurityClass(); + const securityManager = this.getSecurityManager2(node.id); maybeS2 = securityClassIsS2(nodeSecClass) - || !!this.securityManager2?.tempKeys.has(node.id); + || !!securityManager?.tempKeys.has(node.id); } else if (options.s2MulticastGroupId != undefined) { // ... or we're dealing with S2 multicast maybeS2 = true; diff --git a/packages/zwave-js/src/lib/driver/MessageGenerators.ts b/packages/zwave-js/src/lib/driver/MessageGenerators.ts index 360f1a6f677e..7bcc0c8041cd 100644 --- a/packages/zwave-js/src/lib/driver/MessageGenerators.ts +++ b/packages/zwave-js/src/lib/driver/MessageGenerators.ts @@ -568,8 +568,8 @@ export const secureMessageGeneratorS2: MessageGeneratorImplementation = ); } - const secMan = driver.securityManager2!; const nodeId = msg.command.nodeId; + const secMan = driver.getSecurityManager2(nodeId)!; const spanState = secMan.getSPANState(nodeId); let additionalTimeoutMs: number | undefined; @@ -742,7 +742,7 @@ export const secureMessageGeneratorS2Multicast: MessageGeneratorImplementation = ); } - const secMan = driver.securityManager2!; + const secMan = driver.getSecurityManager2(msg.command.nodeId)!; const group = secMan.getMulticastGroup(groupId); if (!group) { throw new ZWaveError( diff --git a/packages/zwave-js/src/lib/driver/ZWaveOptions.ts b/packages/zwave-js/src/lib/driver/ZWaveOptions.ts index 968123f7cbc3..ec07a9ffbac2 100644 --- a/packages/zwave-js/src/lib/driver/ZWaveOptions.ts +++ b/packages/zwave-js/src/lib/driver/ZWaveOptions.ts @@ -146,15 +146,23 @@ export interface ZWaveOptions extends ZWaveHostOptions { }; /** - * Specify the security keys to use for encryption. Each one must be a Buffer of exactly 16 bytes. + * Specify the security keys to use for encryption (Z-Wave Classic). Each one must be a Buffer of exactly 16 bytes. */ securityKeys?: { - S2_Unauthenticated?: Buffer; - S2_Authenticated?: Buffer; S2_AccessControl?: Buffer; + S2_Authenticated?: Buffer; + S2_Unauthenticated?: Buffer; S0_Legacy?: Buffer; }; + /** + * Specify the security keys to use for encryption (Z-Wave Long Range). Each one must be a Buffer of exactly 16 bytes. + */ + securityKeysLongRange?: { + S2_AccessControl?: Buffer; + S2_Authenticated?: Buffer; + }; + /** * Defines the callbacks that are necessary to trigger user interaction during S2 inclusion. * If not given, nodes won't be included using S2, unless matching provisioning entries exists. diff --git a/packages/zwave-js/src/lib/node/Node.ts b/packages/zwave-js/src/lib/node/Node.ts index 5752ac76b6fe..4572b3ce8e3c 100644 --- a/packages/zwave-js/src/lib/node/Node.ts +++ b/packages/zwave-js/src/lib/node/Node.ts @@ -343,7 +343,7 @@ export class ZWaveNode extends Endpoint ) ) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Scheduled poll canceled because expected value was received", "verbose", ); @@ -468,7 +468,7 @@ export class ZWaveNode extends Endpoint const [changeTarget, changeType] = eventName.split(" "); const logArgument = { ...outArg, - nodeId: this.nodeId, + nodeId: this.id, internal: isInternalValue, }; if (changeTarget === "value") { @@ -1543,7 +1543,7 @@ export class ZWaveNode extends Endpoint // because we don't have all the information before that if (!this.isMultiChannelInterviewComplete) { this.driver.driverLog.print( - `Node ${this.nodeId}, Endpoint ${index}: Trying to access endpoint instance before Multi Channel interview`, + `Node ${this.id}, Endpoint ${index}: Trying to access endpoint instance before Multi Channel interview`, "error", ); return undefined; @@ -1662,7 +1662,7 @@ export class ZWaveNode extends Endpoint && this.supportsCC(CommandClasses["Wake Up"]) ) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Re-interview scheduled, waiting for node to wake up...", ); didWakeUp = await this.waitForWakeup() @@ -2124,6 +2124,8 @@ protocol version: ${this.protocolVersion}`; return true; } + const securityManager2 = this.driver.getSecurityManager2(this.id); + /** * @param force When this is `true`, the interview will be attempted even when the CC is not supported by the endpoint. */ @@ -2158,7 +2160,7 @@ protocol version: ${this.protocolVersion}`; if ( endpoint.isCCSecure(cc) && !this.driver.securityManager - && !this.driver.securityManager2 + && !securityManager2 ) { // The CC is only supported securely, but the network key is not set up // Skip the CC @@ -2202,18 +2204,18 @@ protocol version: ${this.protocolVersion}`; || securityClassIsS2(securityClass) ) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Root device interview: Security S2", "silly", ); - if (!this.driver.securityManager2) { + if (!securityManager2) { if (!this._hasEmittedNoS2NetworkKeyError) { // Cannot interview a secure device securely without a network key const errorMessage = `supports Security S2, but no S2 network keys were configured. The interview might not include all functionality.`; this.driver.controllerLog.logNode( - this.nodeId, + this.id, errorMessage, "error", ); @@ -2259,7 +2261,7 @@ protocol version: ${this.protocolVersion}`; // Query supported CCs unless we know for sure that the node wasn't assigned the S0 security class if (this.hasSecurityClass(SecurityClass.S0_Legacy) !== false) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Root device interview: Security S0", "silly", ); @@ -2270,7 +2272,7 @@ protocol version: ${this.protocolVersion}`; const errorMessage = `supports Security S0, but the S0 network key was not configured. The interview might not include all functionality.`; this.driver.controllerLog.logNode( - this.nodeId, + this.id, errorMessage, "error", ); @@ -2305,7 +2307,7 @@ protocol version: ${this.protocolVersion}`; // identify the device and apply device configurations if (this.supportsCC(CommandClasses["Manufacturer Specific"])) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Root device interview: Manufacturer Specific", "silly", ); @@ -2322,7 +2324,7 @@ protocol version: ${this.protocolVersion}`; if (this.supportsCC(CommandClasses.Version)) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Root device interview: Version", "silly", ); @@ -2336,7 +2338,7 @@ protocol version: ${this.protocolVersion}`; this.applyCommandClassesCompatFlag(); } else { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Version CC is not supported. Using the highest implemented version for each CC", "debug", ); @@ -2351,7 +2353,7 @@ protocol version: ${this.protocolVersion}`; // The Wakeup interview should be done as early as possible if (this.supportsCC(CommandClasses["Wake Up"])) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, "Root device interview: Wake Up", "silly", ); @@ -2401,7 +2403,7 @@ protocol version: ${this.protocolVersion}`; } this.driver.controllerLog.logNode( - this.nodeId, + this.id, `Root device interviews before endpoints: ${ rootInterviewOrderBeforeEndpoints .map((cc) => `\n· ${getCCName(cc)}`) @@ -2411,7 +2413,7 @@ protocol version: ${this.protocolVersion}`; ); this.driver.controllerLog.logNode( - this.nodeId, + this.id, `Root device interviews after endpoints: ${ rootInterviewOrderAfterEndpoints .map((cc) => `\n· ${getCCName(cc)}`) @@ -2423,7 +2425,7 @@ protocol version: ${this.protocolVersion}`; // Now that we know the correct order, do the interview in sequence for (const cc of rootInterviewOrderBeforeEndpoints) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, `Root device interview: ${getCCName(cc)}`, "silly", ); @@ -2475,9 +2477,9 @@ protocol version: ${this.protocolVersion}`; // If S2 is the highest security class, interview it for the endpoint if ( securityClassIsS2(securityClass) - && !!this.driver.securityManager2 + && !!securityManager2 ) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} interview: Security S2`, @@ -2501,7 +2503,7 @@ protocol version: ${this.protocolVersion}`; securityClass === SecurityClass.S0_Legacy && !!this.driver.securityManager ) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} interview: Security S0`, @@ -2564,7 +2566,7 @@ protocol version: ${this.protocolVersion}`; endpoint.supportsCC(t.ccId) ); if (foundTest) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `is included using Security S0, but endpoint ${endpoint.index} does not list the CC. Testing if it accepts secure commands anyways.`, @@ -2580,7 +2582,7 @@ protocol version: ${this.protocolVersion}`; const success = !!(await test().catch(() => false)); if (success) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} accepts/expects secure commands`, @@ -2591,7 +2593,7 @@ protocol version: ${this.protocolVersion}`; endpoint.addCC(ccId, { secure: true }); } } else { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} is actually not using S0`, @@ -2601,7 +2603,7 @@ protocol version: ${this.protocolVersion}`; endpoint.addCC(ccId, { secure: false }); } } else { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `is included using Security S0, but endpoint ${endpoint.index} does not list the CC. Found no way to test if accepts secure commands anyways.`, @@ -2618,7 +2620,7 @@ protocol version: ${this.protocolVersion}`; // Endpoints SHOULD not support this CC, but we still need to query their // CCs that the root device may or may not support if (this.supportsCC(CommandClasses.Version)) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} interview: ${ getCCName( @@ -2630,7 +2632,7 @@ protocol version: ${this.protocolVersion}`; await interviewEndpoint(endpoint, CommandClasses.Version, true); } else { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: "Version CC is not supported. Using the highest implemented version for each CC", @@ -2671,7 +2673,7 @@ protocol version: ${this.protocolVersion}`; ); } - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} interview order: ${ endpointInterviewOrder @@ -2683,7 +2685,7 @@ protocol version: ${this.protocolVersion}`; // Now that we know the correct order, do the interview in sequence for (const cc of endpointInterviewOrder) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { endpoint: endpoint.index, message: `Endpoint ${endpoint.index} interview: ${ getCCName( @@ -2702,7 +2704,7 @@ protocol version: ${this.protocolVersion}`; // Continue with the application CCs for the root endpoint for (const cc of rootInterviewOrderAfterEndpoints) { this.driver.controllerLog.logNode( - this.nodeId, + this.id, `Root device interview: ${getCCName(cc)}`, "silly", ); @@ -2747,7 +2749,7 @@ protocol version: ${this.protocolVersion}`; ) { const delay = this.deviceConfig?.compat?.manualValueRefreshDelayMs || 0; - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { message: `Node does not send unsolicited updates; refreshing actuator and sensor values${ delay > 0 ? ` in ${delay} ms` : "" @@ -3022,7 +3024,7 @@ protocol version: ${this.protocolVersion}`; if (endpoint && endpoint.supportsCC(command.ccId)) { // Force the CC to store its values again under the supporting endpoint this.driver.controllerLog.logNode( - this.nodeId, + this.id, `Mapping unsolicited report from root device to endpoint #${endpoint.index}`, ); command.endpointIndex = endpoint.index; @@ -3180,7 +3182,7 @@ protocol version: ${this.protocolVersion}`; // Ensure that we're not flooding the queue with unnecessary NonceReports (GH#1059) const isNonceReport = (t: Transaction) => - t.message.getNodeId() === this.nodeId + t.message.getNodeId() === this.id && isCommandClassContainer(t.message) && t.message.command instanceof SecurityCCNonceReport; @@ -3234,7 +3236,7 @@ protocol version: ${this.protocolVersion}`; */ public async handleSecurity2NonceGet(): Promise { // Only reply if secure communication is set up - if (!this.driver.securityManager2) { + if (!this.driver.getSecurityManager2(this.id)) { if (!this.hasLoggedNoNetworkKey) { this.hasLoggedNoNetworkKey = true; this.driver.controllerLog.logNode(this.id, { @@ -3257,7 +3259,7 @@ protocol version: ${this.protocolVersion}`; // Ensure that we're not flooding the queue with unnecessary NonceReports (GH#1059) const isNonceReport = (t: Transaction) => - t.message.getNodeId() === this.nodeId + t.message.getNodeId() === this.id && isCommandClassContainer(t.message) && t.message.command instanceof Security2CCNonceReport; @@ -3293,7 +3295,7 @@ protocol version: ${this.protocolVersion}`; // if (command.SOS && command.receiverEI) { // // The node couldn't decrypt the last command we sent it. Invalidate // // the shared SPAN, since it did the same - // secMan.storeRemoteEI(this.nodeId, command.receiverEI); + // secMan.storeRemoteEI(this.id, command.receiverEI); // } // Since we landed here, this is not in response to any command we sent @@ -3311,7 +3313,7 @@ protocol version: ${this.protocolVersion}`; this.markAsAwake(); if (this.busyPollingAfterHail) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { message: `Hail received from node, but still busy with previous one...`, }); @@ -3319,7 +3321,7 @@ protocol version: ${this.protocolVersion}`; } this.busyPollingAfterHail = true; - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { message: `Hail received from node, refreshing actuator and sensor values...`, }); @@ -4197,7 +4199,7 @@ protocol version: ${this.protocolVersion}`; ) { const ccVersion = this.driver.getSupportedCCVersion( CommandClasses.Notification, - this.nodeId, + this.id, this.index, ); if (ccVersion === 2 || !this.valueDB.hasMetadata(valueId)) { @@ -4600,7 +4602,7 @@ protocol version: ${this.protocolVersion}`; } this.driver.controllerLog.logNode( - this.nodeId, + this.id, `detected a deviation of the node's clock, updating it...`, ); this.busySettingClock = true; @@ -4638,7 +4640,7 @@ protocol version: ${this.protocolVersion}`; }); await api.reportTime(hours, minutes, seconds); } catch (e: any) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { message: e.message, level: "error", }); @@ -4667,7 +4669,7 @@ protocol version: ${this.protocolVersion}`; }); await api.reportDate(year, month, day); } catch (e: any) { - this.driver.controllerLog.logNode(this.nodeId, { + this.driver.controllerLog.logNode(this.id, { message: e.message, level: "error", }); @@ -5220,7 +5222,7 @@ protocol version: ${this.protocolVersion}`; ?? (await this.driver .waitForCommand( (cc) => - cc.nodeId === this.nodeId + cc.nodeId === this.id && cc instanceof FirmwareUpdateMetaDataCCGet, // Wait up to 2 minutes for each fragment request. // Some users try to update devices with unstable connections, where 30s can be too short. @@ -5337,7 +5339,7 @@ protocol version: ${this.protocolVersion}`; const statusReport = await this.driver .waitForCommand( (cc) => - cc.nodeId === this.nodeId + cc.nodeId === this.id && cc instanceof FirmwareUpdateMetaDataCCStatusReport, // Wait up to 5 minutes. It should never take that long, but the specs // don't say anything specific @@ -5402,7 +5404,7 @@ protocol version: ${this.protocolVersion}`; private hasPendingFirmwareUpdateFragment(fragmentNumber: number): boolean { // Avoid queuing duplicate fragments const isCurrentFirmwareFragment = (t: Transaction) => - t.message.getNodeId() === this.nodeId + t.message.getNodeId() === this.id && isCommandClassContainer(t.message) && t.message.command instanceof FirmwareUpdateMetaDataCCReport && t.message.command.reportNumber === fragmentNumber; @@ -5799,7 +5801,7 @@ protocol version: ${this.protocolVersion}`; // Determine the number of repeating neighbors const numNeighbors = ( - await this.driver.controller.getNodeNeighbors(this.nodeId, true) + await this.driver.controller.getNodeNeighbors(this.id, true) ).length; // Ping the node 10x, measuring the RSSI @@ -6118,7 +6120,7 @@ ${formatLifelineHealthCheckSummary(summary)}`, const numNeighbors = Math.min( ( await this.driver.controller.getNodeNeighbors( - this.nodeId, + this.id, true, ) ).length,