@webarray/esphome-native-api
Version:
TypeScript/Node.js client for ESPHome native API with encryption and deep sleep support
208 lines • 7.06 kB
JavaScript
"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