UNPKG

zigbee-on-host

Version:

Zigbee stack designed to run on a host and communicate with a radio co-processor (RCP)

1,001 lines 55 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.StackContext = exports.END_DEVICE_TIMEOUT_TABLE_MS = exports.NetworkKeyUpdateMethod = exports.ApplicationKeyRequestPolicy = exports.TrustCenterKeyRequestPolicy = exports.InstallCodePolicy = void 0; const promises_1 = require("node:fs/promises"); const logger_js_1 = require("../utils/logger.js"); const mac_js_1 = require("../zigbee/mac.js"); const zigbee_js_1 = require("../zigbee/zigbee.js"); const descriptors_js_1 = require("./descriptors.js"); const nwk_handler_js_1 = require("./nwk-handler.js"); const save_serializer_js_1 = require("./save-serializer.js"); const NS = "stack-context"; var InstallCodePolicy; (function (InstallCodePolicy) { /** Do not support Install Codes */ InstallCodePolicy[InstallCodePolicy["NOT_SUPPORTED"] = 0] = "NOT_SUPPORTED"; /** Support but do not require use of Install Codes or preset passphrases */ InstallCodePolicy[InstallCodePolicy["NOT_REQUIRED"] = 1] = "NOT_REQUIRED"; /** Require the use of Install Codes by joining devices or preset Passphrases */ InstallCodePolicy[InstallCodePolicy["REQUIRED"] = 2] = "REQUIRED"; })(InstallCodePolicy || (exports.InstallCodePolicy = InstallCodePolicy = {})); var TrustCenterKeyRequestPolicy; (function (TrustCenterKeyRequestPolicy) { TrustCenterKeyRequestPolicy[TrustCenterKeyRequestPolicy["DISALLOWED"] = 0] = "DISALLOWED"; /** Any device MAY request */ TrustCenterKeyRequestPolicy[TrustCenterKeyRequestPolicy["ALLOWED"] = 1] = "ALLOWED"; /** Only devices in the apsDeviceKeyPairSet with a KeyAttribute value of PROVISIONAL_KEY MAY request. */ TrustCenterKeyRequestPolicy[TrustCenterKeyRequestPolicy["ONLY_PROVISIONAL"] = 2] = "ONLY_PROVISIONAL"; })(TrustCenterKeyRequestPolicy || (exports.TrustCenterKeyRequestPolicy = TrustCenterKeyRequestPolicy = {})); var ApplicationKeyRequestPolicy; (function (ApplicationKeyRequestPolicy) { ApplicationKeyRequestPolicy[ApplicationKeyRequestPolicy["DISALLOWED"] = 0] = "DISALLOWED"; /** Any device MAY request an application link key with any device (except the Trust Center) */ ApplicationKeyRequestPolicy[ApplicationKeyRequestPolicy["ALLOWED"] = 1] = "ALLOWED"; /** Only those devices listed in applicationKeyRequestList MAY request and receive application link keys. */ ApplicationKeyRequestPolicy[ApplicationKeyRequestPolicy["ONLY_APPROVED"] = 2] = "ONLY_APPROVED"; })(ApplicationKeyRequestPolicy || (exports.ApplicationKeyRequestPolicy = ApplicationKeyRequestPolicy = {})); var NetworkKeyUpdateMethod; (function (NetworkKeyUpdateMethod) { /** Broadcast using only network encryption */ NetworkKeyUpdateMethod[NetworkKeyUpdateMethod["BROADCAST"] = 0] = "BROADCAST"; /** Unicast using network encryption and APS encryption with a device’s link key. */ NetworkKeyUpdateMethod[NetworkKeyUpdateMethod["UNICAST"] = 1] = "UNICAST"; })(NetworkKeyUpdateMethod || (exports.NetworkKeyUpdateMethod = NetworkKeyUpdateMethod = {})); /** Table 3-54 */ exports.END_DEVICE_TIMEOUT_TABLE_MS = [ 10_000, 2 * 60 * 1000, 4 * 60 * 1000, 8 * 60 * 1000, 16 * 60 * 1000, 32 * 60 * 1000, 64 * 60 * 1000, 128 * 60 * 1000, 256 * 60 * 1000, 512 * 60 * 1000, 1024 * 60 * 1000, 2048 * 60 * 1000, 4096 * 60 * 1000, 8192 * 60 * 1000, 16_384 * 60 * 1000, ]; /** The time between state saving to disk. (msec) */ const CONFIG_SAVE_STATE_TIME = 60000; /** Offset added to frame counter properties on save */ const CONFIG_SAVE_FRAME_COUNTER_JUMP_OFFSET = 1024; /** * Centralized shared state and counters for the Zigbee stack. * * This context holds all shared state between protocol layers including: * - Network parameters * - Device and routing tables * - Frame counters (MAC, NWK, APS, ZDO) * - Trust Center policies * - RSSI/LQI ranges */ class StackContext { #callbacks; /** Master table of all known devices on the network (mapped by IEEE address) */ deviceTable = new Map(); /** Address lookup: 16-bit to 64-bit (synced with deviceTable) */ address16ToAddress64 = new Map(); /** Source routing table (mapped by 16-bit address) */ sourceRouteTable = new Map(); /** Application link keys stored for device pairs (ordered by IEEE address) */ appLinkKeyTable = new Map(); /** Install code metadata per device (mapped by IEEE address) */ installCodeTable = new Map(); /** Trust Center policies */ trustCenterPolicies = { allowJoins: false, installCode: InstallCodePolicy.NOT_REQUIRED, allowRejoinsWithWellKnownKey: true, allowTCKeyRequest: TrustCenterKeyRequestPolicy.ALLOWED, networkKeyUpdatePeriod: 0, // disable networkKeyUpdateMethod: NetworkKeyUpdateMethod.BROADCAST, allowAppKeyRequest: ApplicationKeyRequestPolicy.DISALLOWED, allowRemoteTCPolicyChange: false, allowVirtualDevices: false, }; /** Configuration attributes */ configAttributes = { address: Buffer.alloc(0), nodeDescriptor: Buffer.alloc(0), powerDescriptor: Buffer.alloc(0), simpleDescriptors: Buffer.alloc(0), activeEndpoints: Buffer.alloc(0), }; /** Count of MAC NO_ACK reported for each device (mapping by network address) */ macNoACKs = new Map(); /** Associations pending DATA_RQ from device (mapping by IEEE address) */ pendingAssociations = new Map(); /** Indirect transmission for devices with rxOnWhenIdle=false (mapping by IEEE address) */ indirectTransmissions = new Map(); #savePath; #saveStateTimeout; #loaded = false; /** Network parameters */ netParams; /** Pre-computed hash of default TC link key for VERIFY_KEY */ tcVerifyKeyHash = Buffer.alloc(0); /** MAC association permit flag */ associationPermit = false; //---- Trust Center (see 05-3474-23 #4.7.1) #allowJoinTimeout; #pendingNetworkKey; #pendingNetworkKeySequenceNumber; /** Minimum observed RSSI */ rssiMin = -100; /** Maximum observed RSSI */ rssiMax = -25; /** Minimum observed LQI */ lqiMin = 15; /** Maximum observed LQI */ lqiMax = 250; constructor(callbacks, savePath, netParams) { this.#callbacks = callbacks; this.#savePath = savePath; this.netParams = netParams; } // #region Getters/Setters get loaded() { return this.#loaded; } // #endregion /** * 05-3474-23 #4.7.6 (Trust Center maintenance) * * SPEC COMPLIANCE NOTES: * - ✅ Schedules periodic state persistence while stack is running * - ✅ Immediately saves state to ensure on-disk snapshot reflects startup values * - ⚠️ Additional periodic tasks (key rotation, metrics) remain TODO * DEVICE SCOPE: Trust Center */ async start() { // TODO: periodic/delayed actions this.#saveStateTimeout = setTimeout(this.savePeriodicState.bind(this), CONFIG_SAVE_STATE_TIME); await this.savePeriodicState(); } /** * 05-3474-23 #4.7.6 (Trust Center maintenance) * * SPEC COMPLIANCE NOTES: * - ✅ Cancels pending timers and ensures join window closed on shutdown * - ✅ Mirrors spec recommendation to revoke permit-join when TC inactive * DEVICE SCOPE: Trust Center */ stop() { clearTimeout(this.#saveStateTimeout); this.#saveStateTimeout = undefined; this.disallowJoins(); } /** * 05-3474-23 #4.7.6 (Trust Center maintenance) * * Remove the save file and clear tables (just in case) * * SPEC COMPLIANCE NOTES: * - ✅ Clears persistent storage and in-memory tables when performing factory reset * - ⚠️ Caller must reinitialize descriptors/netParams afterwards per spec flow * DEVICE SCOPE: Trust Center */ async clear() { // remove `zoh.save` await (0, promises_1.rm)(this.#savePath, { force: true }); this.deviceTable.clear(); this.address16ToAddress64.clear(); this.sourceRouteTable.clear(); this.appLinkKeyTable.clear(); this.installCodeTable.clear(); this.macNoACKs.clear(); this.pendingAssociations.clear(); this.indirectTransmissions.clear(); } /** * Get next Trust Center key frame counter. * HOT PATH: Optimized counter increment * @returns Incremented TC key frame counter (wraps at 0xffffffff) */ /* @__INLINE__ */ nextTCKeyFrameCounter() { this.netParams.tcKeyFrameCounter = ((this.netParams.tcKeyFrameCounter + 1) & 0xffffffff) >>> 0; return this.netParams.tcKeyFrameCounter; } /** * Get next network key frame counter. * HOT PATH: Optimized counter increment * @returns Incremented network key frame counter (wraps at 0xffffffff) */ /* @__INLINE__ */ nextNWKKeyFrameCounter() { this.netParams.networkKeyFrameCounter = ((this.netParams.networkKeyFrameCounter + 1) & 0xffffffff) >>> 0; return this.netParams.networkKeyFrameCounter; } /** * 05-3474-23 #4.4.11.2 (Network Key transport) * * Store a pending network key that will become active once a matching SWITCH_KEY is received. * * SPEC COMPLIANCE NOTES: * - ✅ Stages pending network key and associated sequence number per TRANSPORT_KEY requirements * - ✅ Normalizes sequence to 8-bit value to mirror Zigbee NWK field size * - ✅ Copies key material to avoid caller mutations (spec mandates immutable staging) * - ⚠️ Does not persist staged key to disk; relies on immediate SWITCH_KEY follow-up (acceptable for coordinator uptime) * DEVICE SCOPE: Trust Center * * @param key Raw network key bytes (16 bytes) * @param sequenceNumber Sequence number advertised for the pending key */ setPendingNetworkKey(key, sequenceNumber) { this.#pendingNetworkKey = Buffer.from(key); this.#pendingNetworkKeySequenceNumber = sequenceNumber & 0xff; logger_js_1.logger.debug(() => `Staged pending network key seq=${this.#pendingNetworkKeySequenceNumber}`, NS); } markNetworkKeyTransported(address64) { const device = this.deviceTable.get(address64); if (device !== undefined) { device.lastTransportedNetworkKeySeq = this.netParams.networkKeySequenceNumber; } } /** * 05-3474-23 #4.4.11.5 (Switch Key) * * Activate the staged network key if the sequence number matches. * Resets frame counters and re-registers hashed keys for cryptographic operations. * * SPEC COMPLIANCE NOTES: * - ✅ Activates staged key only when sequence matches SWITCH_KEY command * - ✅ Resets NWK frame counter as mandated after key activation * - ✅ Re-registers hashed keys for LINK/NWK/TRANSPORT/LOAD contexts to keep crypto in sync * - ✅ Clears staging buffers to prevent reuse or leakage * - ⚠️ Does not emit management notifications; assumes higher layer handles ANNCE broadcasts * DEVICE SCOPE: Trust Center * * @param sequenceNumber Sequence number referenced by SWITCH_KEY command * @returns true when activation succeeded, false when no matching pending key exists */ activatePendingNetworkKey(sequenceNumber) { if (this.#pendingNetworkKey === undefined || this.#pendingNetworkKeySequenceNumber !== sequenceNumber) { return false; } this.netParams.networkKey = Buffer.from(this.#pendingNetworkKey); this.netParams.networkKeySequenceNumber = sequenceNumber; this.netParams.networkKeyFrameCounter = 0; this.#pendingNetworkKey = undefined; this.#pendingNetworkKeySequenceNumber = undefined; (0, zigbee_js_1.registerDefaultHashedKeys)((0, zigbee_js_1.makeKeyedHashByType)(0 /* ZigbeeKeyType.LINK */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(1 /* ZigbeeKeyType.NWK */, this.netParams.networkKey), (0, zigbee_js_1.makeKeyedHashByType)(2 /* ZigbeeKeyType.TRANSPORT */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(3 /* ZigbeeKeyType.LOAD */, this.netParams.tcKey)); logger_js_1.logger.debug(() => `Activated network key seq=${sequenceNumber}`, NS); return true; } // private countDirectChildren(exclude64?: bigint): { childCount: number; routerCount: number } { // let childCount = 0; // let routerCount = 0; // for (const [device64, entry] of this.deviceTable) { // if (!entry.neighbor) { // continue; // } // if (exclude64 !== undefined && device64 === exclude64) { // continue; // } // childCount += 1; // if (entry.capabilities?.deviceType === 1) { // routerCount += 1; // } // } // return { childCount, routerCount }; // } /** * 05-3474-23 #3.6.1.10 (Network address allocation) * * SPEC COMPLIANCE NOTES: * - ✅ Allocates short addresses within 0x0001-0xfff7 range, excluding coordinator and broadcast values * - ✅ Ensures uniqueness against current `address16ToAddress64` map before assignment * - ⚠️ Uses pseudo-random selection rather than deterministic increment (allowed by spec) * - ⚠️ No persistence of last issued address; relies on state table to avoid collisions after reboot * DEVICE SCOPE: Coordinator, routers (N/A) */ assignNetworkAddress() { let newNetworkAddress = 0xffff; let unique = false; do { // maximum exclusive, minimum inclusive newNetworkAddress = Math.floor(Math.random() * (65528 /* ZigbeeConsts.BCAST_MIN */ - 0x0001) + 0x0001); unique = this.address16ToAddress64.get(newNetworkAddress) === undefined; } while (!unique); return newNetworkAddress; } /** * 05-3474-23 #3.6.1.11 / Table 3-54 (End Device Timeout) * * Update the stored end device timeout metadata for a device. * * SPEC COMPLIANCE NOTES: * - ✅ Validates timeout index against Table 3-54 mapping (0-14) * - ✅ Stores absolute expiration timestamp for NLME-ED-TIMEOUT enforcement * - ✅ Persists last requested index to reuse during retransmission handling * - ⚠️ Does not enforce parent capability bits; assumes MAC handler already vetted support * - ⚠️ Lifetime not persisted to disk (cleared on restart per spec allowance) * DEVICE SCOPE: Coordinator, routers (N/A) * * @param address64 IEEE address of the end device * @param timeoutIndex Requested timeout index (0-14) * @param now Optional timestamp override (for testing) * @returns Updated timeout metadata or undefined if device/index invalid */ updateEndDeviceTimeout(address64, timeoutIndex, now = Date.now()) { const timeoutMs = exports.END_DEVICE_TIMEOUT_TABLE_MS[timeoutIndex]; if (timeoutMs === undefined) { return undefined; } const device = this.deviceTable.get(address64); if (device === undefined) { return undefined; } device.endDeviceTimeout = { timeoutIndex, timeoutMs, lastUpdated: now, expiresAt: now + timeoutMs, }; return device.endDeviceTimeout; } /** * 05-3474-23 #3.7.3 (NWK security) / IEEE 802.15.4-2015 #9.4.2 * * Update and validate the incoming NWK security frame counter for a device. * * SPEC COMPLIANCE NOTES: * - ✅ Rejects replayed NWK security frames when counter does not strictly increase * - ✅ Handles counter wrap from 0xffffffff → 0 per Zigbee PRO requirement * - ✅ Stores last accepted counter per IEEE address for subsequent validation * - ⚠️ Devices without stored state (e.g., unknown IEEE) default to allowing frame (per spec recommendation) * - ⚠️ Persistence across restarts not implemented (TODO noted) * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param address64 * @param frameCounter * @returns false if the provided counter is a replay (<= stored value, excluding wrap). */ updateIncomingNWKFrameCounter(address64, frameCounter) { if (address64 === undefined) { return true; } const device = this.deviceTable.get(address64); if (device === undefined) { return true; } const previous = device.incomingNWKFrameCounter; if (previous === undefined) { device.incomingNWKFrameCounter = frameCounter; return true; } if (previous === 0xffffffff && frameCounter === 0) { device.incomingNWKFrameCounter = frameCounter; return true; } if (frameCounter > previous) { device.incomingNWKFrameCounter = frameCounter; return true; } return false; } /** * IEEE 802.15.4-2015 #10.2.1 (Link Quality Indication) * * Apply logistic curve on standard mapping to LQI range [0..255] * * - Silabs EFR32: the RSSI range of [-100..-36] is mapped to an LQI range [0..255] * - TI zstack: `LQI = (MAC_SPEC_ED_MAX * (RSSIdbm - ED_RF_POWER_MIN_DBM)) / (ED_RF_POWER_MAX_DBM - ED_RF_POWER_MIN_DBM);` * where `MAC_SPEC_ED_MAX = 255`, `ED_RF_POWER_MIN_DBM = -87`, `ED_RF_POWER_MAX_DBM = -10` * - Nordic: RSSI accuracy valid range -90 to -20 dBm * * SPEC COMPLIANCE NOTES: * - ✅ Produces LQI values in mandated 0-255 range * - ✅ Clamps RSSI to implementation-defined sensitivity window before scaling * - ✅ Applies monotonic mapping to preserve relative ordering (spec leaves exact curve implementation-defined) * - ⚠️ Logistic curve tuned to typical 2.4 GHz radios; may require calibration per PHY * - ⚠️ rssiMin/rssiMax derived from runtime observation rather than PHY constants * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) */ mapRSSIToLQI(rssi) { if (rssi < this.rssiMin) { return 0; } if (rssi > this.rssiMax) { return 255; } return Math.floor(255 / (1 + Math.exp(-0.13 * (rssi - (this.rssiMin + 0.45 * (this.rssiMax - this.rssiMin)))))); } /** * 05-3474-23 #3.3.4.3 (Link Quality Assessment) * * LQA_raw (c, r) = 255 * (c - c_min) / (c_max - c_min) * (r - r_min) / (r_max - r_min) * - c_min is the lowest signal quality ever reported, i.e. for a packet that can barely be received * - c_max is the highest signal quality ever reported, i.e. for a packet received under ideal conditions * - r_min is the lowest signal strength ever reported, i.e. for a packet close to receiver sensitivity * - r_max is the highest signal strength ever reported, i.e. for a packet received from a strong, close-by transmitter * HOT PATH: Called for every incoming frame to compute link quality assessment. * * SPEC COMPLIANCE NOTES: * - ✅ Computes link quality assessment (LQA) using normalized RSSI/LQI ranges per Zigbee PRO guidance * - ✅ Ensures output range 0-255 for compatibility with NLME-LQI reports * - ✅ Accepts externally provided LQI or derives from RSSI for MACs that omit it * - ⚠️ Logistic coefficients tuned empirically; spec allows vendor-specific mapping * - ⚠️ Runtime min/max windows updated elsewhere; assumes values reflect current environment * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param signalStrength RSSI value * @param signalQuality LQI value (optional, computed from RSSI if not provided) * @returns Computed LQA value (0-255) */ /* @__INLINE__ */ computeLQA(signalStrength, signalQuality) { // HOT PATH: Map RSSI to LQI if not provided if (signalQuality === undefined) { signalQuality = this.mapRSSIToLQI(signalStrength); } // HOT PATH: Clamp signal strength to valid range if (signalStrength < this.rssiMin) { signalStrength = this.rssiMin; } if (signalStrength > this.rssiMax) { signalStrength = this.rssiMax; } // HOT PATH: Clamp signal quality to valid range if (signalQuality < this.lqiMin) { signalQuality = this.lqiMin; } if (signalQuality > this.lqiMax) { signalQuality = this.lqiMax; } // HOT PATH: Compute LQA with optimized formula (single Math.floor call) return Math.floor((((255 * (signalQuality - this.lqiMin)) / (this.lqiMax - this.lqiMin)) * (signalStrength - this.rssiMin)) / (this.rssiMax - this.rssiMin)); } /** * 05-3474-23 #2.4.4.2.3 (Neighbor table reporting) * * Compute the median LQA for a device from `recentLQAs` or using `signalStrength` directly if device unknown. * If given, stores the computed LQA from given parameters in the `recentLQAs` list of the device before computing median. * * SPEC COMPLIANCE NOTES: * - ✅ Maintains rolling median of recent LQA samples for stable reporting in Mgmt_Lqi_rsp * - ✅ Falls back to instantaneous computation when history absent, matching spec guidance * - ✅ Resolves IEEE address from short address when needed for table lookup * - ⚠️ Median window size configurable (default 10) - spec does not mandate exact count * - ⚠️ Zero returned when device unknown aligns with spec allowance for missing entries * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param address16 Used to retrieve `address64` if not given (must be valid if 64 is not) * @param address64 The IEEE address of the device * @param signalStrength RSSI. Optional (only use existing entries if not given) * @param signalQuality LQI. Optional (only use existing entries if not given) * @param maxRecent Number of entries to retain in rolling window (default 10) * @returns Median LQA for the device or 0 when unavailable */ computeDeviceLQA(address16, address64, signalStrength, signalQuality, maxRecent = 10) { if (address64 === undefined && address16 !== undefined) { address64 = this.address16ToAddress64.get(address16); } // sanity check if (address64 !== undefined) { const device = this.deviceTable.get(address64); if (!device) { return 0; } if (signalStrength !== undefined) { const lqa = this.computeLQA(signalStrength, signalQuality); if (device.recentLQAs.length > maxRecent) { // remove oldest LQA if necessary device.recentLQAs.shift(); } device.recentLQAs.push(lqa); } if (device.recentLQAs.length === 0) { return 0; } if (device.recentLQAs.length === 1) { return device.recentLQAs[0]; } const sortedLQAs = device.recentLQAs.slice( /* copy */).sort((a, b) => a - b); const midIndex = Math.floor(sortedLQAs.length / 2); const median = Math.floor(sortedLQAs.length % 2 === 1 ? sortedLQAs[midIndex] : (sortedLQAs[midIndex - 1] + sortedLQAs[midIndex]) / 2); return median; } return signalStrength !== undefined ? this.computeLQA(signalStrength, signalQuality) : 0; } /** * 05-3474-23 #3.3.1.5 (NWK radius handling) * * Decrement radius value for NWK frame forwarding. * HOT PATH: Optimized computation * * SPEC COMPLIANCE NOTES: * - ✅ Decrements NWK radius while enforcing minimum value of 1 as mandated * - ✅ Substitutes CONFIG_NWK_MAX_HOPS when radius=0 (interpreted as unlimited per spec) * - ⚠️ Does not update route record metrics; caller responsible for hop tracking * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param radius Current radius value * @returns Decremented radius (minimum 1) */ /* @__INLINE__ */ decrementRadius(radius) { const newRadius = (radius === 0 ? nwk_handler_js_1.CONFIG_NWK_MAX_HOPS : radius) - 1; return newRadius < 1 ? 1 : newRadius; } /** * Make a key for AppLinkKeyStoreEntry * HOT PATH: Optimized computation * @param deviceA * @param deviceB * @returns */ /* @__INLINE__ */ #makeAppLinkKeyId(deviceA, deviceB) { return deviceA < deviceB ? `${deviceA}-${deviceB}` : `${deviceB}-${deviceA}`; } /** * 05-3474-23 #4.4.11 (Trust Center link/app keys) * * SPEC COMPLIANCE NOTES: * - ✅ Retrieves stored application/link key using canonicalized IEEE pair * - ✅ Returns undefined when pair missing, allowing caller to trigger key negotiation per spec * - ⚠️ Does not validate key freshness; higher layers manage key attributes * DEVICE SCOPE: Trust Center */ getAppLinkKey(deviceA, deviceB) { const entry = this.appLinkKeyTable.get(this.#makeAppLinkKeyId(deviceA, deviceB)); if (entry === undefined) { return undefined; } return entry.key; } /** * 05-3474-23 #4.4.11.1 (Application Link Key establishment) * * SPEC COMPLIANCE NOTES: * - ✅ Stores link/application keys using sorted IEEE tuple to match spec requirement for unordered pairs * - ✅ Keeps full 16-byte key material intact for subsequent APS ENCRYPT operations * - ⚠️ Does not persist key attributes (VERIFIED/PROVISIONAL) – tracked elsewhere * DEVICE SCOPE: Trust Center */ setAppLinkKey(deviceA, deviceB, key) { const [canonicalA, canonicalB] = deviceA < deviceB ? [deviceA, deviceB] : [deviceB, deviceA]; const stored = { deviceA: canonicalA, deviceB: canonicalB, key, }; this.appLinkKeyTable.set(this.#makeAppLinkKeyId(canonicalA, canonicalB), stored); } /** * 05-3474-23 #4.5.1 (Install Code processing) * * SPEC COMPLIANCE NOTES: * - ✅ Validates install code length against permitted sizes (8/10/14/18/22/26 bytes) * - ✅ Verifies CRC-16 per Zigbee specification before accepting raw install codes * - ✅ Derives link key using AES-MMO hash when provided with plain install code * - ✅ Stores hashed value when caller already supplied derived key (e.g., from commissioning tool) * - ⚠️ CRC computed locally; assumes little-endian order per spec Appendix B * DEVICE SCOPE: Trust Center * * @param device64 IEEE address of device whose code is being stored * @param installCode Install code or hashed key buffer (length varies) * @param hashed Indicates that `installCode` already contains derived key material * @returns Derived application link key associated with Trust Center */ addInstallCode(device64, installCode, hashed = false) { if (this.trustCenterPolicies.installCode === InstallCodePolicy.NOT_SUPPORTED) { throw new Error("Install codes are not supported by the current Trust Center policy"); } const keyLength = hashed ? installCode.byteLength : installCode.byteLength - 2; if (!zigbee_js_1.INSTALL_CODE_VALID_SIZES.some((size) => size === keyLength)) { throw new Error(`Invalid install code length ${keyLength}`); } if (hashed) { this.installCodeTable.set(device64, installCode); this.setAppLinkKey(device64, this.netParams.eui64, installCode); return installCode; } const providedCRC = installCode.readUInt16LE(keyLength); const computedCRC = (0, zigbee_js_1.computeInstallCodeCRC)(installCode.subarray(0, keyLength)); if (providedCRC !== computedCRC) { throw new Error("Invalid install code CRC"); } const key = (0, zigbee_js_1.aes128MmoHash)(installCode); this.installCodeTable.set(device64, installCode); this.setAppLinkKey(device64, this.netParams.eui64, key); return key; } /** * 05-3474-23 #4.5.1 (Install Code lifecycle) * * SPEC COMPLIANCE NOTES: * - ✅ Removes stored install code metadata upon revocation * - ⚠️ Leaves derived link key intact (spec allows retention for existing secure links) * DEVICE SCOPE: Trust Center */ removeInstallCode(device64) { this.installCodeTable.delete(device64); // Keep derived link key in appLinkKeyTable; it may have been rotated independently. } /** * 05-3474-23 #4.7.6 (Trust Center persistent data) * * Save state to file system in TLV format. * * SPEC COMPLIANCE NOTES: * - ✅ Persists Trust Center datasets (network parameters, device table, link keys) between restarts * - ✅ Adds frame-counter jump offset when storing to meet anti-replay requirements after reboot * - ✅ Serializes TLV records for extensibility and backward compatibility * - ⚠️ Format version tracked locally; interoperability with other implementations requires converter * - ⚠️ Application link key attributes not currently stored (keys only) * DEVICE SCOPE: Trust Center * * Format version 1: * - VERSION tag * - Network parameter tags (EUI64, PAN_ID, etc.) * - DEVICE_ENTRY tags (each containing nested TLV device data) * - END_MARKER */ async saveState() { // estimate buffer size (generous upper bound) const estimatedSize = (0, save_serializer_js_1.estimateTLVStateSize)(this.deviceTable.size, this.appLinkKeyTable.size); const state = Buffer.allocUnsafe(estimatedSize); let offset = 0; // write version first offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 240 /* TLVTag.VERSION */, save_serializer_js_1.SAVE_FORMAT_VERSION); // network parameters (can be added/removed without breaking old readers) offset = (0, save_serializer_js_1.writeTLVBigUInt64LE)(state, offset, 1 /* TLVTag.EUI64 */, this.netParams.eui64); offset = (0, save_serializer_js_1.writeTLVUInt16LE)(state, offset, 2 /* TLVTag.PAN_ID */, this.netParams.panId); offset = (0, save_serializer_js_1.writeTLVBigUInt64LE)(state, offset, 3 /* TLVTag.EXTENDED_PAN_ID */, this.netParams.extendedPanId); offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 4 /* TLVTag.CHANNEL */, this.netParams.channel); offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 5 /* TLVTag.NWK_UPDATE_ID */, this.netParams.nwkUpdateId); offset = (0, save_serializer_js_1.writeTLVInt8)(state, offset, 6 /* TLVTag.TX_POWER */, this.netParams.txPower); offset = (0, save_serializer_js_1.writeTLV)(state, offset, 7 /* TLVTag.NETWORK_KEY */, this.netParams.networkKey); offset = (0, save_serializer_js_1.writeTLVUInt32LE)(state, offset, 8 /* TLVTag.NETWORK_KEY_FRAME_COUNTER */, this.netParams.networkKeyFrameCounter + CONFIG_SAVE_FRAME_COUNTER_JUMP_OFFSET); offset = (0, save_serializer_js_1.writeTLVUInt8)(state, offset, 9 /* TLVTag.NETWORK_KEY_SEQUENCE_NUMBER */, this.netParams.networkKeySequenceNumber); offset = (0, save_serializer_js_1.writeTLV)(state, offset, 10 /* TLVTag.TC_KEY */, this.netParams.tcKey); offset = (0, save_serializer_js_1.writeTLVUInt32LE)(state, offset, 11 /* TLVTag.TC_KEY_FRAME_COUNTER */, this.netParams.tcKeyFrameCounter + CONFIG_SAVE_FRAME_COUNTER_JUMP_OFFSET); // device table (count is implicit in number of DEVICE_ENTRY tags) for (const [device64, device] of this.deviceTable) { const sourceRouteEntries = this.sourceRouteTable.get(device.address16); const deviceEntry = (0, save_serializer_js_1.serializeDeviceEntry)(device64, device.address16, device.capabilities ? (0, mac_js_1.encodeMACCapabilities)(device.capabilities) : 0x00, device.authorized, device.neighbor, device.lastTransportedNetworkKeySeq, sourceRouteEntries); offset = (0, save_serializer_js_1.writeTLV)(state, offset, 128 /* TLVTag.DEVICE_ENTRY */, deviceEntry); } for (const entry of this.appLinkKeyTable.values()) { const serializedEntry = (0, save_serializer_js_1.serializeAppLinkKeyEntry)(entry.deviceA, entry.deviceB, entry.key); offset = (0, save_serializer_js_1.writeTLV)(state, offset, 12 /* TLVTag.APP_LINK_KEY_ENTRY */, serializedEntry); } // write end marker (aids debugging and validates complete write) state.writeUInt8(255 /* TLVTag.END_MARKER */, offset++); const writtenState = state.subarray(0, offset); // write only the used portion await (0, promises_1.writeFile)(this.#savePath, writtenState); logger_js_1.logger.debug(() => `Saved state to ${this.#savePath} (${writtenState.byteLength} bytes)`, NS); } /** * 05-3474-23 #4.7.6 (Trust Center persistent data) * * Read the current network state in the save file, if any present. * * SPEC COMPLIANCE NOTES: * - ✅ Reads TLV state blob and validates version before applying * - ✅ Logs metadata (PAN ID, channel) for diagnostics per spec recommendations * - ⚠️ Unknown future versions are attempted with warning rather than hard fail (best effort) * DEVICE SCOPE: Trust Center */ async readNetworkState() { try { const stateBuffer = await (0, promises_1.readFile)(this.#savePath); logger_js_1.logger.debug(() => `Loaded state from ${this.#savePath} (${stateBuffer.byteLength} bytes)`, NS); // Parse state once into typed structure with all values already converted to final types const state = (0, save_serializer_js_1.readTLVs)(stateBuffer); // Check version (already parsed to number) const version = state.version ?? 1; if (version > save_serializer_js_1.SAVE_FORMAT_VERSION) { logger_js_1.logger.warning(`Unknown save format version ${version}, attempting to load`, NS); } logger_js_1.logger.debug(() => `Current save network: eui64=${state.eui64} panId=${state.panId} channel=${state.channel}`, NS); return state; } catch { /* empty */ } } /** * 05-3474-23 #4.7.6 (Trust Center start-up procedure) * * Load state from file system if exists, else save "initial" state. * Afterwards, various keys are pre-hashed and descriptors pre-encoded. * * SPEC COMPLIANCE NOTES: * - ✅ Restores network parameters, device table, and link keys before enabling stack operations * - ✅ Recomputes hashed keys for LINK/NWK/TRANSPORT/LOAD usage as required for secure processing * - ✅ Initializes coordinator descriptors per Zigbee Device Objects defaults * - ⚠️ Missing persistence for per-device incoming NWK frame counters (TODO noted) * - ⚠️ Creates initial save file when none exists to align with spec initialization sequence * DEVICE SCOPE: Trust Center */ async loadState() { // pre-emptive this.#loaded = true; const state = await this.readNetworkState(); if (state) { // Network parameters already parsed to final types - update context this.netParams.eui64 = state.eui64; this.netParams.panId = state.panId; this.netParams.extendedPanId = state.extendedPanId; this.netParams.channel = state.channel; this.netParams.nwkUpdateId = state.nwkUpdateId; this.netParams.txPower = state.txPower; this.netParams.networkKey = state.networkKey; this.netParams.networkKeyFrameCounter = state.networkKeyFrameCounter; this.netParams.networkKeySequenceNumber = state.networkKeySequenceNumber; this.netParams.tcKey = state.tcKey; this.netParams.tcKeyFrameCounter = state.tcKeyFrameCounter; // Device entries already parsed with all nested source routes logger_js_1.logger.debug(() => `Current save devices: ${state.deviceEntries.length}`, NS); for (const device of state.deviceEntries) { // Device values already parsed - just destructure const { address64, address16, capabilities, authorized, neighbor, sourceRouteEntries, lastTransportedNetworkKeySeq } = device; const decodedCap = capabilities !== 0 ? (0, mac_js_1.decodeMACCapabilities)(capabilities) : undefined; this.deviceTable.set(address64, { address16, capabilities: decodedCap, authorized, neighbor, lastTransportedNetworkKeySeq, recentLQAs: [], incomingNWKFrameCounter: undefined, // TODO: record this (should persist across reboots) endDeviceTimeout: undefined, linkStatusMisses: 0, // will stay zero for RFDs }); this.address16ToAddress64.set(address16, address64); if (decodedCap && !decodedCap.rxOnWhenIdle) { this.indirectTransmissions.set(address64, []); } if (sourceRouteEntries.length > 0) { const routes = sourceRouteEntries.map((entry) => ({ relayAddresses: entry.relayAddresses, pathCost: entry.pathCost, lastUpdated: entry.lastUpdated, failureCount: 0, lastUsed: undefined, })); this.sourceRouteTable.set(address16, routes); } } for (const entry of state.appLinkKeys) { this.setAppLinkKey(entry.deviceA, entry.deviceB, entry.key); } } else { // `this.#savePath` does not exist, using constructor-given network params, do initial save await this.saveState(); } // pre-compute hashes for default keys for faster processing (0, zigbee_js_1.registerDefaultHashedKeys)((0, zigbee_js_1.makeKeyedHashByType)(0 /* ZigbeeKeyType.LINK */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(1 /* ZigbeeKeyType.NWK */, this.netParams.networkKey), (0, zigbee_js_1.makeKeyedHashByType)(2 /* ZigbeeKeyType.TRANSPORT */, this.netParams.tcKey), (0, zigbee_js_1.makeKeyedHashByType)(3 /* ZigbeeKeyType.LOAD */, this.netParams.tcKey)); this.tcVerifyKeyHash = (0, zigbee_js_1.makeKeyedHash)(this.netParams.tcKey, 0x03 /* input byte per spec for VERIFY_KEY */); const [address, nodeDescriptor, powerDescriptor, simpleDescriptors, activeEndpoints] = (0, descriptors_js_1.encodeCoordinatorDescriptors)(this.netParams.eui64); this.configAttributes.address = address; this.configAttributes.nodeDescriptor = nodeDescriptor; this.configAttributes.powerDescriptor = powerDescriptor; this.configAttributes.simpleDescriptors = simpleDescriptors; this.configAttributes.activeEndpoints = activeEndpoints; } /** * 05-3474-23 #2.3.2.3 (Node Descriptor) * * Set the manufacturer code in the pre-encoded node descriptor * * SPEC COMPLIANCE NOTES: * - ✅ Writes manufacturer code at fixed offset within pre-encoded ZDO node descriptor response * - ⚠️ Assumes descriptor already generated via `encodeCoordinatorDescriptors` * DEVICE SCOPE: Coordinator, routers (N/A), end devices (N/A) * * @param code Manufacturer code assigned by CSA */ setManufacturerCode(code) { this.configAttributes.nodeDescriptor.writeUInt16LE(code, 7 /* static offset */); } /** * 05-3474-23 #4.7.6 (Trust Center maintenance) * * SPEC COMPLIANCE NOTES: * - ✅ Persists state at configured interval while refreshing timer to maintain cadence * - ⚠️ Interval configurable via CONFIG_SAVE_STATE_TIME (60s default) * DEVICE SCOPE: Trust Center */ async savePeriodicState() { await this.saveState(); this.#saveStateTimeout?.refresh(); } /** * Revert allowing joins (keeps `allowRejoinsWithWellKnownKey=true`). * * SPEC COMPLIANCE: * - ✅ Clears timer correctly * - ✅ Updates Trust Center allowJoins policy * - ✅ Maintains allowRejoinsWithWellKnownKey for rejoins * - ✅ Sets associationPermit flag for MAC layer * DEVICE SCOPE: Trust Center */ disallowJoins() { clearTimeout(this.#allowJoinTimeout); this.#allowJoinTimeout = undefined; this.trustCenterPolicies.allowJoins = false; this.trustCenterPolicies.allowRejoinsWithWellKnownKey = true; this.associationPermit = false; logger_js_1.logger.info("Disallowed joins", NS); } /** * SPEC COMPLIANCE: * - ✅ Implements timed join window per spec * - ✅ Updates Trust Center policies * - ✅ Sets MAC associationPermit flag * - ✅ Clamps 0xff to 0xfe for security * - ✅ Auto-disallows after timeout * DEVICE SCOPE: Trust Center * * @param duration The length of time in seconds during which the trust center will allow joins. * The value 0x00 and 0xff indicate that permission is disabled or enabled, respectively, without a specified time limit. * 0xff is clamped to 0xfe for security reasons * @param macAssociationPermit If true, also allow association on coordinator itself. Ignored if duration 0. */ allowJoins(duration, macAssociationPermit) { if (duration > 0) { clearTimeout(this.#allowJoinTimeout); this.trustCenterPolicies.allowJoins = true; this.trustCenterPolicies.allowRejoinsWithWellKnownKey = true; this.associationPermit = macAssociationPermit; this.#allowJoinTimeout = setTimeout(this.disallowJoins.bind(this), Math.min(duration, 0xfe) * 1000); logger_js_1.logger.info(`Allowed joins for ${duration} seconds (self=${macAssociationPermit})`, NS); } else { this.disallowJoins(); } } /** * Handle device association (initial join or rejoin) * * SPEC COMPLIANCE: * - ✅ Validates allowJoins policy for initial join * - ✅ Assigns network addresses correctly * - ✅ Detects and handles address conflicts * - ✅ Creates device table entries with capabilities * - ✅ Sets up indirect transmission for rxOnWhenIdle=false * - ✅ Returns appropriate status codes per IEEE 802.15.4 * - ✅ Triggers state save after association * - ⚠️ Unknown rejoins succeed if allowOverride=true (potential security risk) * - ✅ Enforces install code requirement (denies initial join when missing) * - ✅ Detects network key changes on rejoin and schedules transport * DEVICE SCOPE: Coordinator, routers (N/A) * * @param source16 * @param source64 Assumed valid if assocType === 0x00 * @param initialJoin If false, rejoin. * @param neighbor True if the device associating is a neighbor of the coordinator * @param capabilities MAC capabilities * @param denyOverride Treat as MACAssociationStatus.PAN_ACCESS_DENIED * @param allowOverride Treat as MACAssociationStatus.SUCCESS * @returns */ async associate(source16, source64, initialJoin, capabilities, neighbor, denyOverride, allowOverride) { // 0xffff when not successful and should not be retried let newAddress16 = source16; let status = mac_js_1.MACAssociationStatus.SUCCESS; let unknownRejoin = false; let requiresTransportKey = false; if (denyOverride) { newAddress16 = 0xffff; status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED; } else if (allowOverride) { if ((source16 === undefined || !this.address16ToAddress64.has(source16)) && (source64 === undefined || !this.deviceTable.has(source64))) { // device unknown unknownRejoin = true; requiresTransportKey = true; } } else { if (initialJoin) { if (this.trustCenterPolicies.allowJoins) { if (source16 === undefined || source16 === 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */ || source16 >= 65528 /* ZigbeeConsts.BCAST_MIN */) { // MAC join (no `source16`) newAddress16 = this.assignNetworkAddress(); if (newAddress16 === 0xffff) { status = mac_js_1.MACAssociationStatus.PAN_FULL; } } else { const device = source64 !== undefined ? this.deviceTable.get(source64) : undefined; if (device !== undefined) { if (device.authorized) { // initial join should not conflict on 64, don't allow join if it does newAddress16 = 0xffff; status = 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */; } } else { const existingAddress64 = this.address16ToAddress64.get(source16); if (existingAddress64 !== undefined && source64 !== existingAddress64) { // join with already taken source16 newAddress16 = this.assignNetworkAddress(); if (newAddress16 === 0xffff) { status = mac_js_1.MACAssociationStatus.PAN_FULL; } else { // tell device to use the newly generated value status = 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */; } } } } } else { newAddress16 = 0xffff; status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED; } } else { // rejoin if (source16 === undefined || source16 === 0 /* ZigbeeConsts.COORDINATOR_ADDRESS */ || source16 >= 65528 /* ZigbeeConsts.BCAST_MIN */) { // rejoin without 16, generate one (XXX: never happens?) newAddress16 = this.assignNetworkAddress(); if (newAddress16 === 0xffff) { status = mac_js_1.MACAssociationStatus.PAN_FULL; } } else { const existingAddress64 = this.address16ToAddress64.get(source16); if (existingAddress64 === undefined) { // device unknown unknownRejoin = true; } else if (existingAddress64 !== source64) { // rejoin with already taken source16 newAddress16 = this.assignNetworkAddress(); if (newAddress16 === 0xffff) { status = mac_js_1.MACAssociationStatus.PAN_FULL; } else { // tell device to use the newly generated value status = 240 /* ZigbeeNWKConsts.ASSOC_STATUS_ADDR_CONFLICT */; } } } // if rejoin, network address will be stored // if (this.trustCenterPolicies.allowRejoinsWithWellKnownKey) { // } } } // something went wrong above /* v8 ignore if -- @preserve */ if (newAddress16 === undefined) { newAddress16 = 0xffff; status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED; } // const existingDevice64 = source64 ?? (source16 !== undefined ? this.address16ToAddress64.get(source16) : undefined); // const existingEntry = existingDevice64 !== undefined ? this.deviceTable.get(existingDevice64) : undefined; // if (status === MACAssociationStatus.SUCCESS && neighbor) { // const isExistingDirectChild = existingEntry?.neighbor === true; // if (!isExistingDirectChild && initialJoin && !unknownRejoin) { // const { childCount, routerCount } = this.countDirectChildren(existingDevice64); // if (childCount >= CONFIG_NWK_MAX_CHILDREN) { // newAddress16 = 0xffff; // status = MACAssociationStatus.PAN_FULL; // } else if (capabilities?.deviceType === 1 && routerCount >= CONFIG_NWK_MAX_ROUTERS) { // newAddress16 = 0xffff; // status = MACAssociationStatus.PAN_FULL; // } // } // } if (status === mac_js_1.MACAssociationStatus.SUCCESS && initialJoin && this.trustCenterPolicies.installCode === InstallCodePolicy.REQUIRED && (source64 === undefined || this.installCodeTable.get(source64) === undefined)) { newAddress16 = 0xffff; status = mac_js_1.MACAssociationStatus.PAN_ACCESS_DENIED; } logger_js_1.logger.debug(() => `DEVICE_JOINING[src=${source16}:${source64} newAddr16=${newAddress16} initialJ