SECURITY: Eliminate complex fallback storage, enforce environment variables
Core changes: - Remove file/database fallback storage complexity - Enforce JWT_SECRET and DATABASE_KEY as environment variables only - Auto-generate keys on first startup with clear user guidance - Eliminate circular dependencies and storage layer abstractions Security improvements: - Single source of truth for secrets (environment variables) - No persistent storage of secrets in files or database - Clear deployment guidance for production environments - Simplified attack surface by removing storage complexity WebSocket authentication: - Implement JWT authentication for WebSocket handshake - Add connection limits and user tracking - Update frontend to pass JWT tokens in WebSocket URLs - Configure Nginx for authenticated WebSocket proxy Additional fixes: - Replace CORS wildcard with specific origins - Remove password logging security vulnerability - Streamline encryption architecture following Linus principles
This commit is contained in:
@@ -72,25 +72,37 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy for authenticated terminal connections
|
||||
location /ssh/websocket/ {
|
||||
# Pass to WebSocket server with authentication support
|
||||
proxy_pass http://127.0.0.1:8082/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket upgrade headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Pass client information for authentication logging
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Important: Pass query parameters (contains JWT token)
|
||||
proxy_pass_request_args on;
|
||||
|
||||
# WebSocket timeouts (longer for terminal sessions)
|
||||
proxy_read_timeout 86400s; # 24 hours
|
||||
proxy_send_timeout 86400s; # 24 hours
|
||||
proxy_connect_timeout 10s; # Quick auth check
|
||||
|
||||
# Disable buffering for real-time terminal
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Handle connection errors gracefully
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
|
||||
@@ -20,7 +20,16 @@ import { UserDataImport } from "../utils/user-data-import.js";
|
||||
const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: "*",
|
||||
// SECURITY: Specific origins only - no wildcard for production safety
|
||||
origin: process.env.ALLOWED_ORIGINS ?
|
||||
process.env.ALLOWED_ORIGINS.split(',').map(origin => origin.trim()) :
|
||||
[
|
||||
"http://localhost:3000", // Development React
|
||||
"http://localhost:5173", // Development Vite
|
||||
"http://127.0.0.1:3000", // Local development
|
||||
"http://127.0.0.1:5173", // Local development
|
||||
],
|
||||
credentials: true, // Enable credentials for secure cookies/auth
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
|
||||
@@ -1,34 +1,195 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
|
||||
const wss = new WebSocketServer({ port: 8082 });
|
||||
// Get auth instances
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
sshLogger.success("SSH Terminal WebSocket server started", {
|
||||
operation: "server_start",
|
||||
// Track user connections for rate limiting
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 8082,
|
||||
// WebSocket authentication during handshake
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "missing_token",
|
||||
origin: info.origin,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "invalid_token",
|
||||
origin: info.origin,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for TOTP pending (should not allow terminal access during TOTP)
|
||||
if (payload.pendingTOTP) {
|
||||
sshLogger.warn("WebSocket connection rejected: TOTP verification pending", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check connection limits per user (max 3 concurrent connections)
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "connection_limit",
|
||||
userId: payload.userId,
|
||||
currentConnections: existingConnections.size,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attach user info to request object
|
||||
(info.req as any).userId = payload.userId;
|
||||
(info.req as any).userPayload = payload;
|
||||
|
||||
sshLogger.info("WebSocket connection authenticated", {
|
||||
operation: "websocket_auth_success",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
|
||||
operation: "server_start",
|
||||
port: 8082,
|
||||
features: ["JWT_auth", "connection_limits", "data_access_control"]
|
||||
});
|
||||
|
||||
wss.on("connection", (ws: WebSocket, req) => {
|
||||
// Extract authenticated user info from request
|
||||
const userId = (req as any).userId;
|
||||
const userPayload = (req as any).userPayload;
|
||||
|
||||
if (!userId) {
|
||||
sshLogger.error("WebSocket connection without authentication - should not happen", {
|
||||
operation: "websocket_security_violation",
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data access permissions
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||
operation: "websocket_data_locked",
|
||||
userId,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data locked - re-authenticate with password",
|
||||
code: "DATA_LOCKED"
|
||||
}));
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track user connections for limits
|
||||
if (!userConnections.has(userId)) {
|
||||
userConnections.set(userId, new Set());
|
||||
}
|
||||
const userWs = userConnections.get(userId)!;
|
||||
userWs.add(ws);
|
||||
|
||||
sshLogger.info("WebSocket connection established", {
|
||||
operation: "websocket_connection_established",
|
||||
userId,
|
||||
userConnections: userWs.size,
|
||||
ip: req.socket.remoteAddress
|
||||
});
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
ws.on("close", () => {
|
||||
// Clean up user connection tracking
|
||||
const userWs = userConnections.get(userId);
|
||||
if (userWs) {
|
||||
userWs.delete(ws);
|
||||
if (userWs.size === 0) {
|
||||
userConnections.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("WebSocket connection closed", {
|
||||
operation: "websocket_connection_closed",
|
||||
userId,
|
||||
remainingConnections: userWs?.size || 0
|
||||
});
|
||||
|
||||
cleanupSSH();
|
||||
});
|
||||
|
||||
ws.on("message", (msg: RawData) => {
|
||||
// Verify user still has data access before processing any messages
|
||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!currentDataKey) {
|
||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||
operation: "websocket_message_rejected",
|
||||
userId,
|
||||
reason: "data_access_expired"
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
message: "Data access expired - please re-authenticate",
|
||||
code: "DATA_EXPIRED"
|
||||
}));
|
||||
ws.close(1008, "Data access expired");
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
} catch (e) {
|
||||
sshLogger.error("Invalid JSON received", e, {
|
||||
operation: "websocket_message",
|
||||
operation: "websocket_message_invalid_json",
|
||||
userId,
|
||||
messageLength: msg.toString().length,
|
||||
});
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||
@@ -39,9 +200,14 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
// Ensure userId is attached to hostConfig for secure credential resolution
|
||||
if (data.hostConfig) {
|
||||
data.hostConfig.userId = userId;
|
||||
}
|
||||
handleConnectToHost(data).catch((error) => {
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
hostId: data.hostConfig?.id,
|
||||
ip: data.hostConfig?.ip,
|
||||
});
|
||||
@@ -82,7 +248,8 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message",
|
||||
operation: "websocket_message_unknown_type",
|
||||
userId,
|
||||
messageType: type,
|
||||
});
|
||||
}
|
||||
@@ -187,15 +354,15 @@ wss.on("connection", (ws: WebSocket) => {
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
if (password) {
|
||||
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
|
||||
operation: "terminal_ssh_password",
|
||||
});
|
||||
} else {
|
||||
sshLogger.debug(`No password provided`, {
|
||||
operation: "terminal_ssh_password",
|
||||
});
|
||||
}
|
||||
// SECURITY: Never log password information - removed password preview logging
|
||||
sshLogger.debug(`SSH authentication setup`, {
|
||||
operation: "terminal_ssh_auth_setup",
|
||||
userId,
|
||||
hostId: id,
|
||||
authType,
|
||||
hasPassword: !!password,
|
||||
hasCredentialId: !!credentialId,
|
||||
});
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { settings } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
@@ -20,11 +15,6 @@ class SystemCrypto {
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
|
||||
// Storage path configuration
|
||||
private static readonly JWT_SECRET_FILE = path.join(process.cwd(), '.termix', 'jwt.key');
|
||||
private static readonly JWT_SECRET_DB_KEY = 'system_jwt_secret';
|
||||
private static readonly DATABASE_KEY_FILE = path.join(process.cwd(), '.termix', 'db.key');
|
||||
private static readonly DATABASE_KEY_DB_KEY = 'system_database_key';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -36,7 +26,7 @@ class SystemCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize JWT secret - open source friendly way
|
||||
* Initialize JWT secret - environment variable only
|
||||
*/
|
||||
async initializeJWTSecret(): Promise<void> {
|
||||
try {
|
||||
@@ -44,7 +34,7 @@ class SystemCrypto {
|
||||
operation: "jwt_init",
|
||||
});
|
||||
|
||||
// 1. Environment variable priority (production best practice)
|
||||
// Check environment variable
|
||||
const envSecret = process.env.JWT_SECRET;
|
||||
if (envSecret && envSecret.length >= 64) {
|
||||
this.jwtSecret = envSecret;
|
||||
@@ -55,30 +45,8 @@ class SystemCrypto {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check filesystem storage
|
||||
const fileSecret = await this.loadSecretFromFile();
|
||||
if (fileSecret) {
|
||||
this.jwtSecret = fileSecret;
|
||||
databaseLogger.info("✅ Loaded JWT secret from file", {
|
||||
operation: "jwt_file_loaded",
|
||||
source: "file"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Check database storage
|
||||
const dbSecret = await this.loadSecretFromDB();
|
||||
if (dbSecret) {
|
||||
this.jwtSecret = dbSecret;
|
||||
databaseLogger.info("✅ Loaded JWT secret from database", {
|
||||
operation: "jwt_db_loaded",
|
||||
source: "database"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Generate new key and persist
|
||||
await this.generateAndStoreSecret();
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideUser();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
@@ -99,7 +67,7 @@ class SystemCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database encryption key - same pattern as JWT but for database file encryption
|
||||
* Initialize database encryption key - environment variable only
|
||||
*/
|
||||
async initializeDatabaseKey(): Promise<void> {
|
||||
try {
|
||||
@@ -107,7 +75,7 @@ class SystemCrypto {
|
||||
operation: "db_key_init",
|
||||
});
|
||||
|
||||
// 1. Environment variable priority (production best practice)
|
||||
// Check environment variable
|
||||
const envKey = process.env.DATABASE_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
||||
@@ -118,19 +86,8 @@ class SystemCrypto {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check filesystem storage
|
||||
const fileKey = await this.loadDatabaseKeyFromFile();
|
||||
if (fileKey) {
|
||||
this.databaseKey = fileKey;
|
||||
databaseLogger.info("✅ Loaded database key from file", {
|
||||
operation: "db_key_file_loaded",
|
||||
source: "file"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Generate new key and persist (NO database storage to avoid circular dependency)
|
||||
await this.generateAndStoreDatabaseKey();
|
||||
// No environment variable - generate and guide user
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize database key", error, {
|
||||
@@ -151,234 +108,71 @@ class SystemCrypto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new key and persist storage
|
||||
* Generate and guide user - no fallback storage
|
||||
*/
|
||||
private async generateAndStoreSecret(): Promise<void> {
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
databaseLogger.info("🔑 Generating new JWT secret for this Termix instance", {
|
||||
operation: "jwt_generate",
|
||||
instanceId
|
||||
});
|
||||
|
||||
// Try file storage (priority, faster and doesn't depend on database)
|
||||
try {
|
||||
await this.saveSecretToFile(newSecret);
|
||||
databaseLogger.info("✅ JWT secret saved to file", {
|
||||
operation: "jwt_file_saved",
|
||||
path: SystemCrypto.JWT_SECRET_FILE
|
||||
});
|
||||
} catch (fileError) {
|
||||
databaseLogger.warn("⚠️ Cannot save to file, using database storage", {
|
||||
operation: "jwt_file_save_failed",
|
||||
error: fileError instanceof Error ? fileError.message : "Unknown error"
|
||||
});
|
||||
|
||||
// File storage failed, use database
|
||||
await this.saveSecretToDB(newSecret, instanceId);
|
||||
databaseLogger.info("✅ JWT secret saved to database", {
|
||||
operation: "jwt_db_saved"
|
||||
});
|
||||
}
|
||||
|
||||
// Set in memory for current session
|
||||
this.jwtSecret = newSecret;
|
||||
|
||||
databaseLogger.success("🔐 This Termix instance now has a unique JWT secret", {
|
||||
operation: "jwt_generated_success",
|
||||
// Guide user to set environment variable
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("🔐 TERMIX FIRST STARTUP - JWT SECRET REQUIRED");
|
||||
console.log("=".repeat(80));
|
||||
console.log(`Generated JWT Secret: ${newSecret}`);
|
||||
console.log("");
|
||||
console.log("⚠️ REQUIRED: Set this environment variable:");
|
||||
console.log(` export JWT_SECRET=${newSecret}`);
|
||||
console.log("");
|
||||
console.log("🔄 Restart Termix after setting the environment variable");
|
||||
console.log("=".repeat(80) + "\n");
|
||||
|
||||
databaseLogger.warn("⚠️ JWT secret generated for current session only", {
|
||||
operation: "jwt_temp_generated",
|
||||
instanceId,
|
||||
note: "All tokens from previous sessions are invalidated"
|
||||
envVarName: "JWT_SECRET",
|
||||
note: "Set environment variable and restart for persistent operation"
|
||||
});
|
||||
}
|
||||
|
||||
// ===== File storage methods =====
|
||||
|
||||
/**
|
||||
* Save key to file
|
||||
*/
|
||||
private async saveSecretToFile(secret: string): Promise<void> {
|
||||
const dir = path.dirname(SystemCrypto.JWT_SECRET_FILE);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(SystemCrypto.JWT_SECRET_FILE, secret, {
|
||||
mode: 0o600 // Only owner can read/write
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load key from file
|
||||
*/
|
||||
private async loadSecretFromFile(): Promise<string | null> {
|
||||
try {
|
||||
const secret = await fs.readFile(SystemCrypto.JWT_SECRET_FILE, 'utf8');
|
||||
if (secret.trim().length >= 64) {
|
||||
return secret.trim();
|
||||
}
|
||||
databaseLogger.warn("JWT secret file exists but too short", {
|
||||
operation: "jwt_file_invalid",
|
||||
length: secret.length
|
||||
});
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read, this is normal
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== Database key generation and storage methods =====
|
||||
|
||||
/**
|
||||
* Generate new database key and persist to file storage only
|
||||
* (avoid circular dependency with database)
|
||||
* Generate and guide database key - no fallback storage
|
||||
*/
|
||||
private async generateAndStoreDatabaseKey(): Promise<void> {
|
||||
private async generateAndGuideDatabaseKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
|
||||
const newKeyHex = newKey.toString('hex');
|
||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
databaseLogger.info("🔑 Generating new database encryption key for this Termix instance", {
|
||||
operation: "db_key_generate",
|
||||
instanceId
|
||||
});
|
||||
|
||||
// Only try file storage (no database storage to avoid circular dependency)
|
||||
try {
|
||||
await this.saveDatabaseKeyToFile(newKey);
|
||||
databaseLogger.info("✅ Database key saved to file", {
|
||||
operation: "db_key_file_saved",
|
||||
path: SystemCrypto.DATABASE_KEY_FILE
|
||||
});
|
||||
} catch (fileError) {
|
||||
databaseLogger.error("❌ Failed to save database key to file", {
|
||||
operation: "db_key_file_save_failed",
|
||||
error: fileError instanceof Error ? fileError.message : "Unknown error",
|
||||
note: "Database encryption cannot work without persistent key storage"
|
||||
});
|
||||
throw new Error("Database key file storage is required for database encryption");
|
||||
}
|
||||
|
||||
// Set in memory for current session
|
||||
this.databaseKey = newKey;
|
||||
|
||||
databaseLogger.success("🔐 This Termix instance now has a unique database encryption key", {
|
||||
operation: "db_key_generated_success",
|
||||
// Guide user to set environment variable
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("🔒 TERMIX FIRST STARTUP - DATABASE KEY REQUIRED");
|
||||
console.log("=".repeat(80));
|
||||
console.log(`Generated Database Key: ${newKeyHex}`);
|
||||
console.log("");
|
||||
console.log("⚠️ REQUIRED: Set this environment variable:");
|
||||
console.log(` export DATABASE_KEY=${newKeyHex}`);
|
||||
console.log("");
|
||||
console.log("🔄 Restart Termix after setting the environment variable");
|
||||
console.log("=".repeat(80) + "\n");
|
||||
|
||||
databaseLogger.warn("⚠️ Database key generated for current session only", {
|
||||
operation: "db_key_temp_generated",
|
||||
instanceId,
|
||||
note: "Database file is now encrypted at rest"
|
||||
envVarName: "DATABASE_KEY",
|
||||
note: "Set environment variable and restart for persistent operation"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save database key to file (binary format)
|
||||
*/
|
||||
private async saveDatabaseKeyToFile(key: Buffer): Promise<void> {
|
||||
const dir = path.dirname(SystemCrypto.DATABASE_KEY_FILE);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(SystemCrypto.DATABASE_KEY_FILE, key.toString('hex'), {
|
||||
mode: 0o600 // Only owner can read/write
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load database key from file
|
||||
*/
|
||||
private async loadDatabaseKeyFromFile(): Promise<Buffer | null> {
|
||||
try {
|
||||
const keyHex = await fs.readFile(SystemCrypto.DATABASE_KEY_FILE, 'utf8');
|
||||
if (keyHex.trim().length >= 64) { // 32 bytes = 64 hex chars
|
||||
return Buffer.from(keyHex.trim(), 'hex');
|
||||
}
|
||||
databaseLogger.warn("Database key file exists but too short", {
|
||||
operation: "db_key_file_invalid",
|
||||
length: keyHex.length
|
||||
});
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read, this is normal
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== JWT Database storage methods =====
|
||||
|
||||
/**
|
||||
* Save key to database (plaintext storage, don't pretend encryption helps)
|
||||
*/
|
||||
private async saveSecretToDB(secret: string, instanceId: string): Promise<void> {
|
||||
const secretData = {
|
||||
secret,
|
||||
generatedAt: new Date().toISOString(),
|
||||
instanceId,
|
||||
algorithm: "HS256"
|
||||
};
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY));
|
||||
|
||||
const encodedData = JSON.stringify(secretData);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(settings)
|
||||
.set({ value: encodedData })
|
||||
.where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: SystemCrypto.JWT_SECRET_DB_KEY,
|
||||
value: encodedData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load key from database
|
||||
*/
|
||||
private async loadSecretFromDB(): Promise<string | null> {
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secretData = JSON.parse(result[0].value);
|
||||
|
||||
// Check key validity
|
||||
if (!secretData.secret || secretData.secret.length < 64) {
|
||||
databaseLogger.warn("Invalid JWT secret in database", {
|
||||
operation: "jwt_db_invalid",
|
||||
hasSecret: !!secretData.secret,
|
||||
length: secretData.secret?.length || 0
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return secretData.secret;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to load JWT secret from database", {
|
||||
operation: "jwt_db_load_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate JWT secret (admin function)
|
||||
*/
|
||||
async regenerateJWTSecret(): Promise<string> {
|
||||
databaseLogger.warn("🔄 Regenerating JWT secret - ALL TOKENS WILL BE INVALIDATED", {
|
||||
operation: "jwt_regenerate",
|
||||
});
|
||||
|
||||
await this.generateAndStoreSecret();
|
||||
|
||||
databaseLogger.success("JWT secret regenerated successfully", {
|
||||
operation: "jwt_regenerated",
|
||||
warning: "All existing JWT tokens are now invalid",
|
||||
});
|
||||
|
||||
return this.jwtSecret!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JWT secret system
|
||||
@@ -412,36 +206,6 @@ class SystemCrypto {
|
||||
const isValid = await this.validateJWTSecret();
|
||||
const hasSecret = this.jwtSecret !== null;
|
||||
|
||||
// Check file storage
|
||||
let hasFileStorage = false;
|
||||
try {
|
||||
await fs.access(SystemCrypto.JWT_SECRET_FILE);
|
||||
hasFileStorage = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
|
||||
// Check database storage
|
||||
let hasDBStorage = false;
|
||||
let dbInfo = null;
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, SystemCrypto.JWT_SECRET_DB_KEY));
|
||||
|
||||
if (result.length > 0) {
|
||||
hasDBStorage = true;
|
||||
const secretData = JSON.parse(result[0].value);
|
||||
dbInfo = {
|
||||
generatedAt: secretData.generatedAt,
|
||||
instanceId: secretData.instanceId,
|
||||
algorithm: secretData.algorithm
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Database read failed
|
||||
}
|
||||
|
||||
// Check environment variable
|
||||
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
|
||||
@@ -450,11 +214,8 @@ class SystemCrypto {
|
||||
hasSecret,
|
||||
isValid,
|
||||
storage: {
|
||||
environment: hasEnvVar,
|
||||
file: hasFileStorage,
|
||||
database: hasDBStorage
|
||||
environment: hasEnvVar
|
||||
},
|
||||
dbInfo,
|
||||
algorithm: "HS256",
|
||||
note: "Using simplified key management without encryption layers"
|
||||
};
|
||||
|
||||
@@ -213,7 +213,15 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const wsUrl = isDev
|
||||
// Get JWT token for WebSocket authentication
|
||||
const jwtToken = localStorage.getItem("jwt");
|
||||
if (!jwtToken) {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setConnectionStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? "ws://localhost:8082"
|
||||
: isElectron()
|
||||
? (() => {
|
||||
@@ -227,6 +235,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
// Add JWT token as query parameter for authentication
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
@@ -351,6 +362,24 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
// Handle authentication errors (code 1008)
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
setConnectionError("Authentication failed - please re-login");
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
// Clear invalid JWT token
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Show authentication error message
|
||||
toast.error("Authentication failed. Please log in again.");
|
||||
|
||||
// Don't attempt to reconnect on auth failure
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
if (
|
||||
!wasDisconnectedBySSH.current &&
|
||||
|
||||
@@ -147,7 +147,19 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
ws.addEventListener("close", (event) => {
|
||||
// Handle authentication errors (code 1008)
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
|
||||
|
||||
// Clear invalid JWT token
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Don't attempt to reconnect on auth failure
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln(`\r\n[${t("terminal.connectionClosed")}]`);
|
||||
}
|
||||
@@ -240,7 +252,15 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const wsUrl = isDev
|
||||
// Get JWT token for WebSocket authentication
|
||||
const jwtToken = localStorage.getItem("jwt");
|
||||
if (!jwtToken) {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setConnectionStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? "ws://localhost:8082"
|
||||
: isElectron()
|
||||
? (() => {
|
||||
@@ -255,6 +275,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
// Add JWT token as query parameter for authentication
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
Reference in New Issue
Block a user