- 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>
640 lines
20 KiB
JavaScript
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);
|
|
});
|
|
});
|