Merge Luke and Zac

This commit is contained in:
Karmaa
2025-09-07 21:23:48 -05:00
committed by LukeGus
parent 60928ae191
commit 5f6792dc0d
38 changed files with 6648 additions and 3100 deletions

View File

@@ -0,0 +1,370 @@
import {db} from '../database/db/index.js';
import {sshCredentials, sshCredentialUsage, sshData} from '../database/db/schema.js';
import {eq, and, desc, sql} from 'drizzle-orm';
import {encryptionService} from './encryption.js';
import chalk from 'chalk';
const logger = {
info: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
},
warn: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
},
error: (msg: string, err?: unknown): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
if (err) console.error(err);
},
success: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#0f766e')('[CRED]')} ${msg}`);
}
};
export interface CredentialInput {
name: string;
description?: string;
folder?: string;
tags?: string[];
authType: 'password' | 'key';
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
}
export interface CredentialOutput {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: 'password' | 'key';
username: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
export interface CredentialWithSecrets extends CredentialOutput {
password?: string;
key?: string;
keyPassword?: string;
}
class CredentialService {
/**
* Create a new credential
*/
async createCredential(userId: string, input: CredentialInput): Promise<CredentialOutput> {
try {
// Validate input
if (!input.name?.trim()) {
throw new Error('Credential name is required');
}
if (!input.username?.trim()) {
throw new Error('Username is required');
}
if (!['password', 'key'].includes(input.authType)) {
throw new Error('Invalid auth type');
}
if (input.authType === 'password' && !input.password) {
throw new Error('Password is required for password authentication');
}
if (input.authType === 'key' && !input.key) {
throw new Error('SSH key is required for key authentication');
}
// Encrypt sensitive data
let encryptedPassword: string | null = null;
let encryptedKey: string | null = null;
let encryptedKeyPassword: string | null = null;
if (input.authType === 'password' && input.password) {
encryptedPassword = encryptionService.encryptToString(input.password);
} else if (input.authType === 'key') {
if (input.key) {
encryptedKey = encryptionService.encryptToString(input.key);
}
if (input.keyPassword) {
encryptedKeyPassword = encryptionService.encryptToString(input.keyPassword);
}
}
const credentialData = {
userId,
name: input.name.trim(),
description: input.description?.trim() || null,
folder: input.folder?.trim() || null,
tags: Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || ''),
authType: input.authType,
username: input.username.trim(),
encryptedPassword,
encryptedKey,
encryptedKeyPassword,
keyType: input.keyType || null,
usageCount: 0,
lastUsed: null,
};
const result = await db.insert(sshCredentials).values(credentialData).returning();
const created = result[0];
logger.success(`Created credential "${input.name}" (ID: ${created.id})`);
return this.formatCredentialOutput(created);
} catch (error) {
logger.error('Failed to create credential', error);
throw error;
}
}
/**
* Get all credentials for a user
*/
async getUserCredentials(userId: string): Promise<CredentialOutput[]> {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt));
return credentials.map(cred => this.formatCredentialOutput(cred));
} catch (error) {
logger.error('Failed to fetch user credentials', error);
throw error;
}
}
/**
* Get a credential by ID with decrypted secrets
*/
async getCredentialWithSecrets(userId: string, credentialId: number): Promise<CredentialWithSecrets | null> {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
if (credentials.length === 0) {
return null;
}
const credential = credentials[0];
const output: CredentialWithSecrets = {
...this.formatCredentialOutput(credential)
};
// Decrypt sensitive data
try {
if (credential.encryptedPassword) {
output.password = encryptionService.decryptFromString(credential.encryptedPassword);
}
if (credential.encryptedKey) {
output.key = encryptionService.decryptFromString(credential.encryptedKey);
}
if (credential.encryptedKeyPassword) {
output.keyPassword = encryptionService.decryptFromString(credential.encryptedKeyPassword);
}
} catch (decryptError) {
logger.error(`Failed to decrypt credential ${credentialId}`, decryptError);
throw new Error('Failed to decrypt credential data');
}
return output;
} catch (error) {
logger.error('Failed to get credential with secrets', error);
throw error;
}
}
/**
* Update a credential
*/
async updateCredential(userId: string, credentialId: number, input: Partial<CredentialInput>): Promise<CredentialOutput> {
try {
// Check if credential exists and belongs to user
const existing = await db
.select()
.from(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
if (existing.length === 0) {
throw new Error('Credential not found');
}
const updateData: any = {
updatedAt: new Date().toISOString()
};
if (input.name !== undefined) updateData.name = input.name.trim();
if (input.description !== undefined) updateData.description = input.description?.trim() || null;
if (input.folder !== undefined) updateData.folder = input.folder?.trim() || null;
if (input.tags !== undefined) {
updateData.tags = Array.isArray(input.tags) ? input.tags.join(',') : (input.tags || '');
}
if (input.username !== undefined) updateData.username = input.username.trim();
if (input.authType !== undefined) updateData.authType = input.authType;
if (input.keyType !== undefined) updateData.keyType = input.keyType;
// Handle sensitive data updates
if (input.password !== undefined) {
updateData.encryptedPassword = input.password ? encryptionService.encryptToString(input.password) : null;
}
if (input.key !== undefined) {
updateData.encryptedKey = input.key ? encryptionService.encryptToString(input.key) : null;
}
if (input.keyPassword !== undefined) {
updateData.encryptedKeyPassword = input.keyPassword ? encryptionService.encryptToString(input.keyPassword) : null;
}
await db
.update(sshCredentials)
.set(updateData)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
// Fetch updated credential
const updated = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, credentialId));
logger.success(`Updated credential ID ${credentialId}`);
return this.formatCredentialOutput(updated[0]);
} catch (error) {
logger.error('Failed to update credential', error);
throw error;
}
}
/**
* Delete a credential
*/
async deleteCredential(userId: string, credentialId: number): Promise<void> {
try {
// Check if credential is in use
const hostsUsingCredential = await db
.select()
.from(sshData)
.where(and(
eq(sshData.credentialId, credentialId),
eq(sshData.userId, userId)
));
if (hostsUsingCredential.length > 0) {
throw new Error(`Cannot delete credential: it is currently used by ${hostsUsingCredential.length} host(s)`);
}
// Delete usage records
await db
.delete(sshCredentialUsage)
.where(and(
eq(sshCredentialUsage.credentialId, credentialId),
eq(sshCredentialUsage.userId, userId)
));
// Delete credential
const result = await db
.delete(sshCredentials)
.where(and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, userId)
));
logger.success(`Deleted credential ID ${credentialId}`);
} catch (error) {
logger.error('Failed to delete credential', error);
throw error;
}
}
/**
* Record credential usage
*/
async recordUsage(userId: string, credentialId: number, hostId: number): Promise<void> {
try {
// Record usage
await db.insert(sshCredentialUsage).values({
credentialId,
hostId,
userId,
});
// Update credential usage stats
await db
.update(sshCredentials)
.set({
usageCount: sql`${sshCredentials.usageCount} + 1`,
lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString()
})
.where(eq(sshCredentials.id, credentialId));
} catch (error) {
logger.error('Failed to record credential usage', error);
// Don't throw - this is not critical
}
}
/**
* Get credentials grouped by folder
*/
async getCredentialsFolders(userId: string): Promise<string[]> {
try {
const result = await db
.select({folder: sshCredentials.folder})
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
result.forEach(r => {
if (r.folder && r.folder.trim() !== '') {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
}
});
return Object.keys(folderCounts).filter(folder => folderCounts[folder] > 0);
} catch (error) {
logger.error('Failed to get credential folders', error);
throw error;
}
}
private formatCredentialOutput(credential: any): CredentialOutput {
return {
id: credential.id,
name: credential.name,
description: credential.description,
folder: credential.folder,
tags: typeof credential.tags === 'string'
? (credential.tags ? credential.tags.split(',').filter(Boolean) : [])
: [],
authType: credential.authType,
username: credential.username,
keyType: credential.keyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
};
}
}
export const credentialService = new CredentialService();

View File

@@ -0,0 +1,133 @@
import crypto from 'crypto';
import chalk from 'chalk';
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 16; // 128 bits
const TAG_LENGTH = 16; // 128 bits
interface EncryptionResult {
encrypted: string;
iv: string;
tag: string;
}
interface DecryptionInput {
encrypted: string;
iv: string;
tag: string;
}
class EncryptionService {
private key: Buffer;
constructor() {
// Get or generate encryption key
const keyEnv = process.env.CREDENTIAL_ENCRYPTION_KEY;
if (keyEnv) {
this.key = Buffer.from(keyEnv, 'hex');
if (this.key.length !== KEY_LENGTH) {
throw new Error(`Invalid encryption key length. Expected ${KEY_LENGTH} bytes, got ${this.key.length}`);
}
} else {
// Generate a new key - in production, this should be stored securely
this.key = crypto.randomBytes(KEY_LENGTH);
console.warn(chalk.yellow(`[SECURITY] Generated new encryption key. Store this in CREDENTIAL_ENCRYPTION_KEY: ${this.key.toString('hex')}`));
}
}
/**
* Encrypt sensitive data
* @param plaintext - The data to encrypt
* @returns Encryption result with encrypted data, IV, and tag
*/
encrypt(plaintext: string): EncryptionResult {
try {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString('hex'),
tag: tag.toString('hex')
};
} catch (error) {
throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Decrypt sensitive data
* @param input - Encrypted data with IV and tag
* @returns Decrypted plaintext
*/
decrypt(input: DecryptionInput): string {
try {
const iv = Buffer.from(input.iv, 'hex');
const tag = Buffer.from(input.tag, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(input.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Encrypt data and return as single base64-encoded string
* Format: iv:tag:encrypted
*/
encryptToString(plaintext: string): string {
const result = this.encrypt(plaintext);
const combined = `${result.iv}:${result.tag}:${result.encrypted}`;
return Buffer.from(combined).toString('base64');
}
/**
* Decrypt data from base64-encoded string
*/
decryptFromString(encryptedString: string): string {
try {
const combined = Buffer.from(encryptedString, 'base64').toString();
const parts = combined.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted string format');
}
return this.decrypt({
iv: parts[0],
tag: parts[1],
encrypted: parts[2]
});
} catch (error) {
throw new Error(`Failed to decrypt string: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate that a string can be decrypted (useful for testing)
*/
canDecrypt(encryptedString: string): boolean {
try {
this.decryptFromString(encryptedString);
return true;
} catch {
return false;
}
}
}
// Singleton instance
export const encryptionService = new EncryptionService();
// Types for external use
export type { EncryptionResult, DecryptionInput };

View File

@@ -0,0 +1,277 @@
import {db} from '../database/db/index.js';
import {sshData, sshCredentials} from '../database/db/schema.js';
import {eq, and} from 'drizzle-orm';
import {credentialService} from './credentials.js';
import {encryptionService} from './encryption.js';
import chalk from 'chalk';
const logger = {
info: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.cyan('[INFO]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
},
warn: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.warn(`${timestamp} ${chalk.yellow('[WARN]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
},
error: (msg: string, err?: unknown): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.error(`${timestamp} ${chalk.redBright('[ERROR]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
if (err) console.error(err);
},
success: (msg: string): void => {
const timestamp = chalk.gray(`[${new Date().toLocaleTimeString()}]`);
console.log(`${timestamp} ${chalk.greenBright('[SUCCESS]')} ${chalk.hex('#7c3aed')('[SSH]')} ${msg}`);
}
};
export interface SSHHostWithCredentials {
id: number;
userId: string;
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags: string[];
pin: boolean;
authType: string;
// Auth data - either from credential or legacy fields
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
credentialName?: string;
// Other fields
enableTerminal: boolean;
enableTunnel: boolean;
tunnelConnections: any[];
enableFileManager: boolean;
defaultPath?: string;
createdAt: string;
updatedAt: string;
}
class SSHHostService {
/**
* Get SSH host with resolved credentials
*/
async getHostWithCredentials(userId: string, hostId: number): Promise<SSHHostWithCredentials | null> {
try {
const hosts = await db
.select()
.from(sshData)
.where(and(
eq(sshData.id, hostId),
eq(sshData.userId, userId)
));
if (hosts.length === 0) {
return null;
}
const host = hosts[0];
return await this.resolveHostCredentials(host);
} catch (error) {
logger.error(`Failed to get host ${hostId} with credentials`, error);
throw error;
}
}
/**
* Apply a credential to an SSH host
*/
async applyCredentialToHost(userId: string, hostId: number, credentialId: number): Promise<void> {
try {
// Verify credential exists and belongs to user
const credential = await credentialService.getCredentialWithSecrets(userId, credentialId);
if (!credential) {
throw new Error('Credential not found');
}
// Update host to reference the credential and clear legacy fields
await db
.update(sshData)
.set({
credentialId: credentialId,
username: credential.username,
authType: credential.authType,
// Clear legacy credential fields since we're using the credential reference
password: null,
key: null,
keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshData.id, hostId),
eq(sshData.userId, userId)
));
// Record credential usage
await credentialService.recordUsage(userId, credentialId, hostId);
logger.success(`Applied credential ${credentialId} to host ${hostId}`);
} catch (error) {
logger.error(`Failed to apply credential ${credentialId} to host ${hostId}`, error);
throw error;
}
}
/**
* Remove credential from host (revert to legacy mode)
*/
async removeCredentialFromHost(userId: string, hostId: number): Promise<void> {
try {
await db
.update(sshData)
.set({
credentialId: null,
updatedAt: new Date().toISOString()
})
.where(and(
eq(sshData.id, hostId),
eq(sshData.userId, userId)
));
logger.success(`Removed credential reference from host ${hostId}`);
} catch (error) {
logger.error(`Failed to remove credential from host ${hostId}`, error);
throw error;
}
}
/**
* Get all hosts using a specific credential
*/
async getHostsUsingCredential(userId: string, credentialId: number): Promise<SSHHostWithCredentials[]> {
try {
const hosts = await db
.select()
.from(sshData)
.where(and(
eq(sshData.credentialId, credentialId),
eq(sshData.userId, userId)
));
const result: SSHHostWithCredentials[] = [];
for (const host of hosts) {
const resolved = await this.resolveHostCredentials(host);
result.push(resolved);
}
return result;
} catch (error) {
logger.error(`Failed to get hosts using credential ${credentialId}`, error);
throw error;
}
}
/**
* Resolve host credentials from either credential reference or legacy fields
*/
private async resolveHostCredentials(host: any): Promise<SSHHostWithCredentials> {
const baseHost: SSHHostWithCredentials = {
id: host.id,
userId: host.userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: typeof host.tags === 'string'
? (host.tags ? host.tags.split(',').filter(Boolean) : [])
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [],
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
// If host uses a credential reference, get credentials from there
if (host.credentialId) {
try {
const credential = await credentialService.getCredentialWithSecrets(host.userId, host.credentialId);
if (credential) {
baseHost.credentialId = credential.id;
baseHost.credentialName = credential.name;
baseHost.username = credential.username;
baseHost.authType = credential.authType;
baseHost.password = credential.password;
baseHost.key = credential.key;
baseHost.keyPassword = credential.keyPassword;
baseHost.keyType = credential.keyType;
} else {
logger.warn(`Credential ${host.credentialId} not found for host ${host.id}, using legacy data`);
// Fall back to legacy data
this.addLegacyCredentials(baseHost, host);
}
} catch (error) {
logger.error(`Failed to resolve credential ${host.credentialId} for host ${host.id}`, error);
// Fall back to legacy data
this.addLegacyCredentials(baseHost, host);
}
} else {
// Use legacy credential fields
this.addLegacyCredentials(baseHost, host);
}
return baseHost;
}
private addLegacyCredentials(baseHost: SSHHostWithCredentials, host: any): void {
baseHost.password = host.password;
baseHost.key = host.key;
baseHost.keyPassword = host.keyPassword;
baseHost.keyType = host.keyType;
}
/**
* Migrate a host from legacy credentials to a managed credential
*/
async migrateHostToCredential(userId: string, hostId: number, credentialName: string): Promise<number> {
try {
const host = await this.getHostWithCredentials(userId, hostId);
if (!host) {
throw new Error('Host not found');
}
if (host.credentialId) {
throw new Error('Host already uses managed credentials');
}
// Create a new credential from the host's legacy data
const credentialData = {
name: credentialName,
description: `Migrated from host ${host.name || host.ip}`,
folder: host.folder,
tags: host.tags,
authType: host.authType as 'password' | 'key',
username: host.username,
password: host.password,
key: host.key,
keyPassword: host.keyPassword,
keyType: host.keyType,
};
const credential = await credentialService.createCredential(userId, credentialData);
// Apply the new credential to the host
await this.applyCredentialToHost(userId, hostId, credential.id);
logger.success(`Migrated host ${hostId} to managed credential ${credential.id}`);
return credential.id;
} catch (error) {
logger.error(`Failed to migrate host ${hostId} to credential`, error);
throw error;
}
}
}
export const sshHostService = new SSHHostService();