knxultimate
Version:
KNX IP protocol implementation for Node. This is the ENGINE of Node-Red KNX-Ultimate node.
680 lines (641 loc) • 19.6 kB
text/typescript
/**
* KNX Secure protocol stack in pure Javascript (C) 2021 Supergiovane
*/
//
// The function returns this JSON (with ETS Keyring password "banana"):
//
// retJson {
// ETSProjectName: 'KNX Secure',
// ETSCreated: '2021-11-17T07:43:08',
// ETSCreatedBy: 'ETS 5.7.6 (Build 1398)',
// HASHkeyringPasswordBase64: '08qj3lhCDI1zINbqanGlaQ==',
// HASHCreatedBase64: 'bX2hbMK6AR9l/U9ATjbwlA==',
// backbone: {
// multicastAddress: '224.0.23.12',
// latency: '2000',
// key: '28bd8f6fb56881eb8b4b3e3aec960f13'
// },
// interfaces: [
// {
// individualAddress: '3.1.2',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '2',
// managementPassword: '.!Pea332',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.3',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '3',
// managementPassword: '6Y*xu2QN',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.4',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '4',
// managementPassword: '7e#qfoGG',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.5',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '5',
// managementPassword: 'WC@rJrl*',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.6',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '6',
// managementPassword: '."M1Cmjr',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.7',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '7',
// managementPassword: '+-Ikuj y',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.8',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '8',
// managementPassword: '4NV@Xp=(',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.9',
// type: 'Tunneling',
// host: '3.1.1',
// userID: '9',
// managementPassword: '"3CDfNH3',
// authenticationPassword: 'autenticazione'
// }
// ],
// groupAddresses: [
// { address: '8/0/0', key: '313681939762ff36fdcd774efec56d1b' },
// { address: '8/0/1', key: 'e990d57b3630bc4940a40d0c7caa3698' },
// { address: '8/0/2', key: 'cc0e1a6204d5c4623626b1ccc1069b63' }
// ],
// Devices: [
// {
// individualAddress: '3.1.1',
// sequenceNumber: '121960556295',
// toolKey: '51b52bef0f8d5b83f7975fb3c1d67f96',
// managementPassword: 'commissione',
// authenticationPassword: 'autenticazione'
// },
// {
// individualAddress: '3.1.10',
// sequenceNumber: '121960675276',
// toolKey: 'db580560e32dc040d062e4f91bbf1182',
// managementPassword: null,
// authenticationPassword: null
// },
// {
// individualAddress: '3.1.11',
// sequenceNumber: '121960725775',
// toolKey: 'f03ca86237705ed9d7014627fb16f88a',
// managementPassword: null,
// authenticationPassword: null
// }
// ]
// }
// https://support.knx.org/hc/it/articles/360001582259-Usare-keyring-al-di-fuori-di-ETS-Falcon-SDK
import { module } from './KnxLog'
import xml2js from 'xml2js'
import CryptoJS from 'crypto-js'
import { hasProp } from './utils'
const keyringSalt = '1.keyring.ets.knx.org'
// Class returned by the keyring function after the load
const _retJson: any = {}
const logger = module('keyring')
let signature: string = ''
let createdHash: string = ''
let passwordHash: string = ''
let jSonXMLKeyringFile: any = {} // Holds the Keyring XML in JSON Format
let XMLKeyringFileString: string = '' // Holds the Keyring XML in string Format
async function xml2json(_sXML: string): Promise<any> {
return new Promise((resolve, reject) => {
try {
const parser = new xml2js.Parser({ explicitArray: false })
parser.parseString(_sXML, function (err: any, json: any) {
if (err) {
reject(err)
} else {
resolve(json)
}
})
} catch (error) {
reject(error)
}
})
}
const keyring = (function () {
// Return byte, input char[] password, byte[] salt
async function pbkdf2WithHmacSha256(
password: string,
salt: string,
): Promise<string> {
const iterations: number = 65536
const keyLength: number = 4
if (password == null || password.length === 0) {
password = '\0'
}
try {
const secretKey = CryptoJS.PBKDF2(password, salt, {
keySize: keyLength,
iterations,
hasher: CryptoJS.algo.SHA256,
// padding: CryptoJS.pad.NoPadding // FIXME: this options doesn't exists
})
return secretKey.toString(CryptoJS.enc.Base64)
} catch (error) {
logger.error(`pbkdf2WithHmacSha256 ${error.message}`)
throw error
}
}
// Return bytes
async function sha256(_input: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
const hash = CryptoJS.SHA256(_input)
let buffer = Buffer.from(hash.toString(CryptoJS.enc.Hex), 'hex')
buffer = buffer.subarray(0, 16)
resolve(buffer.toString('base64'))
} catch (error) {
reject(error)
}
})
}
// Return byte, input char[]
async function hashKeyringPwd(keyringPwd: string): Promise<string> {
try {
return await pbkdf2WithHmacSha256(keyringPwd, keyringSalt)
} catch (error) {
logger.error(`hashKeyringPwd ${error.message}`)
throw error
}
}
// private static byte[] aes128Cbc(final byte[] input, final byte[] key, final byte[] iv)
// throws GeneralSecurityException {
// final var cipher = Cipher.getInstance("AES/CBC/NoPadding");
// final var keySpec = new SecretKeySpec(key, "AES");
// final var params = new IvParameterSpec(iv);
// cipher.init(Cipher.DECRYPT_MODE, keySpec, params);
// return cipher.doFinal(input);
// }
async function aes128Cbc(
_inputBase64: string,
_pwdKeyringHashBase64: string,
_createdHashBase64: string,
): Promise<string> {
return new Promise((resolve, reject) => {
try {
const decrypted = CryptoJS.AES.decrypt(
_inputBase64,
CryptoJS.enc.Base64.parse(_pwdKeyringHashBase64),
{
iv: CryptoJS.enc.Base64.parse(_createdHashBase64),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.ZeroPadding,
},
)
resolve(CryptoJS.enc.Hex.stringify(decrypted))
} catch (error) {
logger.error(`aes128Cbc ${error}`)
reject(error)
}
})
}
async function verifySignature(_passwordHash: string): Promise<boolean> {
const sRows = XMLKeyringFileString.split('>')
const aFiltered: any[] = []
try {
for (let index = 0; index < sRows.length; index++) {
let sRow = `${sRows[index]}>`
let bEndElement = false
sRow = sRow.trim()
// Clean close tags
if (sRow !== '' && !sRow.startsWith('<?xml')) {
if (sRow.startsWith('</') || sRow.endsWith('/>')) {
bEndElement = true
}
if (sRow.startsWith('<') && !sRow.startsWith('</')) {
// START_ELEMENT
aFiltered.push(Uint8Array.from([1]))
}
if (!sRow.startsWith('</')) {
sRow = sRow.replace(/</g, '')
sRow = sRow.replace(/\/>/g, '')
sRow = sRow.replace(/>/g, '')
sRow = sRow.trim()
// sRow is something like this: Backbone MulticastAddress="224.0.23.12" Latency="1000" Key="VGnz2DbdiMqN5EE4I7tqLw=="
// Get the TAG
let sTag = ''
let sTemp = ''
let sAttribute = ''
let sValue = ''
// Find the TAG
for (let i = 0; i < sRow.length; i++) {
const _char = sRow[i]
if (_char !== ' ') {
sTemp += _char
} else {
// Got the tag
sTag = sTemp
break
}
}
if (sTag === '') sTag = sTemp // In case of TAG without attributes
sTemp = ''
sRow = sRow.substring(sTag.length + 1) // Purge TAG
// Attributes and values MulticastAddress="224.0.23.12" Banana="22sdsf02312=="
const aAttribs: any[] = []
do {
sAttribute = sRow.substring(0, sRow.indexOf('=')) // Get attribute
sRow = sRow.substring(sRow.indexOf('=') + 1).trim() // Purge attribute
sValue = sRow.substring(0, sRow.indexOf('"', 1) + 1) // Get value
sRow = sRow.substring(sValue.length + 1).trim() // Purge value
sValue = sValue.replace(/"/g, '')
if (
sAttribute !== 'xmlns' &&
sAttribute !== 'Signature'
) {
aAttribs.push({
attlen: sAttribute.length,
att: sAttribute,
vallen: sValue.length,
val: sValue,
})
}
sAttribute = ''
sValue = ''
} while (sRow.length > 0)
if (sTag.length > 0) {
aFiltered.push(Uint8Array.from([sTag.length]))
aFiltered.push(new TextEncoder().encode(sTag))
}
// Order the attribute array
const aAttribsSorted = aAttribs.sort((a, b) =>
// eslint-disable-next-line no-nested-ternary
a.att > b.att ? 1 : b.att > a.att ? -1 : 0,
)
// Put all togheddder
for (let i = 0; i < aAttribsSorted.length; i++) {
const element = aAttribsSorted[i]
if (element.attlen > 0) {
aFiltered.push(
Uint8Array.from([element.attlen]),
)
aFiltered.push(
new TextEncoder().encode(element.att),
)
aFiltered.push(
Uint8Array.from([element.vallen]),
)
aFiltered.push(
new TextEncoder().encode(element.val),
)
}
}
// Add the end element tag.
if (bEndElement) {
// END_ELEMENT
aFiltered.push(Uint8Array.from([2]))
bEndElement = false
}
} else {
// END_ELEMENT
aFiltered.push(Uint8Array.from([2]))
}
}
}
// Add the has password
aFiltered.push(Uint8Array.from([_passwordHash.length]))
aFiltered.push(new TextEncoder().encode(_passwordHash))
const buffKeyringFileForHashing = Buffer.concat(aFiltered)
const keyringFileForHashing = Buffer.from(
buffKeyringFileForHashing,
).toString()
const outputHash = await sha256(keyringFileForHashing)
if (outputHash === signature) {
return true
}
throw new Error('verifySignature failed')
} catch (error) {
throw new Error('verifySignature ') + error.message
}
}
/**
* Decrypts a backbone key, tool key, or group address key using the keyring
* password.
*
* @param _inputBase64 encrypted key
* @param _pwdKeyringHashBase64 the password hash of this keyring
* @param _createdHashBase64 the keyring created datetime hash
* @return decrypted key base64
*/
async function decryptKey(
_inputBase64: string,
_pwdKeyringHashBase64: string,
_createdHashBase64: string,
): Promise<string> {
try {
return await aes128Cbc(
_inputBase64,
_pwdKeyringHashBase64,
_createdHashBase64,
)
} catch (error) {
throw new Error(`decryptKey ${error.message}`)
} finally {
// Arrays.fill(_pwdKeyringHash, (byte) 0);
}
}
/**
* Decrypts a user password, device authentication code, or commissioning password using the keyring password.
*
* @param _inputBase64 encrypted password
* @param _pwdKeyringHashBase64 the password of this keyring
* @param _createdHashBase64 the created hash
* @return decrypted password in plain text
*/
async function decryptPassword(
_inputBase64: string,
_pwdKeyringHashBase64: string,
_createdHashBase64: string,
): Promise<string> {
try {
const pwdData = await extractPassword(
await decryptKey(
_inputBase64,
_pwdKeyringHashBase64,
_createdHashBase64,
),
)
let ret = ''
for (let index = 0; index < pwdData.length; index++) {
const element = pwdData[index]
ret += String.fromCharCode(pwdData[index] & 0xff)
}
return ret
} catch (error) {
throw new Error(
`Error while decrypting password data: ${error.message}`,
)
}
}
// 18/11/2021 Estraggo la password smanettando sul range.
async function extractPassword(data: any): Promise<any> {
data = Buffer.from(data.toString('hex'), 'hex')
const b = data[data.length - 1] & 0xff
const range = await copyOfRange(data, 8, data.length - b)
return range
}
// 18/11/2021 Copy of array range. If _to is > _arr.length, append 0
async function copyOfRange(
_arr: any[],
_start: number,
_to: number,
): Promise<any[]> {
const ret: any[] = []
for (let index = _start; index < _to; index++) {
try {
ret.push(_arr[index])
} catch (error) {
ret.push(0)
}
}
return ret
}
// Read the XML text
// Returns an object with all necessary info, or error if the keyring password is wrong or something is going bad
async function load(
_sXML: string,
_keyringPassword?: string,
): Promise<any> {
if (_keyringPassword === undefined) _keyringPassword = ''
if (_sXML === undefined) _sXML = ''
let created: any
// All returned key are in base64 per comodità .
try {
jSonXMLKeyringFile = await xml2json(_sXML)
XMLKeyringFileString = _sXML
_retJson.ETSProjectName = jSonXMLKeyringFile.Keyring.$.Project
const createdBy = jSonXMLKeyringFile.Keyring.$.CreatedBy
_retJson.ETSCreatedBy = createdBy
created = jSonXMLKeyringFile.Keyring.$.Created
_retJson.ETSCreated = created
} catch (error) {
logger.error(`load ${error.message}`)
throw error
}
try {
// Get the hash from the keyring password
passwordHash = await hashKeyringPwd(_keyringPassword)
_retJson.HASHkeyringPasswordBase64 = passwordHash
} catch (error) {
logger.error(`passwordHash ${error.message}`)
throw new Error(`passwordHash ${error.message}`)
}
// Get the hash from the created tac
createdHash = await sha256(created)
_retJson.HASHCreatedBase64 = createdHash
logger.debug(`createdHash ${createdHash}`) // OK !!!
// Get the signature from the KEYRING attribute
signature = jSonXMLKeyringFile.Keyring.$.Signature.toString('base64')
logger.debug(`signature ${signature}`) // OK !!!
if (_keyringPassword.length > 0) {
try {
await verifySignature(passwordHash)
logger.debug('verifySignature OK')
} catch (error) {
logger.error(
`signature verification failed for keyring ${_keyringPassword}`,
)
throw new Error('The password is wrong')
}
}
// Get the BACKBONE details OK !!!!!
try {
_retJson.backbone = {
multicastAddress:
jSonXMLKeyringFile.Keyring.Backbone.$.MulticastAddress,
latency: jSonXMLKeyringFile.Keyring.Backbone.$.Latency,
key: await decryptKey(
jSonXMLKeyringFile.Keyring.Backbone.$.Key,
passwordHash,
createdHash,
),
}
} catch (error) {
logger.error(`KNX-Secure: Backbone details ${error.message}`)
throw new Error(`KNX-Secure: Backbone details ${error.message}`)
}
// Get the INTERFACES details
// <Interface IndividualAddress="3.1.2" Type="Tunneling" Host="3.1.1" UserID="2" Password="gF8N8lKGU9cD3TNMLEvu50SbI48qI5EeC8WeciL53Zg=" Authentication="jHW6k+R/b+GOfdaNzXXildWI4BrqHkAoa6lUtWCGGDI=" />
// this.interfaces = [{
// individualAddress: "",
// type: "",
// host: "",
// userID: "",
// managementPassword: "",
// authenticationPassword: ""
// }];
try {
_retJson.interfaces = []
if (hasProp(jSonXMLKeyringFile.Keyring, 'Interface')) {
for (
let index = 0;
index < jSonXMLKeyringFile.Keyring.Interface.length;
index++
) {
const element = jSonXMLKeyringFile.Keyring.Interface[index]
_retJson.interfaces.push({
individualAddress: element.$.IndividualAddress,
type: element.$.Type,
host: element.$.Host,
userID: element.$.UserID,
managementPassword: hasProp(element.$, 'Password')
? await decryptPassword(
element.$.Password,
passwordHash,
createdHash,
)
: null,
authenticationPassword: hasProp(
element.$,
'Authentication',
)
? await decryptPassword(
element.$.Authentication,
passwordHash,
createdHash,
)
: null,
})
}
}
} catch (error) {
logger.error(`KNX-Secure: Interfaces details ${error.message}`)
throw new Error(`KNX-Secure: Interfaces details ${error.message}`)
}
// Get the GROUP ADDRESSES details
// <Group Address="16384" Key="CreHKeXp+5U2qMLVU0XWxw==" />
// this.groupAddresses = [{
// address: "",
// key: ""
// }];
try {
_retJson.groupAddresses = []
if (hasProp(jSonXMLKeyringFile.Keyring, 'GroupAddresses')) {
for (
let index = 0;
index <
jSonXMLKeyringFile.Keyring.GroupAddresses.Group.length;
index++
) {
const element =
jSonXMLKeyringFile.Keyring.GroupAddresses.Group[index]
_retJson.groupAddresses.push({
address: await getKNXAddressfromXML(element.$.Address),
key: hasProp(element.$, 'Key')
? await decryptKey(
element.$.Key,
passwordHash,
createdHash,
)
: null,
})
}
}
} catch (error) {
logger.error(`KNX-Secure: GroupAddres details ${error.message}`)
throw new Error(`KNX-Secure: GroupAddres details ${error.message}`)
}
// 18/11/2021 Recupero il gruppo dall'XML
async function getKNXAddressfromXML(
_rawAddress: number,
): Promise<string> {
const digits: number[] = []
if (_rawAddress > 0x7ff) {
digits.push((_rawAddress >> 11) & 0x1f)
}
digits.push((_rawAddress >> 8) & 0x07)
digits.push(_rawAddress & 0xff)
return digits.join('/')
}
// Get the DEVICES details
// <Device IndividualAddress="3.1.1" ToolKey="T770+Sebf2zpx3X3A0S64A==" ManagementPassword="6LPLJeu+XxuGpn6tOqt9fw4NuSa/jIQCYXzFVDwPUiU=" Authentication="rywptqDB0/UNF/5VmlTs5YnrIqO9FJ3YGGEIm08Z1UQ=" SequenceNumber="121960556295" />
// Devices:
// this.devices = [{
// individualAddress: "",
// sequenceNumber: "",
// toolKey: "",
// managementPassword: "",
// authenticationPassword: ""
// }];
try {
_retJson.Devices = []
if (hasProp(jSonXMLKeyringFile.Keyring, 'Devices')) {
for (
let index = 0;
index < jSonXMLKeyringFile.Keyring.Devices.Device.length;
index++
) {
const element =
jSonXMLKeyringFile.Keyring.Devices.Device[index]
_retJson.Devices.push({
individualAddress: element.$.IndividualAddress,
sequenceNumber: element.$.SequenceNumber,
toolKey: hasProp(element.$, 'ToolKey')
? await decryptKey(
element.$.ToolKey,
passwordHash,
createdHash,
)
: null,
managementPassword: hasProp(
element.$,
'ManagementPassword',
)
? await decryptPassword(
element.$.ManagementPassword,
passwordHash,
createdHash,
)
: null,
authenticationPassword: hasProp(
element.$,
'Authentication',
)
? await decryptPassword(
element.$.Authentication,
passwordHash,
createdHash,
)
: null,
})
}
}
} catch (error) {
logger.error(`KNX-Secure: Devices details ${error.message}`)
throw new Error(`KNX-Secure: Devices details ${error.message}`)
}
return _retJson
}
return {
load,
}
})()
export default keyring