esphome-client
Version:
A nearly complete implementation of the ESPHome client protocol with encryption support.
480 lines (479 loc) • 18.7 kB
TypeScript
/**
* @module crypto-noise
*
* This module implements the Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake pattern with optional prologue support. The implementation only uses Node native
* cryptographic primitives for X25519 key exchange operations and includes robust logging support. After completing the handshake, you'll have access to
* cipher states for bidirectional encrypted communication.
*
* @example Basic Handshake and Encryption
* ```typescript
* import { createHandshake } from "./crypto-noise";
* import { randomBytes } from "node:crypto";
*
* // Create a pre-shared key that both parties must possess. This must be exactly 32 bytes.
* const psk = randomBytes(32);
*
* // Initialize the initiator and responder with their respective roles.
* const initiator = createHandshake({ role: "initiator", psk });
* const responder = createHandshake({ role: "responder", psk });
*
* // Perform the two-message handshake pattern. First, the initiator sends their ephemeral key.
* const msg1 = initiator.writeMessage();
* const payload1 = responder.readMessage(msg1);
*
* // Then the responder replies with their ephemeral key, completing the handshake.
* const msg2 = responder.writeMessage();
* const payload2 = initiator.readMessage(msg2);
*
* // After the handshake completes, both parties have cipher states for secure communication.
* // The initiator uses sendCipher to encrypt and receiveCipher to decrypt.
* const encrypted = initiator.sendCipher.EncryptWithAd(Buffer.alloc(0), Buffer.from("Hello World"));
* const decrypted = responder.receiveCipher.DecryptWithAd(Buffer.alloc(0), encrypted);
* console.log("Decrypted message:", decrypted.toString());
*
* // The responder can send messages back using their sendCipher.
* const response = responder.sendCipher.EncryptWithAd(Buffer.alloc(0), Buffer.from("Hello back!"));
* const responseDecrypted = initiator.receiveCipher.DecryptWithAd(Buffer.alloc(0), response);
* ```
*
* @example ESPHome Device Connection
* ```typescript
* import { createESPHomeHandshake } from "./crypto-noise";
* import { connect } from "node:net";
*
* // Connect to an ESPHome device using its pre-shared key from the YAML configuration.
* // The PSK is configured in your device's YAML under api.encryption.key.
* const handshake = createESPHomeHandshake({
* role: "initiator", // Clients are always initiators when connecting to ESPHome devices.
* psk: Buffer.from("your-32-byte-psk-from-esphome-config", "base64"),
* logger: myLogger
* });
*
* // Connect to the device on port 6053, which is the standard ESPHome API port.
* const socket = connect(6053, "192.168.1.100");
*
* socket.on("connect", () => {
* // Send the first handshake message containing our ephemeral public key.
* const hello = handshake.writeMessage();
* socket.write(hello);
* });
*
* socket.on("data", (data) => {
* if (!handshake.isComplete) {
* // Process the device's handshake response.
* handshake.readMessage(data);
*
* // The handshake is now complete. We can use the cipher states for API communication.
* // All subsequent API messages must be encrypted using these cipher states.
* const apiHello = createAPIHelloMessage(); // Your API protocol implementation.
* const encrypted = handshake.sendCipher.EncryptWithAd(Buffer.alloc(0), apiHello);
* socket.write(encrypted);
* } else {
* // Decrypt incoming API messages from the device.
* const plaintext = handshake.receiveCipher.DecryptWithAd(Buffer.alloc(0), data);
* processAPIMessage(plaintext); // Your API message handler.
* }
* });
* ```
*
* @example Using Associated Data for Message Authentication
* ```typescript
* // You can include associated data that gets authenticated but not encrypted.
* // This is useful for message sequence numbers or protocol headers.
* const sequenceNumber = Buffer.allocUnsafe(4);
* sequenceNumber.writeUInt32LE(messageCount++, 0);
*
* // The associated data is authenticated but transmitted in plaintext.
* const encrypted = handshake.sendCipher.EncryptWithAd(sequenceNumber, payload);
*
* // The receiver must provide the same associated data to decrypt successfully.
* const decrypted = handshake.receiveCipher.DecryptWithAd(sequenceNumber, encrypted);
* ```
*/
import type { EspHomeLogging, Nullable } from "./types.js";
import { Buffer } from "node:buffer";
/**
* Maximum Noise protocol message length as specified in the Noise Protocol Framework.
*/
export declare const NOISE_MAX_MESSAGE_LEN = 65535;
/**
* Required length for pre-shared keys in bytes.
*/
export declare const NOISE_PSK_LEN = 32;
/**
* Length of Diffie-Hellman public keys in bytes.
*/
export declare const NOISE_DH_LEN = 32;
/**
* ESPHome Noise protocol prologue prefix used for all ESPHome API connections. This identifies the connection as using the ESPHome Native API protocol.
*/
export declare const ESPHOME_NOISE_PROLOGUE = "NoiseAPIInit\0\0";
/**
* Role in the Noise protocol handshake.
*/
export type NoiseRole = "initiator" | "responder";
/**
* Options for creating a Noise handshake.
*/
export interface NoiseHandshakeOptions {
/** The role this party plays in the handshake. */
role: NoiseRole;
/** The 32-byte pre-shared key for authentication. */
psk: Buffer;
/** Optional prologue data to bind to the handshake. */
prologue?: Buffer;
/** Optional logger for debugging output. */
logger?: EspHomeLogging;
}
/**
* Options for creating an ESPHome Noise handshake.
* This is a specialized version for connecting to ESPHome devices.
*/
export interface ESPHomeHandshakeOptions {
/** The role in the handshake (defaults to "initiator" for clients). */
role?: "initiator" | "responder";
/** The 32-byte pre-shared key configured in the ESPHome device. */
psk: Buffer;
/** Optional additional data to append to the ESPHome prologue. */
additionalPrologueData?: Buffer;
/** Optional logger for debugging output. */
logger?: EspHomeLogging;
}
/**
* Noise handshake error codes to allow precise error handling by consumers.
*/
export type NoiseHandshakeErrorCode = "AUTH_FAILED" | "CT_TOO_SHORT" | "HANDSHAKE_COMPLETE" | "INVALID_PSK_LENGTH" | "MISSING_KEYS" | "MSG_TOO_LONG" | "NO_PATTERN" | "NOT_INITIALIZED" | "TRUNCATED_E" | "UNSUPPORTED_TOKEN";
/**
* Custom error class for Noise protocol errors with error codes for better error handling.
*/
export declare class NoiseHandshakeError extends Error {
readonly code: string;
/**
* Creates a new NoiseHandshakeError.
* @param message - The error message.
* @param code - A machine-readable error code.
*/
constructor(message: string, code: string);
}
/**
* CipherState manages the encryption state for a single direction of communication.
* Implements the CipherState object as specified in Noise Protocol Framework §5.1 using ChaCha20-Poly1305.
*/
declare class CipherState {
private readonly log?;
private k;
private n;
private readonly nonce;
constructor(log?: EspHomeLogging | undefined);
/**
* Initializes the cipher state with a new key, resetting the nonce counter to zero.
*/
InitializeKey(key: Nullable<Buffer>): void;
/**
* Checks whether this cipher state has an encryption key set.
*/
HasKey(): boolean;
/**
* Updates the reusable nonce buffer with the current counter value in little-endian format at offset 4.
*/
private updateNonce;
/**
* Encrypts plaintext with associated data using ChaCha20-Poly1305. Returns plaintext unchanged if no key is set.
*/
EncryptWithAd(ad: Buffer, plaintext: Buffer): Buffer;
/**
* Decrypts ciphertext with associated data using ChaCha20-Poly1305. Returns input unchanged if no key is set.
*/
DecryptWithAd(ad: Buffer, data: Buffer): Buffer;
/**
* Rekeys the cipher state by encrypting zeros with the maximum nonce value, providing forward secrecy.
*/
Rekey(): void;
}
/**
* HandshakeState manages the complete Noise protocol handshake, implementing the NNpsk0 pattern with optional prologue support.
* This class implements the HandshakeState object as specified in Noise Protocol Framework §5.3. After the handshake completes, the sendCipher and receiveCipher
* properties provide access to the encryption states for ongoing communication.
*
* @example Direct Handshake Usage
* ```typescript
* const handshake = new HandshakeState(true, psk, logger, prologue);
*
* // Write the first message with an optional payload.
* const message = handshake.writeMessage(Buffer.from("client-hello"));
*
* // After the handshake completes, use the cipher states directly.
* if (handshake.isComplete) {
* const encrypted = handshake.sendCipher.EncryptWithAd(Buffer.alloc(0), data);
* }
* ```
*
* @example ESPHome Connection Pattern
* ```typescript
* // For ESPHome connections, use the specialized factory function which sets up
* // the correct prologue automatically. ESPHome uses "NoiseAPIInit" as its prologue.
* import { createESPHomeHandshake } from "./crypto-noise";
*
* const handshake = createESPHomeHandshake({
* role: "initiator",
* psk: Buffer.from(esphomeKey, "base64")
* });
*
* // The handshake follows a strict two-message pattern.
* const clientHello = handshake.writeMessage();
* // Send to device and receive response...
* handshake.readMessage(deviceResponse);
*
* // Now handshake.isComplete is true and cipher states are available.
* ```
*/
export declare class HandshakeState {
private readonly initiator;
private readonly psk;
private readonly log?;
sendCipher?: CipherState;
receiveCipher?: CipherState;
isComplete: boolean;
private ss;
private ephemeral?;
private remotePubKey?;
private patternIndex;
/**
* Constructs a new handshake state for the NNpsk0 pattern.
* @param initiator - True if we're the initiator, false if we're the responder.
* @param psk - The 32-byte pre-shared key for authentication.
* @param log - Optional Homebridge-compatible logger for debugging.
* @param prologue - Optional fixed prologue bytes to mix into the handshake hash.
* @throws {NoiseHandshakeError} If the PSK is not exactly 32 bytes.
*/
constructor(initiator: boolean, psk: Buffer, log?: EspHomeLogging | undefined, prologue?: Buffer);
/**
* Gets the role of this party in the handshake.
*/
get role(): NoiseRole;
/**
* Checks if this party can send encrypted messages (handshake complete and send cipher available).
*/
get canSend(): boolean;
/**
* Checks if this party can receive encrypted messages (handshake complete and receive cipher available).
*/
get canReceive(): boolean;
/**
* Ensures the handshake is still in progress (not yet complete).
*/
private ensureHandshakeInProgress;
/**
* Ensures both ephemeral and remote public keys are available for DH operations.
*/
private ensureKeysForDH;
/**
* Processes a single token during message writing.
*/
private processWriteToken;
/**
* Processes a single token during message reading.
*/
private processReadToken;
/**
* Writes a handshake message according to the next pattern in the sequence.
* @param payload - Optional payload data to encrypt and include in the message.
* @returns The complete handshake message to send.
* @throws {NoiseHandshakeError} If the handshake is already complete or if pattern processing fails.
*
* @example
* ```typescript
* try {
* const message1 = initiator.writeMessage();
* const message2 = initiator.writeMessage(Buffer.from("hello"));
* } catch (error) {
* if (error instanceof NoiseHandshakeError) {
* console.error("Write failed:", error.message, "Code:", error.code);
* }
* }
* ```
*/
writeMessage(payload?: Buffer): Buffer;
/**
* Reads a handshake message according to the next pattern in the sequence.
* @param message - The received handshake message to process.
* @returns The decrypted payload from the message.
* @throws {NoiseHandshakeError} If the handshake is already complete or authentication fails.
*
* @example
* ```typescript
* const payload = responder.readMessage(message1);
* console.log("Received:", payload.toString());
* ```
*/
readMessage(message: Buffer): Buffer;
/**
* Clears sensitive key material from memory where possible.
* Note: Cannot clear KeyObject internal memory in Node.js.
*
* @example
* ```typescript
* // Clean up after handshake
* handshake.destroy();
* ```
*/
destroy(): void;
}
/**
* Factory function to create a Noise handshake with a cleaner API. This is the primary way to create a handshake for general Noise protocol usage. For ESPHome specific
* connections, use createESPHomeHandshake instead.
*
* @param options - Configuration options for the handshake.
* @returns A configured HandshakeState instance ready for the handshake process.
*
* @example Standard Usage
* ```typescript
* import { createHandshake } from "./crypto-noise";
*
* const handshake = createHandshake({
* role: "initiator",
* psk: myPreSharedKey,
* prologue: Buffer.from("application-specific-data"),
* logger: myLogger
* });
*
* // Perform the handshake and then use the cipher states.
* const msg = handshake.writeMessage();
* // ... exchange messages ...
*
* // After completion, encrypt data using the cipher states.
* const encrypted = handshake.sendCipher.EncryptWithAd(Buffer.alloc(0), plaintext);
* ```
*
* @example Minimal Configuration
* ```typescript
* // The minimal configuration only requires a role and PSK.
* const handshake = createHandshake({
* role: "responder",
* psk: sharedSecret
* });
* ```
*/
export declare function createHandshake(options: NoiseHandshakeOptions): HandshakeState;
/**
* Factory function to create a Noise handshake specifically for ESPHome connections.
* This function automatically configures the correct prologue for ESPHome Native API communication.
* ESPHome devices expect a specific prologue format and this function handles that setup automatically.
*
* @param options - Configuration options for the ESPHome handshake.
* @returns A configured HandshakeState instance ready for ESPHome communication.
*
* @example Complete ESPHome Connection Flow
* ```typescript
* import { createESPHomeHandshake } from "./crypto-noise";
* import { connect } from "node:net";
*
* // The PSK is configured in your ESPHome device YAML file. Look for the api.encryption.key field in your device configuration.
* // api:
* // encryption:
* // key: "base64-encoded-32-byte-key"
*
* const psk = Buffer.from("your-base64-key", "base64");
* const handshake = createESPHomeHandshake({
* role: "initiator", // Clients connecting to ESPHome devices are always initiators.
* psk: psk
* });
*
* // Connect to the ESPHome device on its API port (default 6053).
* const socket = connect(6053, "device-ip-address");
*
* // Perform the two-message Noise handshake once connected.
* socket.on("connect", () => {
* const clientHello = handshake.writeMessage();
* socket.write(clientHello);
* });
*
* socket.on("data", (data) => {
* if (!handshake.isComplete) {
* // Complete the handshake by processing the device's response.
* handshake.readMessage(data);
* console.log("Handshake complete, ready for encrypted API communication.");
*
* // Now you can send encrypted API messages using the established cipher states.
* const apiMessage = createConnectRequest(); // Your API message creation.
* const encrypted = handshake.sendCipher.EncryptWithAd(Buffer.alloc(0), apiMessage);
* socket.write(encrypted);
* } else {
* // All subsequent communication is encrypted using the cipher states.
* const decrypted = handshake.receiveCipher.DecryptWithAd(Buffer.alloc(0), data);
* handleAPIResponse(decrypted); // Your API response handler.
* }
* });
* ```
*
* @example With Logging for Debugging
* ```typescript
* // Enable detailed logging to troubleshoot handshake issues.
* const handshake = createESPHomeHandshake({
* role: "initiator",
* psk: myPSK,
* logger: {
* debug: (msg) => console.log("[DEBUG]", msg),
* error: (msg) => console.error("[ERROR]", msg)
* }
* });
*
* // The logger will output detailed information about each handshake step,
* // including key exchanges, hash updates, and cipher state transitions.
* ```
*
* @example Implementing an ESPHome-Compatible Server
* ```typescript
* // If you're implementing a server that ESPHome devices can connect to,
* // configure the handshake as a responder. This is uncommon but supported.
* const handshake = createESPHomeHandshake({
* role: "responder",
* psk: serverPSK,
* additionalPrologueData: Buffer.from("server-identifier")
* });
*
* // Wait for incoming connections and process the initiator's hello message.
* server.on("connection", (socket) => {
* socket.on("data", (data) => {
* if (!handshake.isComplete) {
* // Read the client's hello message.
* handshake.readMessage(data);
*
* // Send our response to complete the handshake.
* const response = handshake.writeMessage();
* socket.write(response);
* } else {
* // Handle encrypted API messages.
* const decrypted = handshake.receiveCipher.DecryptWithAd(Buffer.alloc(0), data);
* processIncomingMessage(decrypted);
* }
* });
* });
* ```
*
* @example Error Handling
* ```typescript
* try {
* const handshake = createESPHomeHandshake({
* role: "initiator",
* psk: psk
* });
*
* // Process messages with proper error handling.
* handshake.readMessage(incomingData);
* } catch (error) {
* if (error instanceof NoiseHandshakeError) {
* // Handle specific Noise protocol errors.
* console.error("Handshake failed:", error.message, "Code:", error.code);
*
* // Common error codes include:
* // AUTH_FAILED - Authentication tag verification failed.
* // INVALID_PSK_LENGTH - PSK is not exactly 32 bytes.
* // HANDSHAKE_COMPLETE - Attempting operations after handshake finished.
* // MISSING_KEYS - Required keys not available for DH operation.
* }
* }
* ```
*/
export declare function createESPHomeHandshake(options: ESPHomeHandshakeOptions): HandshakeState;
export {};