@kybarg/ssp
Version:
Node.JS library Encrypted Smiley ® Secure Protocol (eSSP, SSP)
381 lines (318 loc) • 10.4 kB
JavaScript
const { once, EventEmitter } = require('node:events')
const { promisify } = require('node:util')
const { SerialPort } = require('serialport')
const chalk = require('chalk')
const debug = require('debug')('ssp')
const {
argsToByte,
CRC16,
extractPacketData,
generateKeys,
getPacket,
parseData,
createSSPHostEncryptionKey,
validateNodeVersion,
} = require('./utils.js')
const commandList = require('./static/commands.json')
const { SSPParser } = require('./parser/index.js')
const PORT_OPTIONS = {
baudRate: 9600,
dataBits: 8,
stopBits: 2,
parity: 'none',
highWaterMark: 64 * 1024,
}
class SSP extends EventEmitter {
constructor(config) {
super()
validateNodeVersion()
// Initialize event emitter
this.eventEmitter = new EventEmitter()
// Initialize config
const { fixedKey = '0123456701234567', ...otherConfig } = config
this.config = {
encryptAllCommand: true,
id: 0,
timeout: 1000,
commandRetries: 20,
pollingInterval: 300,
...otherConfig,
}
// Initialize keys
this.keys = {
encryptKey: null,
fixedKey,
generator: null,
hostInter: null,
hostRandom: null,
key: null,
modulus: null,
slaveInterKey: null,
}
// Initialize state
this.state = {
enabled: false,
polling: false,
processing: false,
}
this.eCount = 0
this.commandSendAttempts = 0
this.sequence = 0x80
this.protocol_version = null
this.unit_type = null
}
async open(port, options = {}) {
const serialOptions = { ...PORT_OPTIONS, ...options }
this.port = new SerialPort({ path: port, autoOpen: false, ...serialOptions })
this.port.on('open', () => {
this.emit('OPEN')
})
this.port.on('close', () => {
this.emit('CLOSE')
})
this.port.on('error', error => {
this.emit('ERROR', error)
this.eventEmitter.emit('error', error)
})
this.parser = this.port.pipe(new SSPParser())
this.parser.on('data', buffer => {
this.eventEmitter.emit('DATA', buffer)
})
return promisify(this.port.open.bind(this.port))()
}
async close() {
if (this.port !== undefined) {
return promisify(this.port.close.bind(this.port))()
}
return
}
getSequence() {
return this.config.id | this.sequence
}
/**
* Exchange encryption keys.
*
* @returns {Promise<Object>} result - The result of the key exchange.
*/
async initEncryption() {
const newKeys = generateKeys()
// Reset counter and keys
Object.assign(this.keys, newKeys, { encryptKey: null })
this.eCount = 0
// Define key exchange commands
const commands = [
{ command: 'SET_GENERATOR', args: { key: this.keys.generator } },
{ command: 'SET_MODULUS', args: { key: this.keys.modulus } },
{ command: 'REQUEST_KEY_EXCHANGE', args: { key: this.keys.hostInter } },
]
// Execute key exchange commands sequentially
let result
for (const { command, args } of commands) {
result = await this.command(command, args)
if (!result || !result.success) {
throw result
}
}
return result
}
parsePacketData(buffer, command) {
const parsedData = parseData(buffer, command, this.protocol_version, this.unit_type)
debug(parsedData)
if (parsedData.success) {
if (command === 'REQUEST_KEY_EXCHANGE') {
try {
const keys = createSSPHostEncryptionKey(Buffer.from(parsedData.info.key), this.keys)
this.keys = { ...this.keys, ...keys }
debug('AES encrypt key:', chalk.red(`0x${Buffer.from(this.keys.encryptKey).toString('hex')}`))
debug(this.keys)
} catch (error) {
debug(error)
throw new Error('Key exchange error')
}
} else if (command === 'SETUP_REQUEST') {
this.protocol_version = parsedData.info.protocol_version
this.unit_type = parsedData.info.unit_type
} else if (command === 'UNIT_DATA') {
this.unit_type = parsedData.info.unit_type
}
} else {
if (command === 'HOST_PROTOCOL_VERSION') {
this.protocol_version = undefined
}
}
return parsedData
}
/**
* Enable for acepting cash.
*
* @returns {Promise<Object>} result - The result of the enable command.
*/
async enable() {
const result = await this.command('ENABLE')
if (result.status === 'OK') {
this.state.enabled = true
if (!this.state.polling) await this.poll(true)
}
return result
}
/**
* Disable for acepting cash.
*
* @returns {Promise<Object>} result - The result of the disable command.
*/
async disable() {
if (this.state.polling) await this.poll(false)
const result = await this.command('DISABLE')
if (result.status === 'OK') {
this.state.enabled = false
}
return result
}
async command(command, args) {
command = command.toUpperCase()
if (commandList[command] === undefined) {
throw new Error('Unknown command')
}
if (commandList[command].encrypted && this.keys.encryptKey === null) {
throw new Error('Command requires ecnryption')
}
if (this.state.processing) {
throw new Error('Already processing another command')
}
if (command === 'SYNC') {
this.sequence = 0x80
}
this.commandSendAttempts = 0
const isEncrypted = this.keys.encryptKey !== null && (commandList[command].encrypted || this.config.encryptAllCommand)
const argBytes = argsToByte(command, args, this.protocol_version)
const sequence = this.getSequence()
const encryptionKey = isEncrypted ? this.keys.encryptKey : null
const buffer = getPacket(command, argBytes, sequence, encryptionKey, this.eCount)
const bufferPlain = isEncrypted ? getPacket(command, argBytes, sequence, null, this.eCount) : buffer
const result = await this.sendToDevice(command, buffer, bufferPlain)
// update sequence after response received
this.sequence = this.sequence === 0x00 ? 0x80 : 0x00
if (!result.success) {
throw result
}
return result
}
async sendToDevice(command, txBuffer, txBufferPlain) {
for (let i = 0; i < this.config.commandRetries; i++) {
// Set processing state
this.state.processing = true
debug('COM <-', chalk.cyan(txBuffer.toString('hex')), chalk.green(command), this.eCount, Date.now())
const debugData = {
command,
tx: {
createdAt: Date.now(),
encrypted: txBuffer,
plain: txBufferPlain,
},
rx: {
createdAt: null,
encrypted: null,
plain: null,
},
}
// Define command timeout
const commandTimeout = setTimeout(() => {
this.eventEmitter.emit('error', new Error('TIMEOUT'))
}, this.config.timeout)
try {
// Send command to device
this.port.write(txBuffer)
this.commandSendAttempts += 1
// Await data from device
const [rxBuffer] = await once(this.eventEmitter, 'DATA')
clearTimeout(commandTimeout)
debugData.rx.createdAt = Date.now()
debugData.rx.encrypted = rxBuffer
debug('COM ->', chalk.yellow(rxBuffer.toString('hex')), chalk.green(command), this.eCount, Date.now())
// Extract packet data bytes omiting packing data
const DATA = extractPacketData(rxBuffer, this.keys.encryptKey, this.eCount)
debugData.rx.plain = Buffer.from([...rxBuffer.slice(0, 2), DATA.length, ...DATA, ...CRC16([rxBuffer[1], DATA.length, ...DATA])])
// Check if sequence flag mismatch
if (txBuffer[1] !== rxBuffer[1]) {
throw new Error('Sequence flag mismatch')
}
// Increment counter if encrypted command is received
if (this.keys.encryptKey && rxBuffer[3] === 0x7e) {
this.eCount += 1
}
// Return parsed packet data
return this.parsePacketData(DATA, command)
} catch (error) {
debugData.rx.createdAt = Date.now()
// Retry sending the same command
// After 20 retries, host assumes that the slave has crashed.
if (this.commandSendAttempts == this.config.commandRetries) {
throw {
success: false,
error: `Command failed afte ${this.config.commandRetries} retries`,
reason: error,
}
}
} finally {
// Unset processing state and clear command fail timeout
clearTimeout(commandTimeout)
this.state.processing = false
this.emit('DEBUG', debugData)
}
}
}
async poll(status = null) {
// Wait until processing is finished
if (this.state.processing)
await new Promise(resolve => {
const interval = setInterval(() => {
if (!this.state.processing) {
clearInterval(interval)
resolve()
}
}, 1)
})
// If status is true and polling is already in progress, exit early
if (status === true && this.state.polling === true) return
// Start polling if status is true
if (status === true) {
this.state.polling = true
}
// Stop polling if status is false
else if (status === false) {
this.state.polling = false
clearTimeout(this.pollTimeout)
}
// Poll only if polling is enabled
if (this.state.polling) {
try {
const startTime = Date.now()
const result = await this.command('POLL')
// Emit events if result contains info
if (result.info) {
const infos = Array.isArray(result.info) ? result.info : [result.info]
infos.forEach(info => this.emit(info.name, info))
}
// Calculate execution time and schedule next poll
const endTime = Date.now()
const executionTime = endTime - startTime
this.pollTimeout = setTimeout(
async () => {
try {
await this.poll()
} catch (error) {
this.emit('ERROR', error)
}
},
Math.max(0, this.config.pollingInterval - executionTime),
)
return result
} catch (error) {
// Stop polling in case of error
this.state.polling = false
throw error
}
}
}
}
module.exports = SSP