UNPKG

knxultimate

Version:

KNX IP protocol implementation for Node. This is the ENGINE of Node-Red KNX-Ultimate node.

568 lines (500 loc) 14.7 kB
/** * Loads and manages KNX Secure keyring files. * * Written in Italy with love, sun and passion, by Massimo Saccani. * * Released under the MIT License. * Use at your own risk; the author assumes no liability for damages. */ import * as crypto from 'crypto' import * as fs from 'fs' import * as xml2js from 'xml2js' import * as zlib from 'zlib' // Address classes export class IndividualAddress { raw: number constructor(address: string | number) { if (typeof address === 'string') { const parts = address.split('.') if (parts.length !== 3) { throw new Error(`Invalid individual address format: ${address}`) } const area = parseInt(parts[0]) const line = parseInt(parts[1]) const device = parseInt(parts[2]) this.raw = (area << 12) | (line << 8) | device } else { this.raw = address } } toString(): string { const area = (this.raw >> 12) & 0xf const line = (this.raw >> 8) & 0xf const device = this.raw & 0xff return `${area}.${line}.${device}` } } export class GroupAddress { raw: number constructor(address: string | number) { if (typeof address === 'string') { // Check if it's a raw number string if (!address.includes('/')) { this.raw = parseInt(address) return } const parts = address.split('/') if (parts.length === 3) { // 3-level format: main/middle/sub const main = parseInt(parts[0]) const middle = parseInt(parts[1]) const sub = parseInt(parts[2]) this.raw = (main << 11) | (middle << 8) | sub } else if (parts.length === 2) { // 2-level format: main/sub const main = parseInt(parts[0]) const sub = parseInt(parts[1]) this.raw = (main << 11) | sub } else { throw new Error(`Invalid group address format: ${address}`) } } else { this.raw = address } } toString(): string { const main = (this.raw >> 11) & 0x1f const middle = (this.raw >> 8) & 0x7 const sub = this.raw & 0xff return `${main}/${middle}/${sub}` } } // Interface for keyring data structures export interface Interface { type: string individualAddress: IndividualAddress host?: IndividualAddress userId?: number password?: string authentication?: string decryptedPassword?: string decryptedAuthentication?: string groupAddresses: Map<string, IndividualAddress[]> } export interface Backbone { key?: string decryptedKey?: Buffer latency?: number multicastAddress?: string } export interface GroupAddressKey { address: GroupAddress key: string decryptedKey?: Buffer } export interface Device { individualAddress: IndividualAddress toolKey?: string decryptedToolKey?: Buffer managementPassword?: string decryptedManagementPassword?: string authentication?: string decryptedAuthentication?: string sequenceNumber?: number serialNumber?: string } export class Keyring { private interfaces: Map<string, Interface> = new Map() private backbones: Backbone[] = [] private groupAddresses: Map<string, GroupAddressKey> = new Map() private devices: Map<string, Device> = new Map() private passwordHash?: Buffer private createdBy?: string private created?: string private iv?: Buffer /** * Load keyring content using either a file path to a .knxkeys file * or a string containing the keyring (raw XML or base64 of the .knxkeys binary). */ async load(source: string, password: string): Promise<void> { let xmlContent: string if (fs.existsSync(source)) { // Load from file path (.knxkeys) if (process.env.KNX_DEBUG === '1') console.log('🔐 Loading keyring file:', source) const zipContent = fs.readFileSync(source) xmlContent = await this.unzipKnxKeys(zipContent) } else { // Load from provided string: try XML first, then base64-encoded .knxkeys const trimmed = (source || '').trim() if (trimmed.startsWith('<')) { // Raw XML string xmlContent = trimmed } else { // Try base64-encoded .knxkeys content try { const buf = Buffer.from(trimmed, 'base64') // If base64 was invalid, the buffer will be small or garbage; unzip will throw xmlContent = await this.unzipKnxKeys(buf) } catch (e) { // Fallback: treat the string as XML even if not starting with '<' xmlContent = trimmed } } } // Parse XML const parser = new xml2js.Parser() const result = await parser.parseStringPromise(xmlContent) // Hash the password using PBKDF2 (MUST use salt "1.keyring.ets.knx.org") this.passwordHash = this.hashKeyringPassword(password) if (process.env.KNX_DEBUG === '1') console.log('Password hash:', this.passwordHash.toString('hex')) // Extract keyring data await this.parseKeyring(result) } /** * Explicit helper to load from string (raw XML or base64 .knxkeys). */ async loadFromString(content: string, password: string): Promise<void> { return this.load(content, password) } getCreatedBy(): string | undefined { return this.createdBy } getCreated(): string | undefined { return this.created } /** * Hash keyring password using PBKDF2 with the correct salt * This is CRITICAL - must use salt "1.keyring.ets.knx.org" */ private hashKeyringPassword(password: string): Buffer { return crypto.pbkdf2Sync( Buffer.from(password, 'utf-8'), Buffer.from('1.keyring.ets.knx.org', 'utf-8'), 65536, // iterations 16, // key length 'sha256', ) } /** * Decrypt data using AES-128-CBC */ private decryptAes128Cbc( encryptedData: Buffer, key: Buffer, iv: Buffer, ): Buffer { const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv) decipher.setAutoPadding(false) // Important: no auto padding return Buffer.concat([decipher.update(encryptedData), decipher.final()]) } /** * Extract password from decrypted data (match xknx) * Format: 8 bytes header + password + PKCS#7 padding */ private extractPassword(data: Buffer): string { if (!data || data.length === 0) return '' const pad = data[data.length - 1] const padLen = pad >= 1 && pad <= 16 ? pad : 0 const end = data.length - padLen if (end <= 8) return '' const payload = data.slice(8, end) return payload.toString('utf-8') } /** * Unzip the .knxkeys file to get the XML content */ private async unzipKnxKeys(zipContent: Buffer): Promise<string> { return new Promise((resolve, reject) => { zlib.unzip(zipContent.slice(30), (err, buffer) => { if (err) { // Try to find the XML content manually const xmlStart = zipContent.indexOf('<?xml') if (xmlStart !== -1) { const xmlEnd = zipContent.indexOf('</Keyring>') + 10 resolve( zipContent .slice(xmlStart, xmlEnd) .toString('utf-8'), ) } else { reject(err) } } else { resolve(buffer.toString('utf-8')) } }) }) } /** * Parse the keyring XML structure */ private async parseKeyring(data: any): Promise<void> { const keyring = data.Keyring if (!keyring) { throw new Error('Invalid keyring format') } // Store metadata this.createdBy = keyring.$?.CreatedBy this.created = keyring.$?.Created if (this.created) { const createdHash = crypto .createHash('sha256') .update(Buffer.from(this.created, 'utf-8')) .digest() this.iv = createdHash.slice(0, 16) } if (process.env.KNX_DEBUG === '1') console.log( `Keyring created by: ${this.createdBy} on ${this.created}`, ) // Parse interfaces if (keyring.Interface) { const interfaces = Array.isArray(keyring.Interface) ? keyring.Interface : [keyring.Interface] for (const iface of interfaces) { this.parseInterface(iface) } } // Parse backbone if (keyring.Backbone) { const backbones = Array.isArray(keyring.Backbone) ? keyring.Backbone : [keyring.Backbone] for (const backbone of backbones) { this.parseBackbone(backbone) } } // Parse group addresses if (keyring.GroupAddresses?.[0]?.Group) { const groups = Array.isArray(keyring.GroupAddresses[0].Group) ? keyring.GroupAddresses[0].Group : [keyring.GroupAddresses[0].Group] for (const group of groups) { this.parseGroupAddress(group) } } // Parse devices if (keyring.Devices?.[0]?.Device) { const devices = Array.isArray(keyring.Devices[0].Device) ? keyring.Devices[0].Device : [keyring.Devices[0].Device] for (const device of devices) { this.parseDevice(device) } } } /** * Parse and decrypt an interface */ private parseInterface(data: any): void { const attrs = data.$ if (!attrs) return const iface: Interface = { type: attrs.Type, individualAddress: new IndividualAddress(attrs.IndividualAddress), host: attrs.Host ? new IndividualAddress(attrs.Host) : undefined, userId: attrs.UserID ? parseInt(attrs.UserID) : undefined, password: attrs.Password, authentication: attrs.Authentication, groupAddresses: new Map(), } // Decrypt passwords if present if (iface.password && this.passwordHash) { const encrypted = Buffer.from(iface.password, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) const decrypted = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( `Interface ${iface.individualAddress} password raw:`, decrypted.toString('hex'), ) iface.decryptedPassword = this.extractPassword(decrypted) if (process.env.KNX_DEBUG === '1') console.log( `Interface ${iface.individualAddress} password:`, iface.decryptedPassword, ) } if (iface.authentication && this.passwordHash) { const encrypted = Buffer.from(iface.authentication, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) const decrypted = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( `Interface ${iface.individualAddress} auth raw:`, decrypted.toString('hex'), ) iface.decryptedAuthentication = this.extractPassword(decrypted) if (process.env.KNX_DEBUG === '1') console.log( `Interface ${iface.individualAddress} auth:`, iface.decryptedAuthentication, ) } // Parse assigned group addresses if (data.Group) { const groups = Array.isArray(data.Group) ? data.Group : [data.Group] for (const group of groups) { const groupAddr = new GroupAddress(group.$.Address) const senders = group.$.Senders ? group.$.Senders.split(' ').map( (s: string) => new IndividualAddress(s), ) : [] iface.groupAddresses.set(groupAddr.toString(), senders) } } this.interfaces.set(iface.individualAddress.toString(), iface) } /** * Parse and decrypt backbone */ private parseBackbone(data: any): void { const attrs = data.$ if (!attrs) return const backbone: Backbone = { key: attrs.Key, latency: attrs.Latency ? parseInt(attrs.Latency) : undefined, multicastAddress: attrs.MulticastAddress, } // Decrypt key if present if (backbone.key && this.passwordHash) { const encrypted = Buffer.from(backbone.key, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) backbone.decryptedKey = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( 'Backbone key:', backbone.decryptedKey?.toString('hex'), ) } this.backbones.push(backbone) } /** * Parse and decrypt group address */ private parseGroupAddress(data: any): void { const attrs = data.$ if (!attrs || !attrs.Address || !attrs.Key) return const group: GroupAddressKey = { address: new GroupAddress(attrs.Address), key: attrs.Key, } // Decrypt key if (this.passwordHash) { const encrypted = Buffer.from(group.key, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) group.decryptedKey = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( `Group ${group.address} key:`, group.decryptedKey?.toString('hex'), ) } this.groupAddresses.set(group.address.toString(), group) } /** * Parse and decrypt device */ private parseDevice(data: any): void { const attrs = data.$ if (!attrs) return const device: Device = { individualAddress: new IndividualAddress(attrs.IndividualAddress), toolKey: attrs.ToolKey, managementPassword: attrs.ManagementPassword, authentication: attrs.Authentication, sequenceNumber: attrs.SequenceNumber ? parseInt(attrs.SequenceNumber) : undefined, serialNumber: attrs.SerialNumber, } // Decrypt keys and passwords if (device.toolKey && this.passwordHash) { const encrypted = Buffer.from(device.toolKey, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) device.decryptedToolKey = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( `Device ${device.individualAddress} tool key:`, device.decryptedToolKey?.toString('hex'), ) } if (device.managementPassword && this.passwordHash) { const encrypted = Buffer.from(device.managementPassword, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) const decrypted = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( `Device ${device.individualAddress} mgmt raw:`, decrypted.toString('hex'), ) device.decryptedManagementPassword = this.extractPassword(decrypted) } if (device.authentication && this.passwordHash) { const encrypted = Buffer.from(device.authentication, 'base64') const iv = this.iv ?? Buffer.alloc(16, 0) const decrypted = this.decryptAes128Cbc( encrypted, this.passwordHash, iv, ) if (process.env.KNX_DEBUG === '1') console.log( `Device ${device.individualAddress} auth raw:`, decrypted.toString('hex'), ) device.decryptedAuthentication = this.extractPassword(decrypted) } this.devices.set(device.individualAddress.toString(), device) } // Getters for accessing keyring data getInterfaces(): Map<string, Interface> { return this.interfaces } getInterface(address: string): Interface | undefined { return this.interfaces.get(address) } getBackbones(): Backbone[] { return this.backbones } getGroupAddresses(): Map<string, GroupAddressKey> { return this.groupAddresses } getGroupAddress(address: string): GroupAddressKey | undefined { return this.groupAddresses.get(address) } getDevices(): Map<string, Device> { return this.devices } getDevice(address: string): Device | undefined { return this.devices.get(address) } }