knxultimate
Version:
KNX IP protocol implementation for Node. This is the ENGINE of Node-Red KNX-Ultimate node.
644 lines (597 loc) • 17.8 kB
text/typescript
/**
* Mocks a KNX Secure gateway for tests.
*
* 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 { createServer, Server, Socket, AddressInfo } from 'net'
import * as crypto from 'crypto'
import { TypedEventEmitter } from '../../src/TypedEmitter'
import {
KNXIP,
SECURE_WRAPPER_OVERHEAD,
KNXIP_HDR_SECURE_WRAPPER,
SECURE_WRAPPER_TAG,
SECURE_WRAPPER_CTR_SUFFIX,
SECURE_WRAPPER_MAC_SUFFIX,
SECURE_SEQ_LEN,
SERIAL_LEN,
PUBLIC_KEY_LEN,
MAC_LEN_FULL,
SCF_ENCRYPTION_S_A_DATA,
APCI_SEC,
TPCI_DATA,
DATA_SECURE_CTR_SUFFIX,
CEMI as SEC_CEMI,
} from '../../src/secure/secure_knx_constants'
import {
calculateMessageAuthenticationCodeCBC,
encryptDataCtr,
decryptCtr,
} from '../../src/secure/security_primitives'
import KNXProtocol from '../../src/protocol/KNXProtocol'
import KNXTunnelingRequest from '../../src/protocol/KNXTunnelingRequest'
import KNXTunnelingAck from '../../src/protocol/KNXTunnelingAck'
import KNXConnectResponse from '../../src/protocol/KNXConnectResponse'
import KNXConnectionStateResponse from '../../src/protocol/KNXConnectionStateResponse'
import KNXDisconnectResponse from '../../src/protocol/KNXDisconnectResponse'
import HPAI, { KnxProtocol as HPAIProtocol } from '../../src/protocol/HPAI'
import CRD, { ConnectionType } from '../../src/protocol/CRD'
import KNXAddress, { KNXAddressType } from '../../src/protocol/KNXAddress'
import ControlField from '../../src/protocol/cEMI/ControlField'
import NPDU from '../../src/protocol/cEMI/NPDU'
import KNXDataBuffer from '../../src/protocol/KNXDataBuffer'
import LDataReq from '../../src/protocol/cEMI/LDataReq'
import LDataInd from '../../src/protocol/cEMI/LDataInd'
import CEMIConstants from '../../src/protocol/cEMI/CEMIConstants'
import { GroupAddress, IndividualAddress } from '../../src/secure/keyring'
type GatewayEvents = {
error: (error: Error) => void
connected: () => void
groupWrite: (payload: {
groupAddress: string
sourceAddress: string
value: boolean
plainApdu: Buffer
}) => void
}
type SecureGatewayOptions = {
host?: string
port?: number
sessionId?: number
channelId?: number
serial?: Buffer
interfaceIndividualAddress?: string
tunnelAssignedIndividualAddress?: string
groupKeys?: Record<string, Buffer>
initialSendSeq48?: bigint
}
const X25519_SPKI_PREFIX_DER = Buffer.from('302a300506032b656e032100', 'hex')
export class MockSecureGateway extends TypedEventEmitter<GatewayEvents> {
private server: Server | null = null
private socket: Socket | null = null
private rxBuffer: Buffer = Buffer.alloc(0)
private wrapperSeq = 0
private tunnelSeq = 0
private sessionKey: Buffer | null = null
private readonly sessionId: number
private readonly channelId: number
private readonly serial: Buffer
private readonly interfaceIa: number
private readonly assignedIa: number
private readonly groupKeys: Map<number, Buffer>
private readonly sendSeq48Start: bigint
private sendSeq48: bigint
private host: string
private port: number
private handshakeState: 'idle' | 'session' | 'auth' | 'connected' = 'idle'
private clientPublicKey: Buffer | null = null
private readonly serverPrivateKey: crypto.KeyObject
private readonly serverPublicKeyRaw: Buffer
constructor(options: SecureGatewayOptions = {}) {
super()
this.host = options.host ?? '127.0.0.1'
this.port = options.port ?? 0
this.sessionId = options.sessionId ?? 0x5100
this.channelId = options.channelId ?? 0x51
this.serial =
options.serial && options.serial.length === SERIAL_LEN
? Buffer.from(options.serial)
: Buffer.from('a1b2c3d4e5f6', 'hex')
const interfaceIaStr = options.interfaceIndividualAddress ?? '1.1.1'
const tunnelIaStr =
options.tunnelAssignedIndividualAddress ?? '10.15.251'
this.interfaceIa = new IndividualAddress(interfaceIaStr).raw
this.assignedIa = new IndividualAddress(tunnelIaStr).raw
this.groupKeys = new Map()
if (options.groupKeys) {
for (const [ga, key] of Object.entries(options.groupKeys)) {
const addr = new GroupAddress(ga).raw
this.groupKeys.set(addr, Buffer.from(key))
}
}
this.sendSeq48Start = options.initialSendSeq48 ?? BigInt(Date.now())
this.sendSeq48 = this.sendSeq48Start
const keyPair = crypto.generateKeyPairSync('x25519')
this.serverPrivateKey = keyPair.privateKey
const exported = keyPair.publicKey.export({
type: 'spki',
format: 'der',
}) as Buffer
this.serverPublicKeyRaw = exported.subarray(
exported.length - PUBLIC_KEY_LEN,
)
}
async start(): Promise<void> {
if (this.server) return
await new Promise<void>((resolve, reject) => {
this.server = createServer((socket) =>
this.handleConnection(socket),
)
this.server.once('error', reject)
this.server.listen(this.port, this.host, () => {
const addr = this.server?.address()
if (addr && typeof addr === 'object') {
this.host = addr.address || this.host
this.port = addr.port
}
this.server?.off('error', reject)
resolve()
})
})
}
async stop(): Promise<void> {
if (this.socket) {
this.socket.removeAllListeners()
this.socket.destroy()
this.socket = null
}
if (!this.server) return
await new Promise<void>((resolve) => {
this.server?.close(() => resolve())
})
this.server = null
}
get address(): AddressInfo | null {
const addr = this.server?.address()
return typeof addr === 'object' ? (addr as AddressInfo) : null
}
async sendGroupValueWriteSecure(
groupAddress: string,
value: boolean,
): Promise<void> {
if (
!this.socket ||
!this.sessionKey ||
this.handshakeState !== 'connected'
) {
throw new Error('Secure session not established')
}
const dst = new GroupAddress(groupAddress).raw
const key = this.groupKeys.get(dst)
if (!key) {
throw new Error(`No Data Secure key for ${groupAddress}`)
}
const control = new ControlField()
control.addressType = KNXAddressType.TYPE_GROUP
control.ack = 0
control.repeat = 1
const srcAddress = new KNXAddress(
this.interfaceIa,
KNXAddressType.TYPE_INDIVIDUAL,
)
const dstAddress = new KNXAddress(dst, KNXAddressType.TYPE_GROUP)
const apci = 0x80 | (value ? 0x01 : 0x00)
const npdu = new NPDU(0x00, apci, null)
this.applyDataSecure(npdu, control, this.interfaceIa, dst, key)
const cemi = new LDataInd(null, control, srcAddress, dstAddress, npdu)
const request = KNXProtocol.newKNXTunnelingRequest(
this.channelId,
this.nextTunnelSeq(),
cemi,
)
this.socket.write(this.wrapFrame(request.toBuffer()))
}
private handleConnection(socket: Socket) {
if (this.socket) {
socket.destroy()
return
}
this.socket = socket
this.handshakeState = 'session'
this.rxBuffer = Buffer.alloc(0)
this.wrapperSeq = 0
this.tunnelSeq = 0
this.sessionKey = null
this.clientPublicKey = null
this.sendSeq48 = this.sendSeq48Start
socket.on('data', (data) => this.onData(data))
socket.on('error', (err) => this.emit('error', err))
socket.on('close', () => {
this.socket = null
this.handshakeState = 'idle'
})
}
private onData(data: Buffer) {
this.rxBuffer = Buffer.concat([this.rxBuffer, data])
while (this.rxBuffer.length >= 6) {
const length = this.rxBuffer.readUInt16BE(4)
if (this.rxBuffer.length < length) break
const frame = this.rxBuffer.subarray(0, length)
this.rxBuffer = this.rxBuffer.subarray(length)
this.handleFrame(frame)
}
}
private handleFrame(frame: Buffer) {
const service = frame.readUInt16BE(2)
if (service === KNXIP.SECURE_SESSION_REQUEST) {
this.handleSessionRequest(frame)
return
}
if (service === KNXIP.SECURE_WRAPPER) {
this.handleSecureWrapper(frame)
}
// ignore unexpected frames
}
private handleSessionRequest(frame: Buffer) {
if (this.handshakeState !== 'session') return
if (frame.length < 6 + 8 + PUBLIC_KEY_LEN) {
this.emit('error', new Error('SESSION_REQUEST too short'))
return
}
const hpaiLen = frame.readUInt8(6) || 0
const keyOffset = 6 + hpaiLen
const clientKey = frame.subarray(keyOffset, keyOffset + PUBLIC_KEY_LEN)
if (clientKey.length !== PUBLIC_KEY_LEN) {
this.emit(
'error',
new Error(
`Invalid client public key length ${clientKey.length}`,
),
)
return
}
this.clientPublicKey = Buffer.from(clientKey)
const clientPublicKeyObj = crypto.createPublicKey({
key: Buffer.concat([X25519_SPKI_PREFIX_DER, clientKey]),
format: 'der',
type: 'spki',
})
const secret = crypto.diffieHellman({
privateKey: this.serverPrivateKey,
publicKey: clientPublicKeyObj,
})
const sessionHash = crypto.createHash('sha256').update(secret).digest()
this.sessionKey = sessionHash.subarray(0, 16)
const responseLen = KNXIP_HEADER_AND_BODY_LEN(2 + PUBLIC_KEY_LEN)
const response = Buffer.concat([
Buffer.from('06100952', 'hex'),
Buffer.from([responseLen >> 8, responseLen & 0xff]),
Buffer.from([this.sessionId >> 8, this.sessionId & 0xff]),
this.serverPublicKeyRaw,
])
this.socket?.write(response)
}
private handleSecureWrapper(frame: Buffer) {
if (!this.sessionKey) return
const inner = this.unwrapFrame(frame)
const type = inner.readUInt16BE(2)
if (
type === KNXIP.SECURE_SESSION_AUTHENTICATE &&
this.handshakeState === 'session'
) {
this.handshakeState = 'auth'
const status = Buffer.concat([
Buffer.from('06100954', 'hex'),
Buffer.from([0x00, 0x07]),
Buffer.from([0x00]),
])
this.socket?.write(this.wrapFrame(status))
return
}
if (
type === KNXIP.TUNNELING_CONNECT_REQUEST &&
this.handshakeState === 'auth'
) {
this.handshakeState = 'connected'
const connect = this.buildConnectResponse()
this.socket?.write(this.wrapFrame(connect))
this.emit('connected')
return
}
if (
type === KNXIP.TUNNELING_REQUEST &&
this.handshakeState === 'connected'
) {
this.handleTunnelingRequest(inner)
return
}
if (
type === KNXIP.TUNNELING_ACK &&
this.handshakeState === 'connected'
) {
return
}
if (
type === KNXIP.CONNECTIONSTATE_REQUEST &&
this.handshakeState === 'connected'
) {
const stateResponse = this.buildConnectionStateResponse(inner)
this.socket?.write(this.wrapFrame(stateResponse))
return
}
if (type === KNXIP.DISCONNECT_REQUEST) {
const disconnect = this.buildDisconnectResponse(inner)
this.socket?.write(this.wrapFrame(disconnect))
}
}
private handleTunnelingRequest(inner: Buffer) {
const { knxMessage } = KNXProtocol.parseMessage(inner)
const request = knxMessage as KNXTunnelingRequest
const ack = KNXProtocol.newKNXTunnelingACK(
this.channelId,
request.seqCounter,
0,
)
this.socket?.write(this.wrapFrame(ack.toBuffer()))
const cemi = request.cEMIMessage
if (cemi.msgCode !== CEMIConstants.L_DATA_REQ) {
return
}
const npdu = cemi.npdu
const isSecure =
npdu.tpci === APCI_SEC.HIGH && npdu.apci === APCI_SEC.LOW
if (!isSecure) return
const result = this.decryptDataSecure(cemi)
if (!result) return
this.emit('groupWrite', {
groupAddress: result.groupAddress,
sourceAddress: result.sourceAddress,
value: result.value,
plainApdu: result.plainApdu,
})
}
private decryptDataSecure(cemi: LDataReq | LDataInd) {
const npdu = cemi.npdu
const dataBuf = npdu.dataBuffer?.value ?? Buffer.alloc(0)
if (dataBuf.length < 1 + SECURE_SEQ_LEN + 4) {
return null
}
const dst = cemi.dstAddress.get()
const key = this.groupKeys.get(dst)
if (!key) {
return null
}
const src = cemi.srcAddress.get()
const ctrlBuf = cemi.control.toBuffer()
const flags2 = ctrlBuf[1] & SEC_CEMI.CTRL2_RELEVANT_MASK
const scf = dataBuf[0]
const seq = dataBuf.subarray(1, 1 + SECURE_SEQ_LEN)
const encrypted = dataBuf.subarray(1 + SECURE_SEQ_LEN)
const encMac = encrypted.subarray(encrypted.length - 4)
const encPayload = encrypted.subarray(0, encrypted.length - 4)
const addrFields = Buffer.from([
(src >> 8) & 0xff,
src & 0xff,
(dst >> 8) & 0xff,
dst & 0xff,
])
const counter0 = Buffer.concat([
seq,
addrFields,
DATA_SECURE_CTR_SUFFIX,
])
const [plainPayload, macTr] = decryptCtr(
key,
counter0,
encMac,
encPayload,
)
const block0 = Buffer.concat([
seq,
addrFields,
Buffer.from([
0x00,
flags2,
(TPCI_DATA << 2) + APCI_SEC.HIGH,
APCI_SEC.LOW,
0x00,
plainPayload.length,
]),
])
const macCbc = calculateMessageAuthenticationCodeCBC(
key,
Buffer.from([scf]),
plainPayload,
block0,
).subarray(0, 4)
if (!macCbc.equals(macTr)) {
return null
}
if (plainPayload.length < 2) {
return null
}
const tpci = plainPayload[0]
const apci = plainPayload[1]
const action = ((tpci & 0x03) << 2) | ((apci & 0xc0) >> 6)
const valueBit = apci & 0x3f
const groupAddress = cemi.dstAddress.toString()
const sourceAddress = cemi.srcAddress.toString()
return {
groupAddress,
sourceAddress,
value:
action === NPDU.GROUP_WRITE ? (valueBit & 0x01) === 1 : false,
plainApdu: plainPayload,
}
}
private applyDataSecure(
npdu: NPDU,
control: ControlField,
srcIa: number,
dstGa: number,
key: Buffer,
) {
const ctrlBuf = control.toBuffer()
const flags16 = (ctrlBuf[0] << 8) | ctrlBuf[1]
const plainApdu = Buffer.concat([
Buffer.from([npdu.tpci & 0xff, npdu.apci & 0xff]),
npdu.dataBuffer?.value ?? Buffer.alloc(0),
])
const seqBuf = Buffer.alloc(SECURE_SEQ_LEN)
seqBuf.writeUIntBE(
Number(this.sendSeq48 & 0xffffffffffffn),
0,
SECURE_SEQ_LEN,
)
this.sendSeq48 = (this.sendSeq48 + 1n) & 0xffffffffffffn
const addrFields = Buffer.from([
(srcIa >> 8) & 0xff,
srcIa & 0xff,
(dstGa >> 8) & 0xff,
dstGa & 0xff,
])
const block0 = Buffer.concat([
seqBuf,
addrFields,
Buffer.from([
0x00,
flags16 & 0xff,
(TPCI_DATA << 2) + APCI_SEC.HIGH,
APCI_SEC.LOW,
0x00,
plainApdu.length,
]),
])
const macFull = calculateMessageAuthenticationCodeCBC(
key,
Buffer.from([SCF_ENCRYPTION_S_A_DATA]),
plainApdu,
block0,
)
const macShort = macFull.subarray(0, 4)
const ctr0 = Buffer.concat([seqBuf, addrFields, DATA_SECURE_CTR_SUFFIX])
const [encPayload, encMac] = encryptDataCtr(
key,
ctr0,
macShort,
plainApdu,
)
const secureApdu = Buffer.concat([
APCI_SEC.HEADER,
Buffer.from([SCF_ENCRYPTION_S_A_DATA]),
seqBuf,
encPayload,
encMac,
])
npdu.tpci = APCI_SEC.HIGH
npdu.apci = APCI_SEC.LOW
npdu.data = new KNXDataBuffer(secureApdu.subarray(2))
}
private buildConnectResponse(): Buffer {
const hpai = new HPAI(this.host, this.port, HPAIProtocol.IPV4_TCP)
const crd = new CRD(
ConnectionType.TUNNEL_CONNECTION,
new KNXAddress(this.assignedIa, KNXAddressType.TYPE_INDIVIDUAL),
)
const response = new KNXConnectResponse(this.channelId, 0, hpai, crd)
return Buffer.concat([response.header.toBuffer(), response.toBuffer()])
}
private buildConnectionStateResponse(request: Buffer): Buffer {
const channel = request.readUInt8(6)
const response = new KNXConnectionStateResponse(channel, 0)
return response.toBuffer()
}
private buildDisconnectResponse(request: Buffer): Buffer {
const channel = request.readUInt8(6)
const response = new KNXDisconnectResponse(channel, 0)
return response.toBuffer()
}
private wrapFrame(inner: Buffer): Buffer {
if (!this.sessionKey) {
throw new Error('Session key missing')
}
const seq = Buffer.alloc(SECURE_SEQ_LEN)
seq.writeUIntBE(this.wrapperSeq++, 0, SECURE_SEQ_LEN)
const len = SECURE_WRAPPER_OVERHEAD + inner.length
const hdr = Buffer.concat([
KNXIP_HDR_SECURE_WRAPPER,
Buffer.from([len >> 8, len & 0xff]),
])
const additional = Buffer.concat([
hdr,
Buffer.from([this.sessionId >> 8, this.sessionId & 0xff]),
])
const block0 = Buffer.concat([
seq,
this.serial,
SECURE_WRAPPER_TAG,
Buffer.from([inner.length >> 8, inner.length & 0xff]),
])
const blocks = Buffer.concat([
block0,
Buffer.from([0x00, additional.length]),
additional,
inner,
])
const padded = pad16(blocks)
const cipher = crypto.createCipheriv(
'aes-128-cbc',
this.sessionKey,
Buffer.alloc(16, 0),
)
cipher.setAutoPadding(false)
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()])
const macCbc = encrypted.subarray(encrypted.length - MAC_LEN_FULL)
const ctrKey = crypto.createCipheriv(
'aes-128-ctr',
this.sessionKey,
Buffer.concat([seq, this.serial, SECURE_WRAPPER_CTR_SUFFIX]),
)
const encMac = ctrKey.update(macCbc)
const encData = ctrKey.update(inner)
return Buffer.concat([
hdr,
Buffer.from([this.sessionId >> 8, this.sessionId & 0xff]),
seq,
this.serial,
SECURE_WRAPPER_TAG,
encData,
encMac,
])
}
private unwrapFrame(wrapper: Buffer): Buffer {
if (!this.sessionKey) {
throw new Error('Session key missing')
}
const seq = wrapper.subarray(8, 14)
const serial = wrapper.subarray(14, 20)
const tag = wrapper.subarray(20, 22)
const data = wrapper.subarray(22, wrapper.length - 16)
const mac = wrapper.subarray(wrapper.length - 16)
const ctr = crypto.createDecipheriv(
'aes-128-ctr',
this.sessionKey,
Buffer.concat([seq, serial, tag, SECURE_WRAPPER_MAC_SUFFIX]),
)
ctr.update(mac)
return ctr.update(data)
}
private nextTunnelSeq(): number {
const value = this.tunnelSeq & 0xff
this.tunnelSeq = (this.tunnelSeq + 1) & 0xff
return value
}
}
function pad16(buffer: Buffer): Buffer {
const remainder = buffer.length % 16
if (remainder === 0) return buffer
const padding = Buffer.alloc(16 - remainder, 0)
return Buffer.concat([buffer, padding])
}
function KNXIP_HEADER_AND_BODY_LEN(body: number): number {
return body + 6
}
export default MockSecureGateway