#!/usr/bin/env node /** * Tractatus Credential Vault Server * * Secure Node.js server for interactive credential vault access * Uses keepassxc-cli for KeePassXC database operations * * Security: * - Localhost only (127.0.0.1) * - Session-based master password caching * - Auto-lock after 15 minutes inactivity * - HTTPS optional (self-signed cert for local dev) */ const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); // Configuration const PORT = 8888; const HOST = '127.0.0.1'; // Localhost only const VAULT_PATH = path.join(process.env.HOME, 'Documents/credentials/vault.kdbx'); const KEY_FILE_PATH = path.join(process.env.HOME, 'Documents/credentials/vault.kdbx.key'); const LOG_FILE = path.join(process.env.HOME, 'Documents/credentials/logs/access-log.txt'); const AUTO_LOCK_TIMEOUT = 15 * 60 * 1000; // 15 minutes // Session storage (in-memory) const sessions = new Map(); // Create Express app const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); // Middleware app.use(express.json()); app.use(express.static(__dirname)); // Logging function function log(action, entry, result) { const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); const logEntry = `${timestamp} | ${action} | ${entry} | vault-server | ${result}\n`; fs.appendFileSync(LOG_FILE, logEntry); } // Check if vault exists function vaultExists() { return fs.existsSync(VAULT_PATH); } // Check if key file exists function keyFileExists() { return fs.existsSync(KEY_FILE_PATH); } // Execute keepassxc-cli command function executeKeePassCommand(command, args, password, sessionId) { try { const keyFileArg = keyFileExists() ? `-k ${KEY_FILE_PATH}` : ''; const fullCommand = `echo "${password}" | keepassxc-cli ${command} "${VAULT_PATH}" ${keyFileArg} ${args.join(' ')} 2>&1`; const output = execSync(fullCommand, { encoding: 'utf8', shell: '/bin/bash', timeout: 10000 }); // Update session activity if (sessionId && sessions.has(sessionId)) { sessions.get(sessionId).lastActivity = Date.now(); } return { success: true, output: output.trim() }; } catch (error) { return { success: false, error: error.message, output: error.stdout || '' }; } } // Create new session function createSession(password) { const sessionId = crypto.randomBytes(32).toString('hex'); const session = { id: sessionId, password: password, createdAt: Date.now(), lastActivity: Date.now(), locked: false }; sessions.set(sessionId, session); // Set auto-lock timer session.lockTimer = setTimeout(() => { lockSession(sessionId); }, AUTO_LOCK_TIMEOUT); return sessionId; } // Lock session function lockSession(sessionId) { if (sessions.has(sessionId)) { const session = sessions.get(sessionId); clearTimeout(session.lockTimer); session.locked = true; session.password = null; // Clear password from memory log('LOCK', 'session', 'SUCCESS'); } } // Destroy session function destroySession(sessionId) { if (sessions.has(sessionId)) { const session = sessions.get(sessionId); clearTimeout(session.lockTimer); sessions.delete(sessionId); log('LOGOUT', 'session', 'SUCCESS'); } } // Clean up expired sessions setInterval(() => { const now = Date.now(); for (const [sessionId, session] of sessions.entries()) { if (now - session.lastActivity > AUTO_LOCK_TIMEOUT) { lockSession(sessionId); } } }, 60000); // Check every minute // WebSocket connection handler wss.on('connection', (ws) => { console.log('New WebSocket connection'); ws.sessionId = null; ws.on('message', async (message) => { try { const data = JSON.parse(message); switch (data.type) { case 'unlock': handleUnlock(ws, data); break; case 'list': handleList(ws, data); break; case 'show': handleShow(ws, data); break; case 'totp': handleTotp(ws, data); break; case 'lock': handleLock(ws, data); break; case 'logout': handleLogout(ws, data); break; case 'add': handleAdd(ws, data); break; case 'edit': handleEdit(ws, data); break; case 'delete': handleDelete(ws, data); break; default: ws.send(JSON.stringify({ type: 'error', message: 'Unknown command' })); } } catch (error) { ws.send(JSON.stringify({ type: 'error', message: error.message })); } }); ws.on('close', () => { if (ws.sessionId) { destroySession(ws.sessionId); } console.log('WebSocket connection closed'); }); }); // Handle unlock request function handleUnlock(ws, data) { if (!vaultExists()) { ws.send(JSON.stringify({ type: 'error', message: 'Vault not found. Run: ~/Documents/credentials/scripts/create-vault.sh' })); return; } const password = data.password; // Test password by listing root const result = executeKeePassCommand('ls', ['/'], password, null); if (result.success) { const sessionId = createSession(password); ws.sessionId = sessionId; ws.send(JSON.stringify({ type: 'unlocked', sessionId: sessionId, message: 'Vault unlocked successfully' })); log('UNLOCK', 'vault', 'SUCCESS'); } else { ws.send(JSON.stringify({ type: 'error', message: 'Failed to unlock vault. Wrong password?' })); log('UNLOCK', 'vault', 'FAILED'); } } // Handle list request function handleList(ws, data) { if (!ws.sessionId || !sessions.has(ws.sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' })); return; } const session = sessions.get(ws.sessionId); if (session.locked) { ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); return; } const folder = data.folder || '/tractatus'; const result = executeKeePassCommand('ls', [folder], session.password, ws.sessionId); if (result.success) { const entries = result.output .split('\n') .filter(line => { const trimmed = line.trim(); // Filter out keepassxc-cli prompts and empty lines return trimmed !== '' && !trimmed.startsWith('Enter password') && !trimmed.startsWith('Insert password') && !trimmed.startsWith('Repeat password'); }) .map(line => line.trim()); ws.send(JSON.stringify({ type: 'list', folder: folder, entries: entries })); log('LIST', folder, 'SUCCESS'); } else { ws.send(JSON.stringify({ type: 'error', message: `Failed to list folder: ${folder}` })); } } // Handle show credential request function handleShow(ws, data) { if (!ws.sessionId || !sessions.has(ws.sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' })); return; } const session = sessions.get(ws.sessionId); if (session.locked) { ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); return; } const entryPath = data.entry; const result = executeKeePassCommand('show', [entryPath, '--show-protected'], session.password, ws.sessionId); if (result.success) { // DEBUG: Log raw output console.log('RAW OUTPUT from keepassxc-cli show:'); console.log(result.output); console.log('END RAW OUTPUT'); // Parse keepassxc-cli output const lines = result.output.split('\n').filter(line => { // Filter out keepassxc-cli prompts and empty lines const trimmed = line.trim(); return trimmed !== '' && !trimmed.startsWith('Enter password') && !trimmed.startsWith('Insert password') && !trimmed.startsWith('Repeat password'); }); console.log('FILTERED LINES:', lines); const credential = {}; lines.forEach(line => { const match = line.match(/^([^:]+):\s*(.*)$/); if (match) { const key = match[1].trim().toLowerCase(); const value = match[2].trim(); console.log(`PARSED: ${key} = ${value}`); // Include even empty values (url, notes might be empty) credential[key] = value; } }); console.log('FINAL CREDENTIAL OBJECT:', credential); ws.send(JSON.stringify({ type: 'show', entry: entryPath, credential: credential })); log('READ', entryPath, 'SUCCESS'); } else { ws.send(JSON.stringify({ type: 'error', message: `Failed to retrieve credential: ${entryPath}` })); } } // Handle TOTP request function handleTotp(ws, data) { if (!ws.sessionId || !sessions.has(ws.sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' })); return; } const session = sessions.get(ws.sessionId); if (session.locked) { ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); return; } const entryPath = data.entry; const result = executeKeePassCommand('totp', [entryPath], session.password, ws.sessionId); if (result.success) { const code = result.output.trim(); ws.send(JSON.stringify({ type: 'totp', entry: entryPath, code: code, expiresIn: 30 // TOTP codes expire in 30 seconds })); log('TOTP', entryPath, 'SUCCESS'); } else { ws.send(JSON.stringify({ type: 'error', message: `Failed to generate TOTP code for: ${entryPath}` })); } } // Handle lock request function handleLock(ws, data) { if (ws.sessionId) { lockSession(ws.sessionId); ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); } } // Handle logout request function handleLogout(ws, data) { if (ws.sessionId) { destroySession(ws.sessionId); ws.sessionId = null; ws.send(JSON.stringify({ type: 'logged_out', message: 'Logged out successfully' })); } } // Handle add credential request function handleAdd(ws, data) { if (!ws.sessionId || !sessions.has(ws.sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' })); return; } const session = sessions.get(ws.sessionId); if (session.locked) { ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); return; } const { folder, name, username, password, url, notes } = data; if (!folder || !name || !password) { ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields: folder, name, password' })); return; } const entryPath = `${folder}/${name}`; try { // Build keepassxc-cli add command // CRITICAL: Must use --password-prompt flag to read entry password from stdin // Format: echo "vault_password\nentry_password" | keepassxc-cli add vault.kdbx --username "user" --password-prompt /path/Entry const keyFileArg = keyFileExists() ? `-k ${KEY_FILE_PATH}` : ''; // Provide vault password and entry password on stdin (2 lines) let input = session.password + '\n' + password; let cmdArgs = `add "${VAULT_PATH}" ${keyFileArg} "${entryPath}"`; if (username) cmdArgs += ` --username "${username}"`; if (url) cmdArgs += ` --url "${url}"`; if (notes) cmdArgs += ` --notes "${notes}"`; cmdArgs += ` --password-prompt`; // CRITICAL: This flag tells keepassxc-cli to read entry password from stdin const fullCommand = `printf "%s\\n" "${input}" | keepassxc-cli ${cmdArgs} 2>&1`; const output = execSync(fullCommand, { encoding: 'utf8', shell: '/bin/bash', timeout: 10000 }); // Check if successful if (output.includes('Successfully added entry') || output.includes('Entry added')) { ws.send(JSON.stringify({ type: 'added', entryPath: entryPath, message: 'Credential added successfully' })); log('ADD', entryPath, 'SUCCESS'); session.lastActivity = Date.now(); } else { ws.send(JSON.stringify({ type: 'error', message: 'Failed to add credential. Entry may already exist.' })); log('ADD', entryPath, 'FAILED'); } } catch (error) { ws.send(JSON.stringify({ type: 'error', message: `Failed to add credential: ${error.message}` })); log('ADD', entryPath, `FAILED: ${error.message}`); } } // Handle edit credential request function handleEdit(ws, data) { if (!ws.sessionId || !sessions.has(ws.sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' })); return; } const session = sessions.get(ws.sessionId); if (session.locked) { ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); return; } const { folder, name, username, password, url, notes } = data; if (!folder || !name) { ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields: folder, name' })); return; } const entryPath = `${folder}/${name}`; try { // keepassxc-cli edit command // NOTE: edit command doesn't support --password flag, must use --password-prompt with stdin const keyFileArg = keyFileExists() ? `-k ${KEY_FILE_PATH}` : ''; let cmdArgs = `edit "${VAULT_PATH}" ${keyFileArg} "${entryPath}"`; if (username) cmdArgs += ` --username "${username}"`; if (url) cmdArgs += ` --url "${url}"`; if (notes) cmdArgs += ` --notes "${notes}"`; let input = session.password; let fullCommand; // If password is being updated, we need to use --password-prompt and provide both passwords via stdin if (password) { cmdArgs += ` --password-prompt`; // Provide vault password and new entry password on separate lines input = session.password + '\n' + password; } fullCommand = `printf "%s\\n" "${input}" | keepassxc-cli ${cmdArgs} 2>&1`; console.log(`EDIT COMMAND: keepassxc-cli ${cmdArgs.replace(session.password, '***')}`); const output = execSync(fullCommand, { encoding: 'utf8', shell: '/bin/bash', timeout: 10000 }); console.log('EDIT OUTPUT:', output); ws.send(JSON.stringify({ type: 'edited', entryPath: entryPath, message: 'Credential updated successfully' })); log('EDIT', entryPath, 'SUCCESS'); session.lastActivity = Date.now(); } catch (error) { console.error('EDIT ERROR:', error.message); if (error.stdout) console.error('EDIT STDOUT:', error.stdout); if (error.stderr) console.error('EDIT STDERR:', error.stderr); ws.send(JSON.stringify({ type: 'error', message: `Failed to edit credential: ${error.message}` })); log('EDIT', entryPath, `FAILED: ${error.message}`); } } // Handle delete credential request function handleDelete(ws, data) { if (!ws.sessionId || !sessions.has(ws.sessionId)) { ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' })); return; } const session = sessions.get(ws.sessionId); if (session.locked) { ws.send(JSON.stringify({ type: 'locked', message: 'Session locked' })); return; } const { folder, name } = data; if (!folder || !name) { ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields: folder, name' })); return; } const entryPath = `${folder}/${name}`; try { // keepassxc-cli rm command const keyFileArg = keyFileExists() ? `-k ${KEY_FILE_PATH}` : ''; const fullCommand = `echo "${session.password}" | keepassxc-cli rm "${VAULT_PATH}" ${keyFileArg} "${entryPath}" 2>&1`; const output = execSync(fullCommand, { encoding: 'utf8', shell: '/bin/bash', timeout: 10000 }); ws.send(JSON.stringify({ type: 'deleted', entryPath: entryPath, message: 'Credential deleted successfully' })); log('DELETE', entryPath, 'SUCCESS'); session.lastActivity = Date.now(); } catch (error) { ws.send(JSON.stringify({ type: 'error', message: `Failed to delete credential: ${error.message}` })); log('DELETE', entryPath, `FAILED: ${error.message}`); } } // REST API endpoints (for backward compatibility) // Health check app.get('/api/health', (req, res) => { res.json({ status: 'ok', vaultExists: vaultExists(), keyFileExists: keyFileExists(), activeSessions: sessions.size }); }); // Start server server.listen(PORT, HOST, () => { console.log('═══════════════════════════════════════════════════════════'); console.log(' 🔐 TRACTATUS CREDENTIAL VAULT SERVER'); console.log('═══════════════════════════════════════════════════════════'); console.log(''); console.log(` URL: http://${HOST}:${PORT}`); console.log(` Vault: ${VAULT_PATH}`); console.log(` Key File: ${keyFileExists() ? 'Yes' : 'No'}`); console.log(` Auto-lock: ${AUTO_LOCK_TIMEOUT / 60000} minutes`); console.log(''); console.log(' Security:'); console.log(' - Localhost only (127.0.0.1)'); console.log(' - Session-based password caching'); console.log(' - Auto-lock on inactivity'); console.log(' - Access logging enabled'); console.log(''); console.log(' Press Ctrl+C to stop'); console.log('═══════════════════════════════════════════════════════════'); }); // Graceful shutdown process.on('SIGINT', () => { console.log('\n\nShutting down...'); // Clear all sessions for (const [sessionId, session] of sessions.entries()) { clearTimeout(session.lockTimer); } sessions.clear(); server.close(() => { console.log('Server stopped'); process.exit(0); }); });