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;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy for authenticated terminal connections
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
|
# Pass to WebSocket server with authentication support
|
||||||
proxy_pass http://127.0.0.1:8082/;
|
proxy_pass http://127.0.0.1:8082/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket upgrade headers
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
# Pass client information for authentication logging
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_connect_timeout 75s;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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/ {
|
location /ssh/tunnel/ {
|
||||||
|
|||||||
@@ -20,7 +20,16 @@ import { UserDataImport } from "../utils/user-data-import.js";
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
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"],
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allowedHeaders: [
|
allowedHeaders: [
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
|
|||||||
@@ -1,34 +1,195 @@
|
|||||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||||
|
import { parse as parseUrl } from "url";
|
||||||
import { db } from "../database/db/index.js";
|
import { db } from "../database/db/index.js";
|
||||||
import { sshCredentials } from "../database/db/schema.js";
|
import { sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.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", {
|
// Track user connections for rate limiting
|
||||||
operation: "server_start",
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({
|
||||||
port: 8082,
|
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 sshConn: Client | null = null;
|
||||||
let sshStream: ClientChannel | null = null;
|
let sshStream: ClientChannel | null = null;
|
||||||
let pingInterval: NodeJS.Timeout | null = null;
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
ws.on("close", () => {
|
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();
|
cleanupSSH();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", (msg: RawData) => {
|
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;
|
let parsed: any;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString());
|
parsed = JSON.parse(msg.toString());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sshLogger.error("Invalid JSON received", e, {
|
sshLogger.error("Invalid JSON received", e, {
|
||||||
operation: "websocket_message",
|
operation: "websocket_message_invalid_json",
|
||||||
|
userId,
|
||||||
messageLength: msg.toString().length,
|
messageLength: msg.toString().length,
|
||||||
});
|
});
|
||||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
||||||
@@ -39,9 +200,14 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "connectToHost":
|
case "connectToHost":
|
||||||
|
// Ensure userId is attached to hostConfig for secure credential resolution
|
||||||
|
if (data.hostConfig) {
|
||||||
|
data.hostConfig.userId = userId;
|
||||||
|
}
|
||||||
handleConnectToHost(data).catch((error) => {
|
handleConnectToHost(data).catch((error) => {
|
||||||
sshLogger.error("Failed to connect to host", error, {
|
sshLogger.error("Failed to connect to host", error, {
|
||||||
operation: "ssh_connect",
|
operation: "ssh_connect",
|
||||||
|
userId,
|
||||||
hostId: data.hostConfig?.id,
|
hostId: data.hostConfig?.id,
|
||||||
ip: data.hostConfig?.ip,
|
ip: data.hostConfig?.ip,
|
||||||
});
|
});
|
||||||
@@ -82,7 +248,8 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
sshLogger.warn("Unknown message type received", {
|
sshLogger.warn("Unknown message type received", {
|
||||||
operation: "websocket_message",
|
operation: "websocket_message_unknown_type",
|
||||||
|
userId,
|
||||||
messageType: type,
|
messageType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -187,15 +354,15 @@ wss.on("connection", (ws: WebSocket) => {
|
|||||||
hasCredentialId: !!credentialId,
|
hasCredentialId: !!credentialId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (password) {
|
// SECURITY: Never log password information - removed password preview logging
|
||||||
sshLogger.debug(`Password preview: "${password.substring(0, 15)}..."`, {
|
sshLogger.debug(`SSH authentication setup`, {
|
||||||
operation: "terminal_ssh_password",
|
operation: "terminal_ssh_auth_setup",
|
||||||
});
|
userId,
|
||||||
} else {
|
hostId: id,
|
||||||
sshLogger.debug(`No password provided`, {
|
authType,
|
||||||
operation: "terminal_ssh_password",
|
hasPassword: !!password,
|
||||||
});
|
hasCredentialId: !!credentialId,
|
||||||
}
|
});
|
||||||
|
|
||||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||||
if (credentialId && id && hostConfig.userId) {
|
if (credentialId && id && hostConfig.userId) {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import crypto from "crypto";
|
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";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,11 +15,6 @@ class SystemCrypto {
|
|||||||
private jwtSecret: string | null = null;
|
private jwtSecret: string | null = null;
|
||||||
private databaseKey: Buffer | 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() {}
|
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> {
|
async initializeJWTSecret(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -44,7 +34,7 @@ class SystemCrypto {
|
|||||||
operation: "jwt_init",
|
operation: "jwt_init",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Environment variable priority (production best practice)
|
// Check environment variable
|
||||||
const envSecret = process.env.JWT_SECRET;
|
const envSecret = process.env.JWT_SECRET;
|
||||||
if (envSecret && envSecret.length >= 64) {
|
if (envSecret && envSecret.length >= 64) {
|
||||||
this.jwtSecret = envSecret;
|
this.jwtSecret = envSecret;
|
||||||
@@ -55,30 +45,8 @@ class SystemCrypto {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check filesystem storage
|
// No environment variable - generate and guide user
|
||||||
const fileSecret = await this.loadSecretFromFile();
|
await this.generateAndGuideUser();
|
||||||
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();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize JWT secret", 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> {
|
async initializeDatabaseKey(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -107,7 +75,7 @@ class SystemCrypto {
|
|||||||
operation: "db_key_init",
|
operation: "db_key_init",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Environment variable priority (production best practice)
|
// Check environment variable
|
||||||
const envKey = process.env.DATABASE_KEY;
|
const envKey = process.env.DATABASE_KEY;
|
||||||
if (envKey && envKey.length >= 64) {
|
if (envKey && envKey.length >= 64) {
|
||||||
this.databaseKey = Buffer.from(envKey, 'hex');
|
this.databaseKey = Buffer.from(envKey, 'hex');
|
||||||
@@ -118,19 +86,8 @@ class SystemCrypto {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check filesystem storage
|
// No environment variable - generate and guide user
|
||||||
const fileKey = await this.loadDatabaseKeyFromFile();
|
await this.generateAndGuideDatabaseKey();
|
||||||
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();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to initialize database key", 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 newSecret = crypto.randomBytes(32).toString('hex');
|
||||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
databaseLogger.info("🔑 Generating new JWT secret for this Termix instance", {
|
// Set in memory for current session
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.jwtSecret = newSecret;
|
this.jwtSecret = newSecret;
|
||||||
|
|
||||||
databaseLogger.success("🔐 This Termix instance now has a unique JWT secret", {
|
// Guide user to set environment variable
|
||||||
operation: "jwt_generated_success",
|
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,
|
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 =====
|
// ===== Database key generation and storage methods =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate new database key and persist to file storage only
|
* Generate and guide database key - no fallback storage
|
||||||
* (avoid circular dependency with database)
|
|
||||||
*/
|
*/
|
||||||
private async generateAndStoreDatabaseKey(): Promise<void> {
|
private async generateAndGuideDatabaseKey(): Promise<void> {
|
||||||
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
|
const newKey = crypto.randomBytes(32); // 256-bit key for AES-256
|
||||||
|
const newKeyHex = newKey.toString('hex');
|
||||||
const instanceId = crypto.randomBytes(8).toString('hex');
|
const instanceId = crypto.randomBytes(8).toString('hex');
|
||||||
|
|
||||||
databaseLogger.info("🔑 Generating new database encryption key for this Termix instance", {
|
// Set in memory for current session
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.databaseKey = newKey;
|
this.databaseKey = newKey;
|
||||||
|
|
||||||
databaseLogger.success("🔐 This Termix instance now has a unique database encryption key", {
|
// Guide user to set environment variable
|
||||||
operation: "db_key_generated_success",
|
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,
|
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
|
* Validate JWT secret system
|
||||||
@@ -412,36 +206,6 @@ class SystemCrypto {
|
|||||||
const isValid = await this.validateJWTSecret();
|
const isValid = await this.validateJWTSecret();
|
||||||
const hasSecret = this.jwtSecret !== null;
|
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
|
// Check environment variable
|
||||||
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
|
const hasEnvVar = !!(process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64);
|
||||||
@@ -450,11 +214,8 @@ class SystemCrypto {
|
|||||||
hasSecret,
|
hasSecret,
|
||||||
isValid,
|
isValid,
|
||||||
storage: {
|
storage: {
|
||||||
environment: hasEnvVar,
|
environment: hasEnvVar
|
||||||
file: hasFileStorage,
|
|
||||||
database: hasDBStorage
|
|
||||||
},
|
},
|
||||||
dbInfo,
|
|
||||||
algorithm: "HS256",
|
algorithm: "HS256",
|
||||||
note: "Using simplified key management without encryption layers"
|
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 === "5173" ||
|
||||||
window.location.port === "");
|
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"
|
? "ws://localhost:8082"
|
||||||
: isElectron()
|
: isElectron()
|
||||||
? (() => {
|
? (() => {
|
||||||
@@ -227,6 +235,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
})()
|
})()
|
||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
: `${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);
|
const ws = new WebSocket(wsUrl);
|
||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
@@ -351,6 +362,24 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
if (terminal) {
|
if (terminal) {
|
||||||
terminal.clear();
|
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);
|
setIsConnecting(true);
|
||||||
if (
|
if (
|
||||||
!wasDisconnectedBySSH.current &&
|
!wasDisconnectedBySSH.current &&
|
||||||
|
|||||||
@@ -147,7 +147,19 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
} catch (error) {}
|
} 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) {
|
if (!wasDisconnectedBySSH.current) {
|
||||||
terminal.writeln(`\r\n[${t("terminal.connectionClosed")}]`);
|
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 === "5173" ||
|
||||||
window.location.port === "");
|
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"
|
? "ws://localhost:8082"
|
||||||
: isElectron()
|
: isElectron()
|
||||||
? (() => {
|
? (() => {
|
||||||
@@ -255,6 +275,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
|||||||
})()
|
})()
|
||||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
: `${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);
|
const ws = new WebSocket(wsUrl);
|
||||||
webSocketRef.current = ws;
|
webSocketRef.current = ws;
|
||||||
wasDisconnectedBySSH.current = false;
|
wasDisconnectedBySSH.current = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user