UNPKG

@webarray/esphome-native-api

Version:

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

208 lines 7.06 kB
"use strict"; /** * Noise Protocol Encryption for ESPHome Native API * Working implementation with proper state management * Implements Noise_NNpsk0_25519_ChaChaPoly_SHA256 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NoiseEncryption = void 0; const debug_1 = __importDefault(require("debug")); const debug = (0, debug_1.default)('esphome:noise'); // Import the WebAssembly Noise implementation const createNoise = require('@richardhopton/noise-c.wasm'); // Store the noise instance globally once loaded let globalNoiseInstance = null; let noiseLoadPromise = null; /** * Load the noise library (singleton) */ function loadNoise() { if (globalNoiseInstance) { return Promise.resolve(globalNoiseInstance); } if (!noiseLoadPromise) { noiseLoadPromise = new Promise((resolve) => { createNoise((noise) => { globalNoiseInstance = noise; debug('Global noise library loaded'); resolve(noise); }); }); } return noiseLoadPromise; } class NoiseEncryption { constructor(encryptionKey) { this.handshakeState = null; this.sendCipher = null; this.receiveCipher = null; this.handshakeComplete = false; this.initialized = false; // Decode base64 encryption key this.encryptionKey = Buffer.from(encryptionKey, 'base64'); if (this.encryptionKey.length !== 32) { throw new Error('Encryption key must be 32 bytes'); } debug('Noise encryption created with key length:', this.encryptionKey.length); } /** * Initialize the Noise handshake state */ async initialize() { if (this.initialized) { debug('Already initialized'); return; } const noise = await loadNoise(); try { // Create handshake state this.handshakeState = noise.HandshakeState('Noise_NNpsk0_25519_ChaChaPoly_SHA256', noise.constants.NOISE_ROLE_INITIATOR); // Initialize with prologue and PSK // ESPHome uses "NoiseAPIInit\x00\x00" as prologue (with two null bytes) const prologue = Buffer.from('NoiseAPIInit\x00\x00'); this.handshakeState.Initialize(new Uint8Array(prologue), null, // s (static key) - not used in NN pattern null, // rs (remote static) - not used in NN pattern new Uint8Array(this.encryptionKey)); this.initialized = true; debug('Handshake state initialized successfully'); } catch (error) { debug('Failed to initialize noise:', error); throw error; } } /** * Create the first handshake message (e) */ createHandshakeMessage1() { debug('createHandshakeMessage1 called, initialized:', this.initialized, 'handshakeState:', this.handshakeState ? 'exists' : 'null'); if (!this.initialized || !this.handshakeState) { throw new Error('Not initialized. Call initialize() first.'); } try { // Write the first message const message = this.handshakeState.WriteMessage(); const buffer = Buffer.from(message); debug('Created handshake message 1, length:', buffer.length); return buffer; } catch (error) { debug('Failed to create handshake message 1:', error); throw new Error('Failed to create handshake message 1: ' + error.message); } } /** * Process the second handshake message (e, ee) */ processHandshakeMessage2(message) { if (!this.initialized || !this.handshakeState) { throw new Error('Not initialized'); } try { // Read the response message this.handshakeState.ReadMessage(new Uint8Array(message), true); // Split into send and receive ciphers const [encryptor, decryptor] = this.handshakeState.Split(); this.sendCipher = encryptor; this.receiveCipher = decryptor; this.handshakeComplete = true; // Don't free the handshake state immediately after Split() - it causes NOISE_ERROR_INVALID_PARAM // The handshakeState will be cleaned up when the NoiseEncryption instance is destroyed this.handshakeState = null; debug('Handshake complete, ciphers established'); } catch (error) { debug('Failed to process handshake message 2:', error); throw new Error('Failed to process handshake message 2: ' + error.message); } } /** * Encrypt a message */ encrypt(plaintext) { if (!this.handshakeComplete || !this.sendCipher) { throw new Error('Handshake not complete'); } try { const ciphertext = this.sendCipher.EncryptWithAd([], // No associated data new Uint8Array(plaintext)); return Buffer.from(ciphertext); } catch (error) { throw new Error('Encryption failed: ' + error.message); } } /** * Decrypt a message */ decrypt(ciphertext) { if (!this.handshakeComplete || !this.receiveCipher) { throw new Error('Handshake not complete'); } try { const plaintext = this.receiveCipher.DecryptWithAd([], // No associated data new Uint8Array(ciphertext)); return Buffer.from(plaintext); } catch (error) { throw new Error('Decryption failed: ' + error.message); } } /** * Check if handshake is complete */ isHandshakeComplete() { return this.handshakeComplete; } /** * Check if initialized */ get isInitialized() { return this.initialized; } /** * Reset the encryption state */ reset() { if (this.handshakeState) { try { this.handshakeState.free(); } catch { // Ignore cleanup errors } this.handshakeState = null; } if (this.sendCipher) { try { this.sendCipher.free(); } catch { // Ignore cleanup errors } this.sendCipher = null; } if (this.receiveCipher) { try { this.receiveCipher.free(); } catch { // Ignore cleanup errors } this.receiveCipher = null; } this.handshakeComplete = false; this.initialized = false; } /** * Clean up resources */ destroy() { this.reset(); } } exports.NoiseEncryption = NoiseEncryption; //# sourceMappingURL=noise-encryption.js.map