UNPKG

@webarray/esphome-native-api

Version:

TypeScript/Node.js client for ESPHome native API with encryption and deep sleep support

646 lines 26.9 kB
"use strict"; /** * Encrypted Connection Handler for ESPHome Native API * Extends the base connection with noise protocol encryption */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.EncryptedConnection = void 0; const net_1 = require("net"); const eventemitter3_1 = require("eventemitter3"); const p_retry_1 = __importDefault(require("p-retry")); const debug_1 = __importDefault(require("debug")); const protocol_1 = require("../utils/protocol"); const noise_encryption_1 = require("./noise-encryption"); const types_1 = require("../types"); const debug = (0, debug_1.default)('esphome:encrypted-connection'); // Frame types for encrypted communication // ESPHome uses frame type 1 for ALL encrypted frames (both handshake and data) const FRAME_TYPE = 0x01; class EncryptedConnection extends eventemitter3_1.EventEmitter { constructor(options) { super(); this.socket = null; this.state = { connected: false, authenticated: false, }; this.isReconnecting = false; this.isDestroyed = false; this.encryptionBuffer = Buffer.alloc(0); this.encryptionEstablished = false; this.expectedDisconnect = false; this.hasDeepSleep = false; this.options = { host: options.host, port: options.port || 6053, password: options.password || '', clientInfo: options.clientInfo || 'ESPHome TypeScript Client', reconnect: options.reconnect !== false, reconnectInterval: options.reconnectInterval || 5000, pingInterval: options.pingInterval || 20000, pingTimeout: options.pingTimeout || 5000, connectTimeout: options.connectTimeout || 10000, encryptionKey: options.encryptionKey || '', expectedServerName: options.expectedServerName || '', }; this.protocol = new protocol_1.ProtocolHandler(); // Initialize noise encryption if key is provided if (this.options.encryptionKey) { this.noise = new noise_encryption_1.NoiseEncryption(this.options.encryptionKey); debug('Encryption will be initialized for connection to %s:%d', this.options.host, this.options.port); } debug('Encrypted connection initialized for %s:%d', this.options.host, this.options.port); } /** * Connect to the ESPHome device */ async connect() { if (this.isDestroyed) { throw new types_1.ConnectionError('Connection has been destroyed'); } if (this.state.connected) { debug('Already connected'); return; } // Initialize noise encryption if needed (WebAssembly loading) if (this.noise && !this.noise.isInitialized) { debug('Initializing noise encryption WebAssembly'); await this.noise.initialize(); debug('Noise encryption initialized'); } debug('Connecting to %s:%d', this.options.host, this.options.port); try { await (0, p_retry_1.default)(async () => { await this.establishConnection(); }, { retries: this.options.reconnect ? 3 : 0, minTimeout: 1000, maxTimeout: 5000, onFailedAttempt: (error) => { debug('Connection attempt %d failed: %s', error.attemptNumber, error.message); }, }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); debug('Failed to connect: %s', err.message); throw new types_1.ConnectionError(`Failed to connect: ${err.message}`); } } /** * Establish the actual TCP connection */ async establishConnection() { return new Promise((resolve, reject) => { this.cleanup(); const socket = new net_1.Socket(); const connectTimeout = setTimeout(() => { socket.destroy(); reject(new types_1.ConnectionError('Connection timeout')); }, this.options.connectTimeout); connectTimeout.unref(); socket.once('connect', async () => { clearTimeout(connectTimeout); debug('TCP connection established'); this.socket = socket; this.setupSocketHandlers(); this.updateState({ connected: true, authenticated: false }); // Perform noise handshake if encryption is enabled if (this.noise) { try { await this.performNoiseHandshake(); this.encryptionEstablished = true; this.emit('encryptionEstablished'); debug('Encryption established'); } catch (error) { debug('Noise handshake failed: %s', error); socket.destroy(); reject(new types_1.ConnectionError(`Encryption handshake failed: ${error}`)); return; } } this.emit('connect'); this.startPingTimer(); resolve(); }); socket.once('error', (error) => { clearTimeout(connectTimeout); debug('Socket error: %s', error.message); reject(new types_1.ConnectionError(`Socket error: ${error.message}`)); }); socket.connect(this.options.port, this.options.host); } /** * Perform the noise protocol handshake */ , /** * Perform the noise protocol handshake */ private, async, performNoiseHandshake(), Promise < void > { : .socket }); { throw new Error('Socket not connected'); } // eslint-disable-next-line @typescript-eslint/no-var-requires const createNoise = require('@richardhopton/noise-c.wasm'); const encryptionKey = Buffer.from(this.options.encryptionKey, 'base64'); const socket = this.socket; return new Promise((resolve, reject) => { // Create noise instance directly in the handshake flow createNoise((noise) => { debug('Noise library loaded in handshake'); // Create and initialize handshake state let client; let encryptor; let decryptor; try { client = noise.HandshakeState('Noise_NNpsk0_25519_ChaChaPoly_SHA256', noise.constants.NOISE_ROLE_INITIATOR); client.Initialize(new Uint8Array(Buffer.from('NoiseAPIInit\x00\x00')), null, null, new Uint8Array(encryptionKey)); debug('Handshake state initialized in performNoiseHandshake'); } catch (error) { reject(new Error('Failed to initialize handshake: ' + error)); return; } const timeout = setTimeout(() => { reject(new Error('Handshake timeout')); }, 5000); timeout.unref(); let handshakeStep = 0; // 0 = waiting for hello, 1 = waiting for handshake response // Handler for handshake response const handleHandshakeData = async (data) => { debug('handleHandshakeData called, data length=%d, encryptionEstablished=%s, handshakeStep=%d', data.length, this.encryptionEstablished, handshakeStep); try { this.encryptionBuffer = Buffer.concat([this.encryptionBuffer, data]); // Parse frame header while (this.encryptionBuffer.length >= 3) { const frameType = this.encryptionBuffer[0]; const frameLength = this.encryptionBuffer.readUInt16BE(1); if (this.encryptionBuffer.length < 3 + frameLength) { // Not enough data yet return; } if (frameType !== FRAME_TYPE) { throw new Error(`Expected frame type 1, got ${frameType}`); } const frameData = this.encryptionBuffer.slice(3, 3 + frameLength); this.encryptionBuffer = this.encryptionBuffer.slice(3 + frameLength); // Check if we're done with handshake and processing encrypted data if (this.encryptionEstablished && this.decryptor) { // We're in encrypted mode - decrypt and process the message debug('Received encrypted frame, length=%d', frameData.length); try { const decryptedData = Buffer.from(this.decryptor.DecryptWithAd([], new Uint8Array(frameData))); debug('Decrypted data, length=%d', decryptedData.length); // For encrypted connections, messages use a different format: // 2 bytes: message type (big-endian) // 2 bytes: message length (big-endian) // N bytes: protobuf data if (decryptedData.length < 4) { debug('Decrypted data too short'); return; } const messageType = decryptedData.readUInt16BE(0); const messageLength = decryptedData.readUInt16BE(2); const messageData = decryptedData.slice(4, 4 + messageLength); debug('Received encrypted message type %d, length %d', messageType, messageLength); const message = { type: messageType, data: messageData, }; this.handleMessage(message); } catch (decryptError) { debug('Failed to decrypt message: %s', decryptError); throw decryptError; } } else if (handshakeStep === 0) { // Process server hello debug('Received server hello, length=%d', frameData.length); const chosenProto = frameData[0]; if (chosenProto !== 1) { throw new Error(`Unknown protocol selected by server: ${chosenProto}`); } // Check server name if expected if (this.options.expectedServerName) { const nullIndex = frameData.indexOf(0, 1); if (nullIndex > 1) { const serverName = frameData.slice(1, nullIndex).toString(); if (serverName !== this.options.expectedServerName) { throw new Error(`Server name mismatch: expected ${this.options.expectedServerName}, got ${serverName}`); } } } // Send actual handshake message using the client instance directly const handshakeMsg = client.WriteMessage(); // Prepend 0 byte as in reference implementation const msgWithHeader = Buffer.concat([Buffer.from([0]), Buffer.from(handshakeMsg)]); const frame = this.createHandshakeFrame(msgWithHeader); socket.write(frame); debug('Sent handshake message, length=%d', msgWithHeader.length); handshakeStep = 1; } else if (handshakeStep === 1) { // Process handshake response debug('Received handshake response, length=%d', frameData.length); const header = frameData[0]; const message = frameData.slice(1); if (header !== 0) { throw new Error(`Handshake failure: ${message.toString()}`); } // Process handshake message 2 using the client instance directly client.ReadMessage(new Uint8Array(message), true); // Split into send and receive ciphers [encryptor, decryptor] = client.Split(); // Store the ciphers directly on this instance for later use this.encryptor = encryptor; this.decryptor = decryptor; // Store the ciphers for encrypted message handling this.encryptionEstablished = true; // Clean up timeout but keep the data handler clearTimeout(timeout); // Don't remove the data handler - we'll continue using it for encrypted messages // The handler will now process encrypted data frames debug('Encryption established, socket connected: %s, listeners: %d', socket.readyState === 'open', socket.listenerCount('data')); this.emit('encryptionEstablished'); resolve(); } } } catch (error) { clearTimeout(timeout); if (this.socket) { this.socket.off('data', handleHandshakeData); } reject(error); } }; if (socket) { socket.on('data', handleHandshakeData); debug('Socket data handler attached, listener count: %d', socket.listenerCount('data')); // Send initial hello (empty frame) as in reference const helloFrame = this.createHandshakeFrame(Buffer.alloc(0)); socket.write(helloFrame); debug('Sent hello frame'); // Add error handler to catch socket issues socket.on('error', (err) => { debug('Socket error during handshake: %s', err.message); }); } }); // End of createNoise callback }); // End of Promise } /** * Create a handshake frame */ createHandshakeFrame(data) { const frame = Buffer.alloc(3 + data.length); frame[0] = FRAME_TYPE; frame.writeUInt16BE(data.length, 1); data.copy(frame, 3); return frame; } /** * Create an encrypted data frame */ createDataFrame(data) { const frame = Buffer.alloc(3 + data.length); frame[0] = FRAME_TYPE; // Use same frame type as handshake frame.writeUInt16BE(data.length, 1); data.copy(frame, 3); return frame; } /** * Setup socket event handlers (for non-encrypted connections) */ setupSocketHandlers() { if (!this.socket || this.noise) return; this.socket.on('data', (data) => { try { const messages = this.protocol.addData(data); for (const message of messages) { debug('Received message type %d', message.type); this.handleMessage(message); } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); debug('Protocol error: %s', err.message); this.emit('error', new types_1.ProtocolError(err.message)); this.disconnect(); } }); this.socket.on('error', (error) => { debug('Socket error: %s', error.message); this.emit('error', error); }); this.socket.on('close', () => { debug('Socket closed'); this.handleDisconnect(); }); this.socket.on('end', () => { debug('Socket ended'); this.handleDisconnect(); }); } /** * Handle incoming messages */ handleMessage(message) { // Handle ping/pong if (message.type === types_1.MessageType.PingRequest) { this.sendMessage(types_1.MessageType.PingResponse, Buffer.alloc(0)); return; } if (message.type === types_1.MessageType.PingResponse) { this.handlePongResponse(); return; } // Handle disconnect request (device going to sleep) if (message.type === types_1.MessageType.DisconnectRequest) { this.handleDisconnectRequest(); return; } // Emit message for higher-level handling this.emit('message', message); } /** * Send a message to the device */ sendMessage(type, data) { if (!this.socket || !this.state.connected) { throw new types_1.ConnectionError('Not connected'); } if (this.encryptor && this.encryptionEstablished) { // For encrypted connections, use a different message format: // 2 bytes: message type (big-endian) // 2 bytes: message length (big-endian) // N bytes: protobuf data // (No preamble byte, no varint encoding) const messageType = Buffer.allocUnsafe(2); messageType.writeUInt16BE(type, 0); const messageLength = Buffer.allocUnsafe(2); messageLength.writeUInt16BE(data.length, 0); const plaintext = Buffer.concat([messageType, messageLength, data]); // Encrypt the plaintext const encryptedFrame = Buffer.from(this.encryptor.EncryptWithAd([], new Uint8Array(plaintext))); const dataFrame = this.createDataFrame(encryptedFrame); this.socket.write(dataFrame); debug('Sent encrypted message type %d, plaintext %d bytes, encrypted %d bytes, frame %d bytes', type, data.length, encryptedFrame.length, dataFrame.length); } else { // Send unencrypted using standard protocol format const frame = this.protocol.encodeMessage(type, data); this.socket.write(frame); debug('Sent message type %d, %d bytes', type, data.length); } } /** * Handle disconnect request from device (deep sleep) */ handleDisconnectRequest() { debug('Received DisconnectRequest - device going to sleep'); try { // Send acknowledgment this.sendMessage(types_1.MessageType.DisconnectResponse, Buffer.alloc(0)); debug('Sent DisconnectResponse'); } catch (error) { debug('Failed to send DisconnectResponse: %s', error); } // Mark as expected disconnect this.expectedDisconnect = true; // Stop ping mechanism - device is sleeping this.stopPingTimer(); this.stopPingTimeoutTimer(); // If device has deep sleep, disable auto-reconnect if (this.hasDeepSleep) { debug('Deep sleep device - disabling auto-reconnect'); this.options.reconnect = false; } // Disconnect cleanly this.disconnect(); } /** * Disconnect from the device */ disconnect() { debug('Disconnecting (expected: %s)', this.expectedDisconnect); this.cleanup(); this.updateState({ connected: false, authenticated: false }); this.emit('disconnect'); // Reset expected disconnect flag after handling this.expectedDisconnect = false; } /** * Handle disconnection and potential reconnection */ handleDisconnect() { const wasConnected = this.state.connected; this.cleanup(); this.updateState({ connected: false, authenticated: false }); this.encryptionEstablished = false; if (wasConnected) { this.emit('disconnect'); } if (this.options.reconnect && !this.isDestroyed && !this.isReconnecting) { this.scheduleReconnect(); } } /** * Schedule a reconnection attempt */ scheduleReconnect() { if (this.isReconnecting || this.isDestroyed) return; debug('Scheduling reconnect in %dms', this.options.reconnectInterval); this.isReconnecting = true; this.reconnectTimer = setTimeout(async () => { if (this.isDestroyed) return; debug('Attempting to reconnect'); try { await this.connect(); this.isReconnecting = false; } catch (error) { debug('Reconnection failed: %s', error); this.isReconnecting = false; this.scheduleReconnect(); } }, this.options.reconnectInterval); } /** * Enable deep sleep mode for this connection */ setDeepSleepMode(enabled) { this.hasDeepSleep = enabled; debug('Deep sleep mode %s', enabled ? 'enabled' : 'disabled'); if (enabled && this.state.connected) { // If already connected and deep sleep is enabled, stop pinging this.stopPingTimer(); debug('Stopped ping timer for deep sleep device'); } } /** * Check if this is an expected disconnect (e.g., deep sleep) */ isExpectedDisconnect() { return this.expectedDisconnect; } /** * Start the ping timer */ startPingTimer() { // Don't ping deep sleep devices if (this.hasDeepSleep) { debug('Deep sleep device - ping disabled'); return; } this.stopPingTimer(); this.pingTimer = setInterval(() => { if (!this.state.connected) { this.stopPingTimer(); return; } debug('Sending ping'); try { this.sendMessage(types_1.MessageType.PingRequest, Buffer.alloc(0)); this.startPingTimeoutTimer(); } catch (error) { debug('Failed to send ping: %s', error); this.handleDisconnect(); } }, this.options.pingInterval); } /** * Start the ping timeout timer */ startPingTimeoutTimer() { this.stopPingTimeoutTimer(); this.pingTimeoutTimer = setTimeout(() => { debug('Ping timeout'); this.handleDisconnect(); }, this.options.pingTimeout); this.pingTimeoutTimer.unref(); } /** * Handle pong response */ handlePongResponse() { debug('Received pong'); this.stopPingTimeoutTimer(); } /** * Stop the ping timer */ stopPingTimer() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; } this.stopPingTimeoutTimer(); } /** * Stop the ping timeout timer */ stopPingTimeoutTimer() { if (this.pingTimeoutTimer) { clearTimeout(this.pingTimeoutTimer); this.pingTimeoutTimer = undefined; } } /** * Clean up resources */ cleanup() { this.stopPingTimer(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; } if (this.socket) { this.socket.removeAllListeners(); this.socket.destroy(); this.socket = null; } this.protocol.clearBuffer(); this.encryptionBuffer = Buffer.alloc(0); if (this.noise) { this.noise.reset(); } } /** * Update connection state */ updateState(newState) { const oldState = { ...this.state }; this.state = { ...this.state, ...newState }; if (JSON.stringify(oldState) !== JSON.stringify(this.state)) { debug('State changed: %o', this.state); this.emit('stateChange', this.state); } } /** * Destroy the connection */ destroy() { debug('Destroying connection'); this.isDestroyed = true; this.cleanup(); this.removeAllListeners(); } /** * Get the current connection state */ getState() { return { ...this.state }; } /** * Check if connected */ isConnected() { return this.state.connected; } /** * Check if authenticated */ isAuthenticated() { return this.state.authenticated; } /** * Check if encryption is established */ isEncrypted() { return this.encryptionEstablished; } /** * Set authentication state */ setAuthenticated(authenticated) { this.updateState({ authenticated }); } /** * Set API version */ setApiVersion(major, minor) { this.updateState({ apiVersion: { major, minor } }); } /** * Set server info */ setServerInfo(info) { this.updateState({ serverInfo: info }); } } exports.EncryptedConnection = EncryptedConnection; //# sourceMappingURL=encrypted-connection-broken.js.map