esphome-client
Version:
A nearly complete implementation of the ESPHome client protocol with encryption support.
771 lines • 32.8 kB
JavaScript
/* Copyright(C) 2017-2025, HJD (https://github.com/hjdhjd). All rights reserved.
*
* crypto-noise.ts: A complete Node-native Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake implementation with no external dependencies.
*/
import { createCipheriv, createDecipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync } from "node:crypto";
import { Buffer } from "node:buffer";
// Protocol constants that define the specific Noise protocol variant we're implementing.
const PROTOCOL_NAME = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
const DH_LEN = 32;
const CIPHER_ALGO = "chacha20-poly1305";
const AUTH_TAG_LEN = 16;
const HASH_ALGO = "sha256";
// Cached empty buffer to avoid repeated allocations for commonly used empty buffers.
const EMPTY_BUFFER = Buffer.alloc(0);
/**
* Maximum Noise protocol message length as specified in the Noise Protocol Framework.
*/
export const NOISE_MAX_MESSAGE_LEN = 65535;
/**
* Required length for pre-shared keys in bytes.
*/
export const NOISE_PSK_LEN = 32;
/**
* Length of Diffie-Hellman public keys in bytes.
*/
export 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 const ESPHOME_NOISE_PROLOGUE = "NoiseAPIInit\x00\x00";
// Cache the protocol name hash since it never changes.
const PROTOCOL_NAME_HASH = (() => {
const nameBuf = Buffer.from(PROTOCOL_NAME, "ascii");
if (nameBuf.length <= 32) {
const h = Buffer.alloc(32);
nameBuf.copy(h);
return h;
}
return createHash(HASH_ALGO).update(nameBuf).digest();
})();
// The NNpsk0 handshake pattern: first message mixes PSK and sends ephemeral, second message sends ephemeral and performs DH.
const NNPSK0_PATTERN = [
["psk", "e"],
["e", "ee"]
];
/**
* Custom error class for Noise protocol errors with error codes for better error handling.
*/
export class NoiseHandshakeError extends Error {
code;
/**
* Creates a new NoiseHandshakeError.
* @param message - The error message.
* @param code - A machine-readable error code.
*/
constructor(message, code) {
super(message);
this.code = code;
this.code = code;
this.name = "NoiseHandshakeError";
}
}
/**
* Generates an X25519 key pair and returns both the private key and raw 32-byte public key.
* This encapsulates the Node.js crypto API complexity for cleaner usage throughout the code.
*/
function generateX25519KeyPair() {
const { publicKey, privateKey } = generateKeyPairSync("x25519");
// Export directly as SPKI DER and extract the last 32 bytes which contain the raw public key. We do this as an efficiency win rather than going through
// JWK encoding/decoding.
const spki = publicKey.export({ format: "der", type: "spki" });
const publicKeyRaw = spki.subarray(spki.length - 32);
return { privateKey, publicKeyRaw };
}
/**
* Creates an X25519 public key object from raw 32-byte key material.
* This handles the SPKI DER construction that Node.js requires for key import.
*/
function importX25519PublicKey(rawKey) {
// SPKI DER prefix for X25519 keys (OID 1.3.101.110). The raw key follows this prefix.
const spkiPrefix = Buffer.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00]);
const spkiDer = Buffer.concat([spkiPrefix, rawKey]);
return createPublicKey({ format: "der", key: spkiDer, type: "spki" });
}
function hkdf(chainingKey, ikm, numOutputs) {
const outputLength = 32 * numOutputs;
// The Noise Protocol's HKDF matches RFC 5869 with an empty info parameter. The chaining key serves as the salt.
const derivedKeyArray = hkdfSync(HASH_ALGO, ikm, chainingKey, EMPTY_BUFFER, outputLength);
// Convert ArrayBuffer to Buffer and split into separate 32-byte keys.
const derivedKey = Buffer.from(derivedKeyArray);
if (numOutputs === 2) {
return [derivedKey.subarray(0, 32), derivedKey.subarray(32, 64)];
}
else {
return [derivedKey.subarray(0, 32), derivedKey.subarray(32, 64), derivedKey.subarray(64, 96)];
}
}
/**
* 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.
*/
class CipherState {
log;
// Our encryption key.
k;
// The nonce counter for ChaCha20-Poly1305.
n;
// The nonce buffer.
nonce;
constructor(log) {
this.log = log;
// Initialize our class. We allocate the nonce buffer only once as a performance optimization and reuse it throughout our session.
this.k = null;
this.n = BigInt(0);
this.nonce = Buffer.alloc(12);
}
/**
* Initializes the cipher state with a new key, resetting the nonce counter to zero.
*/
InitializeKey(key) {
this.k = key;
this.n = BigInt(0);
this.log?.debug?.("CipherState: Key initialized, hasKey=" + (key !== null) + ".");
}
/**
* Checks whether this cipher state has an encryption key set.
*/
HasKey() {
return this.k !== null;
}
/**
* Updates the reusable nonce buffer with the current counter value in little-endian format at offset 4.
*/
updateNonce() {
// The first four bytes are always zeros per ChaCha20-Poly1305 specification.
this.nonce.fill(0, 0, 4);
this.nonce.writeBigUInt64LE(this.n, 4);
return this.nonce;
}
/**
* Encrypts plaintext with associated data using ChaCha20-Poly1305. Returns plaintext unchanged if no key is set.
*/
EncryptWithAd(ad, plaintext) {
// Without a key, return plaintext unchanged (happens during handshake before encryption is established).
if (!this.HasKey() || (this.k === null)) {
return plaintext;
}
if (plaintext.length + AUTH_TAG_LEN > NOISE_MAX_MESSAGE_LEN) {
throw new NoiseHandshakeError("Message too long", "MSG_TOO_LONG");
}
const cipher = createCipheriv(CIPHER_ALGO, this.k, this.updateNonce(), { authTagLength: AUTH_TAG_LEN });
// We specify plaintextLength here to ensure we're forward-compatible with Node 22 and beyond.
cipher.setAAD(ad, { plaintextLength: plaintext.length });
// For empty plaintext, we still need to generate the auth tag.
const ct = plaintext.length === 0 ? cipher.final() : Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
this.log?.debug?.("CipherState: Encrypted with nonce=" + this.n + ", plaintext=" + plaintext.length + " bytes, ciphertext=" + ct.length + " bytes.");
this.n++;
return Buffer.concat([ct, tag]);
}
/**
* Decrypts ciphertext with associated data using ChaCha20-Poly1305. Returns input unchanged if no key is set.
*/
DecryptWithAd(ad, data) {
// Without a key, return data unchanged (happens during handshake before encryption is established).
if (!this.HasKey() || (this.k === null)) {
return data;
}
// We reject packets that are shorter than our tag length.
if (data.length < AUTH_TAG_LEN) {
throw new NoiseHandshakeError("Ciphertext too short", "CT_TOO_SHORT");
}
// Split the input into ciphertext and authentication tag.
const ciphertext = data.subarray(0, data.length - AUTH_TAG_LEN);
const tag = data.subarray(data.length - AUTH_TAG_LEN);
const decipher = createDecipheriv(CIPHER_ALGO, this.k, this.updateNonce(), { authTagLength: AUTH_TAG_LEN });
decipher.setAAD(ad, { plaintextLength: ciphertext.length });
decipher.setAuthTag(tag);
try {
// For empty ciphertext, just verify the tag without calling update.
const pt = ciphertext.length === 0 ? decipher.final() : Buffer.concat([decipher.update(ciphertext), decipher.final()]);
this.log?.debug?.("CipherState: Decrypted with nonce=" + this.n + ", ciphertext=" + ciphertext.length + " bytes, plaintext=" + pt.length + " bytes.");
this.n++;
return pt;
}
catch (e) {
if (e instanceof Error) {
this.log?.error?.("CipherState: Decryption failed: " + e.message + ".");
}
throw new NoiseHandshakeError("Authentication failed", "AUTH_FAILED");
}
}
/**
* Rekeys the cipher state by encrypting zeros with the maximum nonce value, providing forward secrecy.
*/
Rekey() {
if (!this.HasKey() || (this.k === null)) {
return;
}
// Use the maximum possible nonce value (2^64 - 1) for the rekey operation.
const maxNonce = (BigInt(1) << BigInt(64)) - BigInt(1);
this.nonce.fill(0, 0, 4);
this.nonce.writeBigUInt64LE(maxNonce, 4);
// Encrypt 32 bytes of zeros to generate the new key material. Must be zeros for the rekey operation.
const zeros = Buffer.alloc(32);
const cipher = createCipheriv(CIPHER_ALGO, this.k, this.nonce, { authTagLength: AUTH_TAG_LEN });
cipher.setAAD(EMPTY_BUFFER, { plaintextLength: zeros.length });
const ct = Buffer.concat([cipher.update(zeros), cipher.final()]);
// The first 32 bytes of the ciphertext become our new key.
this.InitializeKey(ct.subarray(0, 32));
this.log?.debug?.("CipherState: Rekey operation completed successfully.");
}
}
/**
* SymmetricState manages the symmetric cryptography operations during the handshake.
* Implements the SymmetricState object as specified in Noise Protocol Framework §5.2.
*/
class SymmetricState {
log;
// The chaining key for key derivation.
ck = Buffer.alloc(0);
// The handshake hash.
h = Buffer.alloc(0);
// The cipher state for encryption/decryption.
cs;
constructor(log) {
this.log = log;
this.cs = new CipherState(log);
}
/**
* Initializes the symmetric state with the protocol name, setting up initial handshake hash and chaining key.
*/
InitializeSymmetric() {
// Use the cached protocol name hash for efficiency.
this.h = Buffer.from(PROTOCOL_NAME_HASH);
this.ck = Buffer.from(PROTOCOL_NAME_HASH);
// We initialize the cipher state with no key.
this.cs.InitializeKey(null);
this.log?.debug?.("SymmetricState: Initialized with protocol \"" + PROTOCOL_NAME + "\".");
}
/**
* Mixes data into the handshake hash to maintain a running hash of all handshake data for authentication.
*/
MixHash(data) {
this.h = createHash(HASH_ALGO).update(Buffer.concat([this.h, data])).digest();
this.log?.debug?.("SymmetricState: Mixed data into hash, new h=" + this.h.toString("hex") + ".");
}
/**
* Mixes input key material into the chaining key and derives a new encryption key.
*/
MixKey(ikm) {
const [ck, tempK] = hkdf(this.ck, ikm, 2);
this.ck = ck;
this.cs.InitializeKey(tempK);
this.log?.debug?.("SymmetricState: Mixed key material, new ck=" + ck.toString("hex") + ".");
}
/**
* Mixes input key material into both the chaining key and handshake hash (used for PSK operations).
*/
MixKeyAndHash(ikm) {
const [ck, tempH, tempK] = hkdf(this.ck, ikm, 3);
this.ck = ck;
this.MixHash(tempH);
this.cs.InitializeKey(tempK);
this.log?.debug?.("SymmetricState: Mixed key and hash with PSK material.");
}
/**
* Encrypts plaintext and mixes the ciphertext into the handshake hash.
*/
EncryptAndHash(plaintext) {
const c = this.cs.EncryptWithAd(this.h, plaintext);
this.MixHash(c);
return c;
}
/**
* Decrypts ciphertext and mixes it into the handshake hash.
*/
DecryptAndHash(ciphertext) {
const p = this.cs.DecryptWithAd(this.h, ciphertext);
// Mix the ciphertext into the handshake hash.
this.MixHash(ciphertext);
return p;
}
/**
* Splits the symmetric state into two cipher states for bidirectional communication at handshake completion.
*/
Split() {
// Derive both keys at once using HKDF, which is more efficient than separate calls. We get both 32-byte keys at once here.
const derivedKey = Buffer.from(hkdfSync(HASH_ALGO, EMPTY_BUFFER, this.ck, EMPTY_BUFFER, 64));
const c1 = new CipherState(this.log);
const c2 = new CipherState(this.log);
// Use subarray to create views without copying the underlying buffer.
c1.InitializeKey(derivedKey.subarray(0, 32));
c2.InitializeKey(derivedKey.subarray(32, 64));
this.log?.debug?.("SymmetricState: Split into two cipher states for transport encryption.");
return [c1, c2];
}
}
/**
* 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 class HandshakeState {
initiator;
psk;
log;
// Cipher for sending encrypted messages after handshake.
sendCipher;
// Cipher for receiving encrypted messages after handshake.
receiveCipher;
// Whether the handshake has completed successfully.
isComplete = false;
// The symmetric state managing the handshake.
ss;
// Our ephemeral key pair.
ephemeral;
// The remote party's public key.
remotePubKey;
// Current position in the handshake pattern.
patternIndex = 0;
/**
* 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, psk, log, prologue = EMPTY_BUFFER) {
this.initiator = initiator;
this.psk = psk;
this.log = log;
// Validate PSK length.
if (psk.length !== NOISE_PSK_LEN) {
throw new NoiseHandshakeError("PSK must be exactly " + NOISE_PSK_LEN + " bytes, got " + psk.length, "INVALID_PSK_LENGTH");
}
this.ss = new SymmetricState(log);
// Initialize the symmetric state to set up initial handshake hash and chaining key.
this.ss.InitializeSymmetric();
// Mix the prologue into the handshake hash before any messages to bind the handshake to pre-agreed context data.
this.ss.MixHash(prologue);
this.log?.debug?.("HandshakeState: Mixed prologue into initial hash: " + prologue.toString("hex") + ".");
this.log?.debug?.("HandshakeState: Initialized as " + (initiator ? "initiator" : "responder") + ".");
}
/**
* Gets the role of this party in the handshake.
*/
get role() {
return this.initiator ? "initiator" : "responder";
}
/**
* Checks if this party can send encrypted messages (handshake complete and send cipher available).
*/
get canSend() {
return this.sendCipher !== undefined && this.sendCipher.HasKey();
}
/**
* Checks if this party can receive encrypted messages (handshake complete and receive cipher available).
*/
get canReceive() {
return this.receiveCipher !== undefined && this.receiveCipher.HasKey();
}
/**
* Ensures the handshake is still in progress (not yet complete).
*/
ensureHandshakeInProgress() {
if (this.patternIndex >= NNPSK0_PATTERN.length) {
throw new NoiseHandshakeError("Handshake already complete", "HANDSHAKE_COMPLETE");
}
}
/**
* Ensures both ephemeral and remote public keys are available for DH operations.
*/
ensureKeysForDH() {
if (!this.ephemeral || !this.remotePubKey) {
throw new NoiseHandshakeError("Missing keys for Diffie-Hellman operation", "MISSING_KEYS");
}
}
/**
* Processes a single token during message writing.
*/
processWriteToken(token) {
switch (token) {
case "psk":
this.log?.debug?.("HandshakeState: Mixing PSK into handshake state.");
this.ss.MixKeyAndHash(this.psk);
return EMPTY_BUFFER;
case "e": {
// Generate a new ephemeral key pair and send the public key.
const { privateKey, publicKeyRaw } = generateX25519KeyPair();
this.ephemeral = { privateKey, publicKeyRaw };
// Mix the public key into the handshake state.
this.ss.MixHash(publicKeyRaw);
this.ss.MixKey(publicKeyRaw);
this.log?.debug?.("HandshakeState: Sending ephemeral public key: " + publicKeyRaw.toString("hex") + ".");
return publicKeyRaw;
}
case "ee": {
this.ensureKeysForDH();
if (!this.ephemeral || !this.remotePubKey) {
throw new NoiseHandshakeError("Keys not available after validation", "MISSING_KEYS");
}
// Compute the shared secret using X25519 and mix it into the handshake state.
const dh = diffieHellman({ privateKey: this.ephemeral.privateKey, publicKey: this.remotePubKey });
this.ss.MixKey(dh);
this.log?.debug?.("HandshakeState: Processed ephemeral-ephemeral DH exchange.");
return EMPTY_BUFFER;
}
default:
throw new NoiseHandshakeError("Unsupported token: " + token, "UNSUPPORTED_TOKEN");
}
}
/**
* Processes a single token during message reading.
*/
processReadToken(token, message, index) {
switch (token) {
case "psk":
this.log?.debug?.("HandshakeState: Mixing PSK into handshake state.");
this.ss.MixKeyAndHash(this.psk);
return 0;
case "e": {
// Verify we actually have 32 bytes.
if ((message.length - index) < DH_LEN) {
throw new NoiseHandshakeError("Truncated ephemeral key", "TRUNCATED_E");
}
// Read the remote ephemeral public key from the message.
const remoteKeyRaw = message.subarray(index, index + DH_LEN);
// Mix the remote public key into the handshake state.
this.ss.MixHash(remoteKeyRaw);
this.ss.MixKey(remoteKeyRaw);
// Import the raw key as a KeyObject for DH operations.
this.remotePubKey = importX25519PublicKey(remoteKeyRaw);
this.log?.debug?.("HandshakeState: Received ephemeral public key: " + remoteKeyRaw.toString("hex") + ".");
return DH_LEN;
}
case "ee": {
this.ensureKeysForDH();
if (!this.ephemeral || !this.remotePubKey) {
throw new NoiseHandshakeError("Keys not available after validation", "MISSING_KEYS");
}
// Compute the shared secret using X25519 and mix it into the handshake state.
const dh = diffieHellman({ privateKey: this.ephemeral.privateKey, publicKey: this.remotePubKey });
this.ss.MixKey(dh);
this.log?.debug?.("HandshakeState: Processed ephemeral-ephemeral DH exchange.");
return 0;
}
default:
throw new NoiseHandshakeError("Unsupported token: " + token, "UNSUPPORTED_TOKEN");
}
}
/**
* 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 = EMPTY_BUFFER) {
this.ensureHandshakeInProgress();
const pattern = NNPSK0_PATTERN[this.patternIndex++];
if (!pattern) {
throw new NoiseHandshakeError("No pattern available to process", "NO_PATTERN");
}
// Collect message parts to minimize concatenation operations.
const parts = [];
// Process each token in the pattern and collect non-empty outputs.
for (const token of pattern) {
const tokenOutput = this.processWriteToken(token);
if (tokenOutput.length > 0) {
parts.push(tokenOutput);
}
}
// Handle empty messages efficiently.
if ((payload === EMPTY_BUFFER) && (parts.length === 0)) {
return this.ss.EncryptAndHash(EMPTY_BUFFER);
}
// Encrypt and add the payload if present.
const encPayload = this.ss.EncryptAndHash(payload);
if (encPayload.length > 0) {
parts.push(encPayload);
}
// Optimize for common cases to avoid unnecessary concatenation.
const out = (parts.length === 0) ? EMPTY_BUFFER : ((parts.length === 1) ? parts[0] : Buffer.concat(parts));
this.log?.debug?.("HandshakeState: Wrote message with " + payload.length + " byte payload.");
// If this was the last message and we're the responder, split the state (responder splits after writing final message).
if ((this.patternIndex >= NNPSK0_PATTERN.length) && !this.initiator) {
const [c1, c2] = this.ss.Split();
this.receiveCipher = c1;
this.sendCipher = c2;
this.isComplete = true;
this.log?.debug?.("HandshakeState: Handshake complete (responder split on write).");
}
return out;
}
/**
* 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) {
this.ensureHandshakeInProgress();
const pattern = NNPSK0_PATTERN[this.patternIndex++];
if (!pattern) {
throw new NoiseHandshakeError("No pattern available to process", "NO_PATTERN");
}
let index = 0;
// Process each token in the pattern.
for (const token of pattern) {
const bytesConsumed = this.processReadToken(token, message, index);
index += bytesConsumed;
}
// Decrypt the payload from the remainder of the message.
const cipherPayload = message.subarray(index);
const payload = this.ss.DecryptAndHash(cipherPayload);
this.log?.debug?.("HandshakeState: Read message with " + payload.length + " byte payload.");
// If this was the last message and we're the initiator, split the state (initiator splits after reading final message).
if ((this.patternIndex >= NNPSK0_PATTERN.length) && this.initiator) {
const [c1, c2] = this.ss.Split();
this.sendCipher = c1;
this.receiveCipher = c2;
this.isComplete = true;
this.log?.debug?.("HandshakeState: Handshake complete (initiator split on read).");
}
return payload;
}
/**
* 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() {
// Clear sensitive key material from memory where possible.
if (this.ephemeral) {
this.ephemeral.publicKeyRaw.fill(0);
// Note: Cannot clear KeyObject internal memory in Node.js.
}
// Reset state to prevent reuse.
this.patternIndex = NNPSK0_PATTERN.length;
this.isComplete = false;
this.ephemeral = undefined;
this.remotePubKey = undefined;
this.sendCipher = undefined;
this.receiveCipher = undefined;
}
}
/**
* 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 function createHandshake(options) {
return new HandshakeState(options.role === "initiator", options.psk, options.logger, options.prologue);
}
/**
* 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 function createESPHomeHandshake(options) {
options.role ??= "initiator";
// ESPHome always uses "NoiseAPIInit" as the prologue prefix. Additional data can be appended if needed, but typically isn't used.
let prologue = Buffer.from(ESPHOME_NOISE_PROLOGUE, "utf8");
if (options.additionalPrologueData && (options.additionalPrologueData.length > 0)) {
// Concatenate the ESPHome prologue with any additional data.
prologue = Buffer.concat([prologue, options.additionalPrologueData]);
}
return new HandshakeState(options.role === "initiator", options.psk, options.logger, prologue);
}
//# sourceMappingURL=crypto-noise.js.map