- 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>
213 lines
6.1 KiB
JavaScript
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();
|