Files
dashcaddy/dashcaddy-api/keychain-manager.js
Sami 6979302fb7 Fix 7 critical security bugs and 1 high-severity data loss bug
- CSRF: HMAC-signed double-submit cookie (server-bound, not raw compare)
- Keychain: execFileSync with arg arrays to prevent command injection
- Caddy config: always use structured generation, never accept raw config
- Templates: replace {{GENERATED_SECRET}} with crypto.randomBytes
- Caddyfile removal: move regex inside ctx.caddy.modify() to fix TOCTOU race
- Credentials: proper-lockfile for all file operations, fix key rotation
  to decrypt with old key before generating new key
- Service removal: filter by ID only, not AND with appTemplate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:08:30 -08:00

213 lines
6.1 KiB
JavaScript

/**
* Keychain Manager for DashCaddy
* Provides secure credential storage using OS-native keychains
* Falls back to encrypted file storage if keychain is unavailable
*/
const { execSync, execFileSync } = require('child_process');
const os = require('os');
const crypto = require('crypto');
const SERVICE_NAME = 'DashCaddy';
const ACCOUNT_PREFIX = 'dashcaddy';
class KeychainManager {
constructor() {
this.platform = os.platform();
this.available = this.checkAvailability();
}
/**
* Check if OS keychain is available
* @returns {boolean}
*/
checkAvailability() {
try {
if (this.platform === 'win32') {
// Check if PowerShell is available
execSync('powershell -Command "Get-Command Get-Credential"', { stdio: 'ignore' });
return true;
} else if (this.platform === 'darwin') {
// Check if security command is available
execSync('which security', { stdio: 'ignore' });
return true;
} else if (this.platform === 'linux') {
// Check if secret-tool (libsecret) is available
try {
execSync('which secret-tool', { stdio: 'ignore' });
return true;
} catch {
// Try gnome-keyring
execSync('which gnome-keyring-daemon', { stdio: 'ignore' });
return true;
}
}
return false;
} catch {
console.warn('[Keychain] OS keychain not available, will use encrypted file storage');
return false;
}
}
/**
* Store a credential in the OS keychain
* @param {string} key - Credential identifier
* @param {string} value - Credential value
* @returns {Promise<boolean>} Success status
*/
async store(key, value) {
if (!this.available) {
return false;
}
const account = `${ACCOUNT_PREFIX}.${key}`;
try {
if (this.platform === 'win32') {
return await this.storeWindows(account, value);
} else if (this.platform === 'darwin') {
return await this.storeMacOS(account, value);
} else if (this.platform === 'linux') {
return await this.storeLinux(account, value);
}
return false;
} catch (error) {
console.error(`[Keychain] Failed to store ${key}:`, error.message);
return false;
}
}
/**
* Retrieve a credential from the OS keychain
* @param {string} key - Credential identifier
* @returns {Promise<string|null>} Credential value or null
*/
async retrieve(key) {
if (!this.available) {
return null;
}
const account = `${ACCOUNT_PREFIX}.${key}`;
try {
if (this.platform === 'win32') {
return await this.retrieveWindows(account);
} else if (this.platform === 'darwin') {
return await this.retrieveMacOS(account);
} else if (this.platform === 'linux') {
return await this.retrieveLinux(account);
}
return null;
} catch (error) {
console.error(`[Keychain] Failed to retrieve ${key}:`, error.message);
return null;
}
}
/**
* Delete a credential from the OS keychain
* @param {string} key - Credential identifier
* @returns {Promise<boolean>} Success status
*/
async delete(key) {
if (!this.available) {
return false;
}
const account = `${ACCOUNT_PREFIX}.${key}`;
try {
if (this.platform === 'win32') {
return await this.deleteWindows(account);
} else if (this.platform === 'darwin') {
return await this.deleteMacOS(account);
} else if (this.platform === 'linux') {
return await this.deleteLinux(account);
}
return false;
} catch (error) {
console.error(`[Keychain] Failed to delete ${key}:`, error.message);
return false;
}
}
// Windows Credential Manager implementation (uses execFileSync to prevent injection)
async storeWindows(account, value) {
execFileSync('cmdkey', [`/generic:${SERVICE_NAME}:${account}`, `/user:${account}`, `/pass:${value}`], { stdio: 'ignore' });
return true;
}
async retrieveWindows(account) {
try {
const result = execFileSync('cmdkey', [`/list:${SERVICE_NAME}:${account}`], { encoding: 'utf8' });
const match = result.match(/Password:\s*(.+)/);
return match ? match[1].trim() : null;
} catch {
return null;
}
}
async deleteWindows(account) {
execFileSync('cmdkey', [`/delete:${SERVICE_NAME}:${account}`], { stdio: 'ignore' });
return true;
}
// macOS Keychain implementation (uses execFileSync to prevent injection)
async storeMacOS(account, value) {
try {
execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
} catch {
// Ignore if doesn't exist
}
execFileSync('security', ['add-generic-password', '-s', SERVICE_NAME, '-a', account, '-w', value], { stdio: 'ignore' });
return true;
}
async retrieveMacOS(account) {
try {
const result = execFileSync('security', ['find-generic-password', '-s', SERVICE_NAME, '-a', account, '-w'], { encoding: 'utf8' });
return result.trim() || null;
} catch {
return null;
}
}
async deleteMacOS(account) {
execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
return true;
}
// Linux Secret Service implementation (uses execFileSync + stdin to prevent injection)
async storeLinux(account, value) {
try {
execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], {
input: value,
stdio: ['pipe', 'ignore', 'ignore']
});
return true;
} catch {
return false;
}
}
async retrieveLinux(account) {
try {
const result = execFileSync('secret-tool', ['lookup', 'service', SERVICE_NAME, 'account', account], { encoding: 'utf8' });
return result.trim() || null;
} catch {
return null;
}
}
async deleteLinux(account) {
try {
execFileSync('secret-tool', ['clear', 'service', SERVICE_NAME, 'account', account], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
}
module.exports = new KeychainManager();