tractatus/.credential-vault/server.js
TheFlow ac2db33732 fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

640 lines
20 KiB
JavaScript

#!/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);
});
});