UNPKG

homebridge

Version:
350 lines • 15.4 kB
/** * Commissioning Manager * * Handles passcode generation, discriminator generation, credential persistence, * QR code rendering, commissioning event listeners, and commissioning file updates. */ import { randomBytes } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { ManualPairingCodeCodec, QrCode, QrPairingCodeCodec } from '@matter/types/schema'; import { Logger } from '../../logger.js'; import { DEFAULT_PRODUCT_ID, DEFAULT_VENDOR_ID, MAX_PASSCODE_ATTEMPTS } from './ServerConfig.js'; const log = Logger.withPrefix('Matter/Server'); export class CommissioningManager { passcode = 0; discriminator = 0; vendorId; productId; commissioningInfo = {}; // Stored references so commissioning Observable listeners can be removed in // teardownCommissioningEventListeners(). matter.js Observables require the // exact same observer reference passed to .off() that was passed to .on(). // Bound at registration time so each CommissioningDeps closure stays consistent. onFabricsChanged = null; onCommissioned = null; onDecommissioned = null; constructor() { this.vendorId = DEFAULT_VENDOR_ID; this.productId = DEFAULT_PRODUCT_ID; } /** * Generate a secure random passcode * According to Matter spec, passcode must be: * - 8 digits (00000001 to 99999998) * - Not in the invalid list * - Not sequential or repeating patterns */ generateSecurePasscode() { let passcode; const maxAttempts = MAX_PASSCODE_ATTEMPTS; let attempts = 0; const invalidPasscodes = [ 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999, 12345678, 87654321, ]; do { // Use cryptographically secure random number generation with rejection sampling const maxRange = 99999998; const maxUint32 = 0xFFFFFFFF; const limit = Math.floor(maxUint32 / maxRange) * maxRange; let randomValue; do { randomValue = randomBytes(4).readUInt32BE(0); } while (randomValue >= limit); // Generate a value between 1 and 99999998 without modulo bias passcode = (randomValue % maxRange) + 1; attempts++; if (attempts > maxAttempts) { throw new Error('Failed to generate secure passcode after maximum attempts'); } } while (invalidPasscodes.includes(passcode) || !this.isValidPasscode(passcode)); return passcode; } /** * Validate a passcode according to Matter specifications */ isValidPasscode(passcode) { // Must be between 1 and 99999998 if (passcode < 1 || passcode > 99999998) { return false; } // Convert to 8-digit string const passcodeStr = passcode.toString().padStart(8, '0'); // Check for sequential patterns (12345678, 23456789, etc.) let isSequential = true; for (let i = 1; i < passcodeStr.length; i++) { if (Number.parseInt(passcodeStr[i]) !== Number.parseInt(passcodeStr[i - 1]) + 1) { isSequential = false; break; } } if (isSequential) { return false; } // Check for reverse sequential (87654321, 76543210, etc.) let isReverseSequential = true; for (let i = 1; i < passcodeStr.length; i++) { if (Number.parseInt(passcodeStr[i]) !== Number.parseInt(passcodeStr[i - 1]) - 1) { isReverseSequential = false; break; } } if (isReverseSequential) { return false; } // Check for too many repeating digits (more than 3 of same digit) const digitCounts = new Map(); for (const digit of passcodeStr) { digitCounts.set(digit, (digitCounts.get(digit) || 0) + 1); const count = digitCounts.get(digit); if (count !== undefined && count > 3) { return false; } } return true; } /** * Generate a random discriminator * According to Matter spec, discriminator must be: * - 12 bits (0-4095) * - Should be random for security */ generateRandomDiscriminator() { // Generate cryptographically secure random 12-bit discriminator (0-4095) const discriminator = randomBytes(2).readUInt16BE(0) & 0x0FFF; // Mask to 12 bits // Validate discriminator range if (discriminator < 0 || discriminator > 4095) { throw new Error(`Invalid discriminator generated: ${discriminator}`); } return discriminator; } /** * Load or generate commissioning credentials (passcode and discriminator) * Reads/writes a simple credentials.json file in the bridge storage directory. */ async loadOrGenerateCredentials(matterStoragePath) { const credentialsPath = join(matterStoragePath, 'credentials.json'); try { const { readFile } = await import('node:fs/promises'); const data = JSON.parse(await readFile(credentialsPath, 'utf-8')); if (data.passcode && data.discriminator) { log.info('Loading existing commissioning credentials from storage'); this.passcode = data.passcode; this.discriminator = data.discriminator; return; } } catch { // File doesn't exist or is invalid - generate new credentials } // Generate new credentials and store them log.info('Generating new commissioning credentials'); this.passcode = this.generateSecurePasscode(); this.discriminator = this.generateRandomDiscriminator(); // Store for future use await writeFile(credentialsPath, JSON.stringify({ passcode: this.passcode, discriminator: this.discriminator }, null, 2), 'utf-8'); log.info('Commissioning credentials saved to storage'); } /** * Generate and display commissioning information */ async generateCommissioningInfo(deps) { const passcode = this.passcode.toString().padStart(8, '0'); const discriminator = this.discriminator; const vendorId = this.vendorId; const productId = this.productId; // Use Matter.js library to generate pairing codes properly const manualCode = ManualPairingCodeCodec.encode({ discriminator, passcode: this.passcode, }); // Format as XXXX-XXX-XXXX for display const manualPairingCode = `${manualCode.slice(0, 4)}-${manualCode.slice(4, 7)}-${manualCode.slice(7, 11)}`; log.info(`Encoding QR code with: passcode=${this.passcode}, discriminator=${discriminator}, vendorId=${vendorId}, productId=${productId}`); const qrCodePayload = QrPairingCodeCodec.encode([{ version: 0, vendorId, productId, flowType: 0, // Standard commissioning flow discoveryCapabilities: 4, // OnNetwork=4 discriminator, passcode: this.passcode, }]); log.info(`Generated QR code: ${qrCodePayload}`); log.info(`Generated manual code: ${manualPairingCode}`); // Store commissioning info this.commissioningInfo = { qrCode: qrCodePayload, manualPairingCode, }; // Save commissioning info to disk for UI access try { if (!deps.matterStoragePath) { throw new Error('Matter storage path not initialized'); } const commissioningFilePath = join(deps.matterStoragePath, 'commissioning.json'); const commissioningData = { qrCode: qrCodePayload, manualPairingCode, serialNumber: deps.serialNumber, passcode: this.passcode, discriminator: this.discriminator, commissioned: deps.fabricManager.isCommissioned(), }; await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8'); log.debug(`Saved commissioning info to ${commissioningFilePath}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log.warn(`Failed to save commissioning info to disk: ${errorMessage}`); } // Display commissioning information log.info(`${'='.repeat(60)}`); log.info('📱 MATTER COMMISSIONING INFORMATION'); log.info('='.repeat(60)); log.info(`Manual Pairing Code: ${manualPairingCode}`); log.info(`Passcode: ${passcode}`); log.info(`Discriminator: ${discriminator}`); log.info('QR Code for commissioning:'); // Generate and display QR code in terminal using matter.js native QR renderer const qrCodeString = QrCode.get(qrCodePayload); log.info(`\n${qrCodeString}`); log.info(`${'='.repeat(60)}`); } /** * Set up Matter.js commissioning event listeners */ setupCommissioningEventListeners(deps) { if (!deps.serverNode) { log.warn('Cannot set up commissioning event listeners - serverNode not initialized'); return; } if (this.onFabricsChanged || this.onCommissioned || this.onDecommissioned) { log.debug('Commissioning event listeners already registered, skipping'); return; } log.debug('Setting up commissioning event listeners'); try { // Listen for fabric changes (add/remove/update) this.onFabricsChanged = (fabricIndex, action) => { log.info(`Fabric ${action}: index ${fabricIndex}`); // Compute commissioning state once and reuse for both the file update // and the IPC emit, then push the snapshot into updateCommissioningFile // so it doesn't redo the same fabric reads. const snapshot = deps.fabricManager.getCommissioningSnapshot(); this.updateCommissioningFile(deps, snapshot).catch((error) => { log.warn('Failed to update commissioning file after fabric change:', error); }); deps.emitter.emit('commissioning-status-changed', snapshot.commissioned, snapshot.fabricCount); }; deps.serverNode.events.commissioning.fabricsChanged.on(this.onFabricsChanged); // Listen for commissioning (first fabric added) this.onCommissioned = () => { log.info('Bridge commissioned'); const snapshot = deps.fabricManager.getCommissioningSnapshot(); this.updateCommissioningFile(deps, snapshot).catch((error) => { log.warn('Failed to update commissioning file after commissioning:', error); }); deps.emitter.emit('commissioning-status-changed', true, snapshot.fabricCount); }; deps.serverNode.events.commissioning.commissioned.on(this.onCommissioned); // Listen for decommissioning (last fabric removed) this.onDecommissioned = () => { log.info('Bridge decommissioned'); this.updateCommissioningFile(deps).catch((error) => { log.warn('Failed to update commissioning file after decommissioning:', error); }); deps.emitter.emit('commissioning-status-changed', false, 0); }; deps.serverNode.events.commissioning.decommissioned.on(this.onDecommissioned); log.debug('Commissioning event listeners registered successfully'); } catch (error) { log.error('Failed to set up commissioning event listeners:', error); // Roll back any partial registration so a retry can succeed this.teardownCommissioningEventListeners(deps.serverNode); } } /** * Remove Matter.js commissioning event listeners. * * Called from ServerLifecycle.cleanup() to release the closures that capture * deps (serverNode, fabricManager, emitter, matterStoragePath) and `this`. * Without this, the matter.js Observable retains the observer across stop() * cycles, holding the entire CommissioningDeps graph from GC. */ teardownCommissioningEventListeners(serverNode) { if (!serverNode) { this.onFabricsChanged = null; this.onCommissioned = null; this.onDecommissioned = null; return; } try { if (this.onFabricsChanged) { serverNode.events.commissioning.fabricsChanged.off(this.onFabricsChanged); } if (this.onCommissioned) { serverNode.events.commissioning.commissioned.off(this.onCommissioned); } if (this.onDecommissioned) { serverNode.events.commissioning.decommissioned.off(this.onDecommissioned); } log.debug('Commissioning event listeners removed'); } catch (error) { log.debug('Error removing commissioning event listeners:', error); } finally { this.onFabricsChanged = null; this.onCommissioned = null; this.onDecommissioned = null; } } /** * Update commissioning info file when commissioning state changes. * * Pass a precomputed `snapshot` to avoid redundant fabric reads — each of * isCommissioned(), getCommissionedFabricCount(), and getFabricInfo() may * scan the matter storage directory synchronously, and they're called in * tight succession from the commissioning event handlers. */ async updateCommissioningFile(deps, snapshot) { try { if (!deps.matterStoragePath) { return; } const { commissioned, fabricCount, fabrics } = snapshot ?? deps.fabricManager.getCommissioningSnapshot(); const commissioningFilePath = join(deps.matterStoragePath, 'commissioning.json'); const commissioningData = { qrCode: this.commissioningInfo.qrCode, manualPairingCode: this.commissioningInfo.manualPairingCode, serialNumber: deps.serialNumber, passcode: this.passcode, discriminator: this.discriminator, commissioned, fabricCount, fabrics, }; await writeFile(commissioningFilePath, JSON.stringify(commissioningData, null, 2), 'utf-8'); log.debug('Updated commissioning info file'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log.debug(`Failed to update commissioning info file: ${errorMessage}`); } } } //# sourceMappingURL=CommissioningManager.js.map