melq
Version:
Quantum-secure chat network with ML-KEM-768 encryption and host-based architecture
1,452 lines (1,264 loc) ⢠62.2 kB
JavaScript
import WebSocket, { WebSocketServer } from 'ws';
import Fastify from 'fastify';
import crypto from 'crypto';
import { networkInterfaces } from 'os';
import readline from 'readline';
import { MLKEM } from '../crypto/mlkem.js';
import { AESCrypto } from '../crypto/aes.js';
import { NetworkDiscovery } from './discovery.js';
import { TunnelingService, parseConnectionCode } from './tunneling.js';
import chalk from 'chalk';
const NODE_MODES = {
HOST: 'host',
CLIENT: 'client'
};
export class UnifiedNode {
constructor() {
this.nodeId = this.generateNodeId();
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise Rejection:', reason);
this.safeLog(`Unhandled promise rejection: ${reason}`, chalk.red);
});
this.mode = null;
this.mlkem = new MLKEM();
this.aesCrypto = new AESCrypto();
this.keyPair = null;
// Common properties
this.peerKeys = new Map(); // nodeId -> shared secret
this.chats = new Map(); // chatId -> chat info
this.messageHandlers = new Map();
this.cliInterface = null;
// Host mode properties
this.server = null;
this.wss = null;
this.connectedNodes = new Map(); // nodeId -> node info
this.chatRooms = new Map(); // chatId -> room info
this.chatHistory = new Map(); // chatId -> messages[]
this.hostPort = null;
this.sessionPassword = null; // Password for protected sessions
this.authenticatedNodes = new Set(); // nodeIds that have been authenticated
// Client mode properties
this.coordinatorWs = null;
this.heartbeatInterval = null;
// Shutdown state
this.isShuttingDown = false;
// Discovery and tunneling
this.discovery = new NetworkDiscovery();
this.tunneling = new TunnelingService();
}
// Safe logging method that uses CLI interface if available
safeLog(message, color = null) {
if (this.cliInterface && this.cliInterface.log) {
this.cliInterface.log(message, color);
} else {
// Fallback to console.log when CLI interface is not available
const output = color ? color(message) : message;
console.log(output);
}
}
generateNodeId() {
return `node_${crypto.randomBytes(8).toString('hex')}`;
}
async initialize() {
console.log('Generating ML-KEM-768 keypair...');
this.keyPair = await this.mlkem.generateKeyPair();
console.log('ā Post-quantum cryptographic keys generated');
}
// HOST MODE METHODS
async startAsHost(port = 0, options = {}) {
const { exposeToInternet = false, tunnelMethod = 'auto', customDomain, password } = options;
await this.initialize();
this.mode = NODE_MODES.HOST;
this.sessionPassword = password || null;
// Find available port if default port is in use (multi-node support)
const requestedPort = port || 42045;
const availablePort = await this.findAvailablePort(requestedPort, requestedPort + 50);
if (!availablePort) {
throw new Error(`No available ports found in range ${requestedPort}-${requestedPort + 50}. Please try a different port range.`);
}
if (availablePort !== requestedPort) {
console.log(chalk.yellow(`š Port ${requestedPort} is busy, using port ${availablePort} instead`));
}
// Use the found available port
port = availablePort;
// Create Fastify server
this.server = Fastify({ logger: false });
// Setup WebSocket server
await this.server.register(async (fastify) => {
this.wss = new WebSocketServer({
server: fastify.server,
path: '/ws'
});
this.wss.on('connection', (ws) => {
this.handleHostConnection(ws);
});
});
// Add health endpoint
this.server.get('/health', async () => ({
status: 'healthy',
nodeId: this.nodeId,
nodes: this.connectedNodes.size,
chats: this.chatRooms.size,
mode: 'host'
}));
// Start server
const address = await this.server.listen({
port,
host: '0.0.0.0'
});
this.hostPort = this.server.server.address().port;
const localIp = this.getLocalIP();
// Add error handling for unintentional server crashes
this.server.server.on('error', (error) => {
this.safeLog(`šØ Server error detected: ${error.message}`, chalk.red);
if (this.gracefulCleanupCallback && !this.isShuttingDown) {
this.safeLog('š Triggering tunnel keepalive due to server error...', chalk.cyan);
this.gracefulCleanupCallback();
}
});
const localConnectionCode = `melq://${localIp}:${this.hostPort}`;
let internetConnectionCode = null;
let internetUrl = null;
console.log(`š MELQ server started on ${localIp}:${this.hostPort}`);
console.log(`š Local connection code: ${localConnectionCode}`);
// Start advertising this network for local discovery
try {
await this.discovery.startAdvertising({
nodeId: this.nodeId,
port: this.hostPort
});
console.log(chalk.gray('ā Local network discovery active'));
} catch (error) {
console.log(chalk.gray('Network discovery advertising failed (this is OK):'), error.message);
}
// Expose to internet if requested
if (exposeToInternet) {
try {
console.log(chalk.yellow(`š Setting up internet access with ${tunnelMethod} tunnel for port ${this.hostPort}...`));
const internetInfo = await this.tunneling.exposeToInternet(this.hostPort, {
preferredMethod: tunnelMethod,
customDomain
});
if (internetInfo) {
internetConnectionCode = internetInfo.connectionCode;
internetUrl = internetInfo.publicUrl;
console.log(chalk.green('\nš Network exposed to internet!'));
console.log(chalk.blue(`š” Internet connection code: ${internetConnectionCode}`));
if (internetInfo.requiresPortForwarding) {
console.log(chalk.yellow('ā ļø Port forwarding required on your router'));
}
}
} catch (error) {
console.log(chalk.yellow('ā ļø Failed to expose to internet:'), error.message);
console.log(chalk.gray('Network is still available locally'));
}
}
return {
port: this.hostPort,
ip: localIp,
localConnectionCode,
internetConnectionCode,
internetUrl,
hasInternet: !!internetConnectionCode
};
}
handleHostConnection(ws) {
this.safeLog('Node connected to hosted network');
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
await this.handleHostMessage(ws, message);
} catch (error) {
console.error('WebSocket message handling error:', error);
this.safeLog(`Message handling error: ${error.message}`, chalk.red);
// Try to send error response if possible
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'error',
message: 'Message processing failed'
}));
}
} catch (sendError) {
// Ignore send errors - connection might be broken
}
}
});
ws.on('close', () => {
const nodeId = this.getNodeIdBySocket(ws);
if (nodeId) {
this.connectedNodes.delete(nodeId);
this.peerKeys.delete(nodeId);
this.authenticatedNodes.delete(nodeId); // Clean up authentication state
this.safeLog(`Node ${nodeId} disconnected`);
// Notify CLI interface to update UI
if (this.cliInterface) {
this.cliInterface.updatePrompt();
// If in chat mode, refresh the display to update peer count in header
if (this.cliInterface.mode === 'chat') {
this.cliInterface.refreshChatDisplay();
}
}
}
});
}
async handleHostMessage(ws, message) {
switch (message.type) {
case 'password_challenge':
// Client is requesting password challenge
if (ws) {
if (this.sessionPassword) {
const response = {
type: 'password_required',
message: 'This session is password protected. Please enter the password.'
};
const encrypted = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
} else {
const response = { type: 'password_not_required' };
const encrypted = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
}
}
break;
case 'password_attempt':
// Client is attempting password authentication
if (ws) {
try {
await this.handlePasswordAttempt(ws, message);
} catch (error) {
console.error('Password attempt error:', error);
this.safeLog(`Password authentication error: ${error.message}`, chalk.red);
}
}
break;
case 'register':
this.safeLog(`šØ Received registration from ${message.nodeId}`, chalk.cyan);
if (ws) {
this.connectedNodes.set(message.nodeId, {
socket: ws,
publicKey: message.publicKey,
address: message.address,
timestamp: Date.now(),
authenticated: message.authenticated || false
});
// Check if we have a shared key for encryption
if (this.peerKeys.has(message.nodeId)) {
this.safeLog(`š Shared key exists for ${message.nodeId}`, chalk.green);
} else {
this.safeLog(`ā ļø No shared key for ${message.nodeId} - establishing key exchange`, chalk.yellow);
}
// Auto-authenticate nodes for non-password sessions or host auto-client
if (message.authenticated || !this.sessionPassword) {
this.authenticatedNodes.add(message.nodeId);
}
const response = { type: 'registered', nodeId: message.nodeId };
try {
ws.send(JSON.stringify(response));
this.safeLog(`ā
Registration confirmed for ${message.nodeId}`, chalk.green);
} catch (error) {
this.safeLog(`ā Failed to send registration to ${message.nodeId}: ${error.message}`, chalk.red);
}
} else {
this.safeLog(`ā ļø No WebSocket for registration from ${message.nodeId}`, chalk.yellow);
}
break;
case 'secure_message':
// Decrypt and handle encrypted message
try {
const decryptedMessage = this.decryptSecureMessage(message);
if (decryptedMessage) {
await this.handleHostMessage(ws, decryptedMessage);
}
} catch (error) {
console.error('Secure message handling error:', error);
this.safeLog(`Failed to handle secure message: ${error.message}`, chalk.red);
}
break;
case 'discover_nodes':
// Check authentication for password-protected sessions
if (!this.isNodeAuthenticated(message.nodeId)) {
if (ws) {
const denialMessage = {
type: 'access_denied',
message: 'Authentication required'
};
const encrypted = this.encryptMessageForNode(denialMessage, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
}
return;
}
const nodeList = Array.from(this.connectedNodes.entries())
.filter(([id]) => id !== message.nodeId)
.map(([id, node]) => ({
nodeId: id,
publicKey: node.publicKey,
address: node.address
}));
if (ws) {
const response = { type: 'node_list', nodes: nodeList };
const encryptedResponse = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encryptedResponse.data));
}
break;
case 'get_chats':
// Check authentication for password-protected sessions
if (!this.isNodeAuthenticated(message.nodeId)) {
if (ws) {
const denialMessage = {
type: 'access_denied',
message: 'Authentication required'
};
const encrypted = this.encryptMessageForNode(denialMessage, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
}
return;
}
const availableChats = Array.from(this.chatRooms.entries()).map(([chatId, chat]) => ({
chatId,
chatName: chat.name,
creator: chat.creator,
participants: chat.participants
}));
if (ws) {
const response = { type: 'chat_list', chats: availableChats };
const encrypted = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
} else {
// Internal call - handle directly
this.handleChatList(availableChats);
}
break;
case 'create_chat':
// Check authentication for password-protected sessions
if (!this.isNodeAuthenticated(message.nodeId)) {
if (ws) {
const denialMessage = {
type: 'access_denied',
message: 'Authentication required'
};
const encrypted = this.encryptMessageForNode(denialMessage, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
}
return;
}
const chatId = `chat_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
this.chatRooms.set(chatId, {
creator: message.nodeId,
name: message.chatName,
participants: [message.nodeId],
created: Date.now()
});
// Add to local chats
this.chats.set(chatId, {
id: chatId,
name: message.chatName,
participants: [message.nodeId]
});
if (ws) {
const response = { type: 'chat_created', chatId, chatName: message.chatName };
const encrypted = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
this.broadcastToAllNodes({
type: 'chat_available',
chatId,
chatName: message.chatName,
creator: message.nodeId
}, message.nodeId);
} else {
// Internal call - just refresh prompt
if (this.cliInterface) this.cliInterface.rl.prompt();
}
break;
case 'join_chat':
// Check authentication for password-protected sessions
if (!this.isNodeAuthenticated(message.nodeId)) {
if (ws) {
const denialMessage = {
type: 'access_denied',
message: 'Authentication required'
};
const encrypted = this.encryptMessageForNode(denialMessage, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
}
return;
}
const chat = this.chatRooms.get(message.chatId);
if (chat && !chat.participants.includes(message.nodeId)) {
chat.participants.push(message.nodeId);
// Send chat history to the new participant
this.sendChatHistoryToParticipant(message.nodeId, message.chatId);
// Notify other participants
this.broadcastToChatParticipants(message.chatId, {
type: 'user_joined',
chatId: message.chatId,
nodeId: message.nodeId
}, message.nodeId);
// Ensure the new participant can communicate with existing participants
this.ensureKeyExchangesForNewParticipant(message.chatId, message.nodeId);
}
break;
case 'send_chat_message':
// Check authentication for password-protected sessions
if (!this.isNodeAuthenticated(message.nodeId)) {
if (ws) {
const denialMessage = {
type: 'access_denied',
message: 'Authentication required'
};
const encrypted = this.encryptMessageForNode(denialMessage, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
}
return;
}
try {
await this.handleChatMessage(message);
} catch (error) {
console.error('Chat message handling error:', error);
this.safeLog(`Failed to handle chat message: ${error.message}`, chalk.red);
}
break;
case 'relay_message':
this.relayEncryptedMessage(message);
break;
case 'ping':
const pongResponse = { type: 'pong' };
const encryptedPong = this.encryptMessageForNode(pongResponse, message.fromNodeId);
ws.send(JSON.stringify(encryptedPong.data));
break;
default:
console.log(`Unknown message type: ${message.type}`, message);
}
}
async handlePasswordAttempt(ws, message) {
if (!this.sessionPassword) {
// No password required, mark as authenticated
if (message.nodeId) {
this.authenticatedNodes.add(message.nodeId);
}
const response = { type: 'password_accepted' };
const encrypted = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
} else {
// Check if password is encrypted or plaintext
let passwordToCheck = message.password;
if (message.encryptedPassword && message.ciphertext) {
// Decrypt the password using ML-KEM
try {
const sharedSecret = await this.mlkem.decapsulate(message.ciphertext, this.keyPair.privateKey);
const decryptionKey = this.aesCrypto.deriveKeyFromSharedSecret(sharedSecret);
passwordToCheck = this.aesCrypto.decrypt(message.encryptedPassword, decryptionKey);
} catch (error) {
passwordToCheck = null; // Force rejection
}
}
if (passwordToCheck === this.sessionPassword) {
// Correct password, mark as authenticated
if (message.nodeId) {
this.authenticatedNodes.add(message.nodeId);
}
const response = { type: 'password_accepted' };
const encrypted = this.encryptMessageForNode(response, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
} else {
const rejectionMessage = {
type: 'password_rejected',
message: 'Incorrect password. Access denied.'
};
const encrypted = this.encryptMessageForNode(rejectionMessage, message.nodeId);
ws.send(JSON.stringify(encrypted.data));
// Close connection after failed password attempt
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'Invalid password');
}
}, 1000);
}
}
}
// Check if node is authenticated for password-protected sessions
isNodeAuthenticated(nodeId) {
if (!this.sessionPassword) return true; // No password required
return this.authenticatedNodes.has(nodeId);
}
async findAvailablePort(startPort, endPort) {
const net = await import('net');
return new Promise((resolve) => {
const tryPort = (port) => {
if (port > endPort) {
resolve(null); // No available port found
return;
}
const server = net.createServer();
server.listen(port, () => {
const actualPort = server.address().port;
server.close(() => {
resolve(actualPort);
});
});
server.on('error', () => {
tryPort(port + 1);
});
};
tryPort(startPort);
});
}
getNodeIdBySocket(socket) {
for (const [nodeId, node] of this.connectedNodes.entries()) {
if (node.socket === socket) {
return nodeId;
}
}
return null;
}
async handleChatMessage(message) {
const chat = this.chatRooms.get(message.chatId);
if (!chat) {
this.safeLog(`Chat not found: ${message.chatId}`, chalk.red);
return;
}
// Create message data
const messageData = {
chatId: message.chatId,
text: message.messageText,
timestamp: message.timestamp,
fromNodeId: message.nodeId,
senderAlias: message.nodeId.slice(-8)
};
// Store message in chat history
if (!this.chatHistory.has(message.chatId)) {
this.chatHistory.set(message.chatId, []);
}
this.chatHistory.get(message.chatId).push(messageData);
// Ensure all participants have key exchanges with the sender
await this.ensureKeyExchangesForChat(message.chatId, message.nodeId);
// Send encrypted message to each participant
for (const participantId of chat.participants) {
if (participantId !== message.nodeId) { // Don't send to sender
const participantNode = this.connectedNodes.get(participantId);
if (participantNode && participantNode.socket.readyState === WebSocket.OPEN) {
// Check if we have a shared key with this participant
if (this.peerKeys.has(participantId)) {
const sharedSecret = this.peerKeys.get(participantId);
const encryptionKey = this.aesCrypto.deriveKeyFromSharedSecret(sharedSecret);
const encryptedData = this.aesCrypto.encrypt(JSON.stringify(messageData), encryptionKey);
// Send encrypted message directly to participant
const messageToSend = {
type: 'encrypted_message',
fromNodeId: message.nodeId,
chatId: message.chatId,
encryptedData: encryptedData,
timestamp: message.timestamp
};
const encrypted = this.encryptMessageForNode(messageToSend, participantId);
participantNode.socket.send(JSON.stringify(encrypted.data));
} else {
this.safeLog(`No shared key with participant ${participantId}, initiating key exchange...`, chalk.yellow);
// Try to initiate key exchange
await this.initiateKeyExchangeWithParticipant(participantId);
}
} else {
this.safeLog(`Participant ${participantId} not connected`, chalk.gray);
}
}
}
// If the sender is the host, handle the message locally
if (message.nodeId === this.nodeId && this.messageHandlers.has('message')) {
this.messageHandlers.get('message')(messageData);
}
}
async ensureKeyExchangesForChat(chatId, senderId) {
const chat = this.chatRooms.get(chatId);
if (!chat) return;
// Ensure sender has key exchanges with all other participants
for (const participantId of chat.participants) {
if (participantId !== senderId) {
const senderNode = this.connectedNodes.get(senderId);
const participantNode = this.connectedNodes.get(participantId);
if (senderNode && participantNode) {
// Check if sender has key exchange with participant (from sender's perspective)
if (!this.peerKeys.has(participantId) || this.peerKeys.get(participantId) === 'pending') {
this.safeLog(`Ensuring key exchange between ${senderId.slice(-8)} and ${participantId.slice(-8)}`, chalk.cyan);
await this.facilitateKeyExchange(senderId, participantId);
}
}
}
}
}
async facilitateKeyExchange(nodeId1, nodeId2) {
// Check if key exchange already exists
const keyId = `${nodeId1}-${nodeId2}`;
const reverseKeyId = `${nodeId2}-${nodeId1}`;
if (this.peerKeys.has(keyId) || this.peerKeys.has(reverseKeyId)) {
this.safeLog(`Key exchange already exists between ${nodeId1.slice(-8)} and ${nodeId2.slice(-8)}`, chalk.gray);
return;
}
// Get node info
const node1 = this.connectedNodes.get(nodeId1);
const node2 = this.connectedNodes.get(nodeId2);
if (node1 && node2) {
this.safeLog(`Facilitating key exchange between ${nodeId1.slice(-8)} and ${nodeId2.slice(-8)}`, chalk.cyan);
// Send each node the other's public key for key exchange
const message1 = {
type: 'peer_info',
nodeId: nodeId2,
publicKey: node2.publicKey,
address: node2.address
};
const encrypted1 = this.encryptMessageForNode(message1, nodeId1);
node1.socket.send(JSON.stringify(encrypted1.data));
const message2 = {
type: 'peer_info',
nodeId: nodeId1,
publicKey: node1.publicKey,
address: node1.address
};
const encrypted2 = this.encryptMessageForNode(message2, nodeId2);
node2.socket.send(JSON.stringify(encrypted2.data));
// Mark that we initiated this exchange to prevent duplicates
this.peerKeys.set(keyId, 'pending');
}
}
async initiateKeyExchangeWithParticipant(participantId) {
const participantNode = this.connectedNodes.get(participantId);
if (participantNode && !this.peerKeys.has(participantId)) {
try {
await this.initiateKeyExchange({
nodeId: participantId,
publicKey: participantNode.publicKey,
address: participantNode.address
});
} catch (error) {
this.safeLog(`Key exchange failed with ${participantId}: ${error.message}`, chalk.red);
}
}
}
sendChatHistoryToParticipant(participantId, chatId) {
const participantNode = this.connectedNodes.get(participantId);
const chatHistory = this.chatHistory.get(chatId) || [];
if (participantNode && participantNode.socket.readyState === WebSocket.OPEN) {
const message = {
type: 'chat_history',
chatId: chatId,
messages: chatHistory
};
const encrypted = this.encryptMessageForNode(message, participantId);
participantNode.socket.send(JSON.stringify(encrypted.data));
}
}
async ensureKeyExchangesForNewParticipant(chatId, newParticipantId) {
const chat = this.chatRooms.get(chatId);
if (!chat) return;
// Set up key exchanges between new participant and all existing participants
for (const existingParticipantId of chat.participants) {
if (existingParticipantId !== newParticipantId) {
await this.facilitateKeyExchange(newParticipantId, existingParticipantId);
}
}
}
// CLIENT MODE METHODS
async joinNetwork(connectionCode, isHostAutoClient = false) {
await this.initialize();
this.mode = NODE_MODES.CLIENT;
this.isHostAutoClient = isHostAutoClient;
let coordinatorUrl;
try {
coordinatorUrl = parseConnectionCode(connectionCode);
} catch (error) {
throw new Error(`Invalid connection code: ${error.message}`);
}
return new Promise((resolve, reject) => {
// Add connection timeout to prevent hanging on URLs
const connectionTimeout = setTimeout(() => {
if (this.coordinatorWs && this.coordinatorWs.readyState === WebSocket.CONNECTING) {
this.coordinatorWs.terminate();
reject(new Error(`Connection timeout: Failed to connect to ${coordinatorUrl} within 15 seconds. The host may not be running or the tunnel may have issues.`));
}
}, 15000);
// Add headers for tunnel service compatibility
const wsOptions = {
headers: {
'User-Agent': 'MELQ-P2P-Client/1.0.0'
}
};
// Handle ngrok-free.app warning bypass
if (coordinatorUrl.includes('.ngrok-free.app')) {
wsOptions.headers['ngrok-skip-browser-warning'] = 'true';
}
// Handle localtunnel specific headers
if (coordinatorUrl.includes('.loca.lt')) {
wsOptions.headers['Bypass-Tunnel-Reminder'] = 'true';
wsOptions.headers['Accept'] = 'application/json';
}
this.coordinatorWs = new WebSocket(coordinatorUrl, wsOptions);
this.coordinatorWs.on('open', async () => {
clearTimeout(connectionTimeout);
console.log(`š” Joined network: ${coordinatorUrl}`);
this.safeLog(`š Client WebSocket connected to ${coordinatorUrl}, readyState: ${this.coordinatorWs.readyState}`, chalk.blue);
this.startHeartbeat();
try {
// Skip password check for host auto-client
if (!this.isHostAutoClient) {
// Password check will handle registration internally
await this.checkPasswordRequirement();
this.safeLog('ā
Authentication and registration completed', chalk.green);
} else {
// For host auto-client, do direct registration
this.safeLog('ā
Host auto-client connected', chalk.gray);
await this.register();
this.safeLog('ā
Registration completed', chalk.green);
}
// Now immediately discover peers and get chats
await this.discoverNodes();
await this.getChats();
} catch (error) {
// Auto-discovery failed silently
}
resolve();
});
this.coordinatorWs.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
await this.handleClientMessage(message);
} catch (error) {
this.safeLog(`ā Failed to parse WebSocket message: ${error.message}`, chalk.red);
console.error('Raw data:', data.toString());
}
});
this.coordinatorWs.on('close', (code, reason) => {
this.handleHostDisconnection(code, reason);
});
this.coordinatorWs.on('error', (error) => {
clearTimeout(connectionTimeout);
console.error('Connection error:', error);
console.error(`Attempted connection to: ${coordinatorUrl}`);
// Provide more helpful error messages
if (error.message.includes('400')) {
console.error('\\nš HTTP 400 Error - This usually means:');
console.error('⢠The host is not running MELQ (most likely)');
console.error('⢠The ngrok tunnel points to wrong port');
console.error('⢠The host is running something else on that port');
console.error('⢠WebSocket upgrade failed');
}
if (coordinatorUrl.includes('.ngrok-free.app')) {
console.error('\\nš” For ngrok-free.app, ask the host to:');
console.error('1. Confirm they ran: melq --host --internet');
console.error('2. Share the exact URL shown by MELQ');
console.error('3. Ensure ngrok is forwarding to the MELQ port');
}
reject(error);
});
});
}
async handleClientMessage(message) {
switch (message.type) {
case 'password_required':
// Server requires password authentication
this.handlePasswordChallenge(message.message);
break;
case 'password_not_required':
// Server doesn't require password, proceed with registration
try {
await this.attemptRegistration(true);
// Registration completed successfully
if (this.passwordAndRegistrationResolver) {
clearTimeout(this.passwordTimeout);
this.passwordAndRegistrationResolver();
this.passwordAndRegistrationResolver = null;
}
} catch (error) {
this.safeLog(`Registration failed: ${error.message}`, chalk.red);
// Registration failed
if (this.passwordAndRegistrationResolver) {
clearTimeout(this.passwordTimeout);
this.passwordAndRegistrationResolver = null;
}
}
break;
case 'password_accepted':
// Password was correct, proceed with registration
try {
await this.attemptRegistration(true);
// Registration completed successfully
if (this.passwordAndRegistrationResolver) {
clearTimeout(this.passwordTimeout);
this.passwordAndRegistrationResolver();
this.passwordAndRegistrationResolver = null;
}
} catch (error) {
this.safeLog(`Registration failed: ${error.message}`, chalk.red);
// Registration failed
if (this.passwordAndRegistrationResolver) {
clearTimeout(this.passwordTimeout);
this.passwordAndRegistrationResolver = null;
}
}
break;
case 'password_rejected':
// Password was incorrect
this.handlePasswordRejection(message.message);
break;
case 'registered':
this.safeLog(`šØ Received registration confirmation`, chalk.cyan);
this.safeLog(`Registered in network as ${message.nodeId}`, chalk.green);
// Resolve registration promise if waiting
if (this.registrationResolver) {
this.safeLog(`ā
Registration completed successfully`, chalk.green);
clearTimeout(this.registrationTimeout);
this.registrationResolver();
this.registrationResolver = null;
} else {
this.safeLog(`ā ļø No registration resolver waiting`, chalk.yellow);
}
if (this.cliInterface) this.cliInterface.rl.prompt();
break;
case 'node_list':
// Resolve discovery promise if waiting
if (this.discoveryResolver) {
clearTimeout(this.discoveryTimeout);
this.discoveryResolver();
this.discoveryResolver = null;
}
this.handleNodeList(message.nodes);
break;
case 'chat_created':
this.safeLog(`Chat created: ${message.chatName}`, chalk.yellow);
this.chats.set(message.chatId, {
id: message.chatId,
name: message.chatName,
participants: [this.nodeId]
});
if (this.cliInterface) this.cliInterface.rl.prompt();
break;
case 'chat_available':
this.safeLog(`New chat available: ${message.chatName}`, chalk.yellow);
this.chats.set(message.chatId, {
id: message.chatId,
name: message.chatName,
participants: [message.creator]
});
if (this.cliInterface) this.cliInterface.rl.prompt();
break;
case 'user_joined':
const chatName = this.chats.get(message.chatId)?.name || 'Unknown Chat';
this.safeLog(`${message.nodeId.slice(-8)} joined chat: ${chatName}`, chalk.green);
if (this.cliInterface) this.cliInterface.rl.prompt();
break;
case 'encrypted_message':
this.handleEncryptedMessage(message);
break;
case 'key_exchange_request':
this.handleKeyExchangeRequest(message).catch(console.error);
break;
case 'key_exchange_response':
this.handleKeyExchangeResponse(message);
break;
case 'chat_list':
// Resolve chat list promise if waiting
if (this.chatListResolver) {
clearTimeout(this.chatListTimeout);
this.chatListResolver();
this.chatListResolver = null;
}
this.handleChatList(message.chats);
break;
case 'peer_info':
// Received peer info for key exchange
this.handlePeerInfo(message);
break;
case 'chat_history':
// Received chat history when joining a chat
this.handleChatHistory(message);
break;
case 'access_denied':
// Server denied access due to lack of authentication
this.safeLog(`ā Access denied: ${message.message}`, chalk.red);
break;
case 'secure_message':
// Decrypt and handle encrypted message
this.safeLog(`š Attempting to decrypt secure message from ${message.fromNodeId}`, chalk.yellow);
const decryptedMessage = this.decryptSecureMessage(message);
if (decryptedMessage) {
this.safeLog(`ā
Successfully decrypted message: ${decryptedMessage.type}`, chalk.green);
this.handleClientMessage(decryptedMessage);
} else {
this.safeLog(`ā Failed to decrypt secure message`, chalk.red);
}
break;
case 'pong':
// Heartbeat response - no action needed
break;
default:
console.log(`Unknown message type: ${message.type}`, message);
}
}
// Secure send method for client-to-host communication
async sendSecure(message, targetNodeId = null) {
if (this.mode === NODE_MODES.HOST) {
// In host mode, handle messages directly
await this.handleHostMessage(null, message);
} else if (this.coordinatorWs && this.coordinatorWs.readyState === WebSocket.OPEN) {
// In client mode, encrypt message if possible
const encryptedMessage = this.encryptMessageForNode(message, targetNodeId || 'host');
this.coordinatorWs.send(JSON.stringify(encryptedMessage.data));
}
}
// Send message to specific client (host mode only)
sendToClient(message, targetNodeId) {
if (this.mode !== NODE_MODES.HOST) return;
const targetNode = this.connectedNodes.get(targetNodeId);
if (targetNode && targetNode.socket.readyState === WebSocket.OPEN) {
const encryptedMessage = this.encryptMessageForNode(message, targetNodeId);
targetNode.socket.send(JSON.stringify(encryptedMessage.data));
}
}
// COMMON METHODS (used by both host and client modes)
async send(message) {
if (this.mode === NODE_MODES.HOST) {
// In host mode, handle messages directly
await this.handleHostMessage(null, message);
} else if (this.coordinatorWs && this.coordinatorWs.readyState === WebSocket.OPEN) {
this.coordinatorWs.send(JSON.stringify(message));
}
}
checkPasswordRequirement() {
return new Promise((resolve, reject) => {
// Set up a one-time listener that waits for registration completion
this.passwordAndRegistrationResolver = resolve;
this.passwordTimeout = setTimeout(() => {
this.passwordAndRegistrationResolver = null;
reject(new Error('Password check and registration timeout'));
}, 15000); // Longer timeout for both password and registration
this.send({
type: 'password_challenge',
nodeId: this.nodeId
});
});
}
handlePasswordChallenge(message) {
if (this.cliInterface) {
// Use CLI interface for password input
this.cliInterface.promptForPassword(message, (password) => {
this.submitPassword(password);
});
} else {
// Fallback to console input
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question(`${message}\nEnter password: `, (password) => {
rl.close();
this.submitPassword(password);
});
}
}
// Encrypt password using host's public key (asymmetric encryption for initial auth)
async encryptPasswordForHost(password, hostPublicKey) {
try {
// Use ML-KEM to encrypt the password with host's public key
const { ciphertext } = await this.mlkem.encapsulate(hostPublicKey);
// Use the shared secret to encrypt the password
const tempSecret = Buffer.from(ciphertext).toString('base64');
const encryptionKey = this.aesCrypto.deriveKeyFromSharedSecret(tempSecret);
const encryptedPassword = this.aesCrypto.encrypt(password, encryptionKey);
return {
ciphertext: ciphertext,
encryptedPassword: encryptedPassword
};
} catch (error) {
this.safeLog(`Password encryption failed: ${error.message}`, chalk.red);
// Fallback to plaintext (not ideal but maintains functionality)
return { password: password };
}
}
async submitPassword(password) {
try {
// Try to encrypt password using ML-KEM with host's public key
const hostNode = this.connectedNodes.get('host') || Array.from(this.connectedNodes.values())[0];
if (hostNode && hostNode.publicKey) {
const encryptedPasswordData = await this.encryptPasswordForHost(password, hostNode.publicKey);
if (encryptedPasswordData.ciphertext) {
// Successfully encrypted with ML-KEM
this.send({
type: 'password_attempt',
nodeId: this.nodeId,
encryptedPassword: encryptedPasswordData.encryptedPassword,
ciphertext: encryptedPasswordData.ciphertext
});
return;
}
}
// Fallback to plaintext (will be encrypted at transport layer if key exists)
this.send({
type: 'password_attempt',
nodeId: this.nodeId,
password: password
});
} catch (error) {
this.safeLog(`Password encryption failed: ${error.message}`, chalk.red);
// Fallback to plaintext
this.send({
type: 'password_attempt',
nodeId: this.nodeId,
password: password
});
}
}
attemptRegistration(authenticated = false) {
// Resolve password challenge if waiting
if (this.passwordResolver) {
clearTimeout(this.passwordTimeout);
this.passwordResolver();
this.passwordResolver = null;
}
// Now proceed with registration
return new Promise((resolve, reject) => {
// Set up a one-time listener for registration confirmation
this.registrationResolver = resolve;
this.registrationTimeout = setTimeout(() => {
this.registrationResolver = null;
reject(new Error('Registration timeout'));
}, 10000);
this.safeLog(`š Sending registration request...`, chalk.yellow);
this.send({
type: 'register',
nodeId: this.nodeId,
publicKey: this.keyPair.publicKey,
address: `direct://${this.nodeId}`,
authenticated: authenticated || this.isHostAutoClient
});
});
}
handlePasswordRejection(message) {
this.safeLog(`ā ${message}`, chalk.red);
// Resolve password challenge with error
if (this.passwordAndRegistrationResolver) {
clearTimeout(this.passwordTimeout);
this.passwordAndRegistrationResolver = null;
throw new Error('Password authentication failed');
}
}
// List of message types that should NOT be encrypted (handshake/auth messages)
getUnencryptedMessageTypes() {
return new Set([
'register',
'registered',
'password_challenge',
'password_attempt',
'password_required',
'password_not_required',
'password_accepted',
'password_rejected',
'key_exchange_request',
'key_exchange_response',
'peer_info',
'ping',
'pong',
'access_denied'
]);
}
// Encrypt any message for transmission to a specific node
encryptMessageForNode(message, targetNodeId) {
// Don't encrypt handshake/auth messages or if no shared key exists
if (!this.peerKeys.has(targetNodeId) || this.getUnencryptedMessageTypes().has(message.type)) {
return { encrypted: false, data: message };
}
const sharedSecret = this.peerKeys.get(targetNodeId);
const encryptionKey = this.aesCrypto.deriveKeyFromSharedSecret(sharedSecret);
const encryptedData = this.aesCrypto.encrypt(JSON.stringify(message), encryptionKey);
return {
encrypted: true,
data: {
type: 'secure_message',
fromNodeId: this.nodeId,
encryptedData: encryptedData
}
};
}
// Decrypt a received secure message
decryptSecureMessage(message) {
if (!this.peerKeys.has(message.fromNodeId)) {
this.safeLog(`š No shared key found for node: ${message.fromNodeId}`, chalk.red);
this.safeLog(`Available keys: ${Array.from(this.peerKeys.keys()).join(', ')}`, chalk.gray);
return null;
}
try {
const sharedSecret = this.peerKeys.get(message.fromNodeId);
this.safeLog(`š Using shared key for decryption from ${message.fromNodeId}`, chalk.gray);
const decryptionKey = this.aesCrypto.deriveKeyFromSharedSecret(sharedSecret);
const decryptedText = this.aesCrypto.decrypt(message.encryptedData, decryptionKey);
return JSON.parse(decryptedText);
} catch (error) {
this.safeLog(`Failed to decrypt message from ${message.fromNodeId.slice(-8)}: ${error.message}`, chalk.red);
return null;
}
}
register() {
return this.attemptRegistration();
}
async discoverNodes() {
return new Promise((resolve, reject) => {
// Set up a one-time listener for node list response
this.discoveryResolver = resolve;
this.discoveryTimeout = setTimeout(() => {
this.discoveryResolver = null;
reject(new Error('Node discovery timeout'));
}, 5000);
this.send({
type: 'discover_nodes',
nodeId: this.nodeId
});
});
}
createChat(chatName) {
this.send({
type: 'create_chat',
nodeId: this.nodeId,
chatName
});
}
getChats() {
return new Promise((resolve, reject) => {
// Set up a one-time listener for chat list response
this.chatListResolver = resolve;
this.chatListTimeout = setTimeout(() => {
this.chatListResolver = null;
reject(new Error('Chat list timeout'));
}, 5000);
this.send({
type: 'get_chats',
nodeId: this.nodeId
});
});
}
joinChat(chatId) {
this.send({
type: 'join_chat',
nodeId: this.nodeId,
chatId: chatId
});
}
sendChatMessage(chatId, messageText) {
this.send({
type: 'send_chat_message',
nodeId: this.nodeId,
chatId: chatId,
messageText: messageText,
timestamp: Date.now()
});
}
// UTILITY METHODS
getLocalIP() {
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) {
return net.address;
}
}
}
return '127.0.0.1';
}
getNodeIdBySocket(socket) {
for (const [nodeId, node] of this.connectedNodes.entries()) {
if (node.socket === socket) return nodeId;
}
return null;
}
broadcastToAllNodes(message, excludeNodeId = null) {
this.connectedNodes.forEach((node, nodeId) => {
if (nodeId !== excludeNodeId && node.socket.readyState === WebSocket.OPEN) {
const encrypted = this.encryptMessageForNode(message, nodeId);
node.socket.send(JSON.stringify(encrypted.data));
}
});
}
broadcastToChatParticipants(chatId, message, excludeNodeId = null) {
const chat = this.chatRooms.get(chatId);
if (!chat) return;
chat.participants.forEach(nodeId => {
if (nodeId !== excludeNodeId) {
const node = this.connectedNodes.get(nodeId);
if (node && node.socket.readyState === WebSocket.OPEN) {
const encrypted = this.encryptMessageForNode(message, nodeId);
node.socket.send(JSON.stringify(encrypted.data));
}
}
});
}
relayEncryptedMessage(message) {
if (this.mode === NODE_MODES.HOST) {
const targetNode = this.connectedNodes.get(message.targetNodeId);
if (targetNode && targetNode.socket.readyState === WebSocket.OPEN) {
if (message.messageType) {
// Key exchange or other special messages
const messageToSend = {
type: message.messageType,
fromNodeId: message.fromNodeId,
ciphertext: message.ciphertext,
acknowledged: message.acknowledged
};
const encrypted = this.encryptMessageForNode(messageToSend, message.targetNodeId);
targetNode.socket.send(JSON.stringify(encrypted.data));
} else {
// Regular encrypted message
const messageToSend = {
type: 'encrypted_message',
fromNodeId: message.fromNodeId,
chatId: message.chatId,
encryptedData: message.encryptedData,
timestamp: Date.now()
};
const encrypted = this.encryptMessageForNode(messageToSend, message.targetNodeId);
targetNode.socket.send(JSON.stringify(encrypted.data));
}
}
} else {
// In client mode, send to coordinator for relay
this.send(message);
}
}
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.coordinatorWs && this.coordinatorWs.readyState === WebSocket.OPEN) {
this.send({ type: 'ping', nodeId: this.nodeId });
}
}, 30000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
async discoverLocalNetworks() {
return await this.discovery.discoverNetworks(5000);
}
async disconnect() {
this.safeLog('š Shutting down node...', chalk.yellow);
this.isShuttingDown = true;
this.stopHeartbeat();
if (this.discovery) {
try {
this.discovery.stop();
this.safeLog('ā Network discovery stopped', chalk.gray);
} catch (error) {
this.safeLog(`Warning: Discovery stop failed: ${error.message}`, chalk.yellow);
}
}
if (this.tunneling) {
try {
this.tunneling.cleanup();
this.safeLog('ā Tunneling service cleaned up', chalk.gray);
} catch (error) {
this.safeLog(`Warning: Tunneling cleanup failed: ${error.message}`, chalk.yellow);
}
}
await this.disconnectServerOnly(false);
}
// Trigger graceful shutdown with tunnel keepalive (for unintentional disconnects)
async triggerGracefulKeepalive() {
this.safeLog('š Triggering graceful tunnel keepalive...', chalk.cyan);
if (this.graceful