From 86261f486028833589b3787af05d70f06399c92c Mon Sep 17 00:00:00 2001 From: ronitchidara Date: Tue, 27 Jan 2026 12:51:16 +0530 Subject: [PATCH] security: add cross-platform secrets manager - Support macOS Keychain via `security` command - Support Linux Secret Service via `secret-tool` (libsecret) - Fallback to AES-256-GCM encrypted files with PBKDF2 key derivation - Machine-derived encryption key for file fallback - Utilities: generateSecureToken, hashSecret, verifySecretHash - Migration helpers for moving plain-text credentials to secure storage - Comprehensive test suite (27 tests) Co-Authored-By: Claude Opus 4.5 --- src/infra/secrets-manager.test.ts | 294 ++++++++++++++++ src/infra/secrets-manager.ts | 565 ++++++++++++++++++++++++++++++ 2 files changed, 859 insertions(+) create mode 100644 src/infra/secrets-manager.test.ts create mode 100644 src/infra/secrets-manager.ts diff --git a/src/infra/secrets-manager.test.ts b/src/infra/secrets-manager.test.ts new file mode 100644 index 000000000..207bbd3d6 --- /dev/null +++ b/src/infra/secrets-manager.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + encryptData, + decryptData, + storeSecret, + getSecret, + deleteSecret, + listSecretKeys, + hasSecret, + generateSecureToken, + hashSecret, + verifySecretHash, + getDefaultEncryptedSecretsPath, + detectAvailableBackends, +} from "./secrets-manager.js"; + +describe("secrets-manager encryption", () => { + test("encrypts and decrypts data correctly", () => { + const original = "super secret password 123!@#"; + const encrypted = encryptData(original); + const decrypted = decryptData(encrypted); + expect(decrypted).toBe(original); + }); + + test("encrypts to different ciphertext each time (random IV)", () => { + const original = "same data"; + const encrypted1 = encryptData(original); + const encrypted2 = encryptData(original); + expect(encrypted1.equals(encrypted2)).toBe(false); + }); + + test("decrypts with correct password", () => { + const original = "password protected data"; + const password = "my-custom-password"; + const encrypted = encryptData(original, password); + const decrypted = decryptData(encrypted, password); + expect(decrypted).toBe(original); + }); + + test("fails to decrypt with wrong password", () => { + const original = "password protected data"; + const encrypted = encryptData(original, "correct-password"); + expect(() => decryptData(encrypted, "wrong-password")).toThrow(); + }); + + test("handles empty string", () => { + // Empty string encryption produces just headers (salt+iv+authTag) with no ciphertext + // The decryption should handle this edge case + const original = ""; + const encrypted = encryptData(original); + // Encrypted buffer should have at least salt(32) + iv(16) + authTag(16) = 64 bytes + expect(encrypted.length).toBeGreaterThanOrEqual(64); + const decrypted = decryptData(encrypted); + expect(decrypted).toBe(original); + }); + + test("handles unicode characters", () => { + const original = "日本語テスト 🔐 émojis"; + const encrypted = encryptData(original); + const decrypted = decryptData(encrypted); + expect(decrypted).toBe(original); + }); + + test("handles large data", () => { + const original = "x".repeat(100_000); + const encrypted = encryptData(original); + const decrypted = decryptData(encrypted); + expect(decrypted).toBe(original); + }); + + test("rejects tampered ciphertext", () => { + const original = "sensitive data"; + const encrypted = encryptData(original); + // Tamper with the encrypted data + encrypted[encrypted.length - 1] ^= 0xff; + expect(() => decryptData(encrypted)).toThrow(); + }); + + test("rejects truncated ciphertext", () => { + const original = "sensitive data"; + const encrypted = encryptData(original); + const truncated = encrypted.subarray(0, 50); + expect(() => decryptData(truncated)).toThrow(); + }); +}); + +describe("secrets-manager file storage", () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "secrets-test-")); + testFilePath = path.join(testDir, "test-secrets.enc"); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + test("stores and retrieves secret from encrypted file", () => { + const key = "test-api-key"; + const value = "sk-test-1234567890"; + + const storeResult = storeSecret(key, value, { + preferKeychain: false, + enableEncryptedFallback: true, + encryptedFilePath: testFilePath, + }); + + expect(storeResult.success).toBe(true); + expect(storeResult.storage).toBe("encrypted-file"); + + const getResult = getSecret(key, { + preferKeychain: false, + enableEncryptedFallback: true, + encryptedFilePath: testFilePath, + }); + + expect(getResult.success).toBe(true); + expect(getResult.value).toBe(value); + expect(getResult.storage).toBe("encrypted-file"); + }); + + test("returns error for non-existent secret", () => { + const result = getSecret("non-existent-key", { + preferKeychain: false, + enableEncryptedFallback: true, + encryptedFilePath: testFilePath, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + + test("deletes secret from encrypted file", () => { + const key = "delete-me"; + const value = "temporary secret"; + + storeSecret(key, value, { + preferKeychain: false, + encryptedFilePath: testFilePath, + }); + + expect(hasSecret(key, { preferKeychain: false, encryptedFilePath: testFilePath })).toBe(true); + + deleteSecret(key, { + preferKeychain: false, + encryptedFilePath: testFilePath, + }); + + expect(hasSecret(key, { preferKeychain: false, encryptedFilePath: testFilePath })).toBe(false); + }); + + test("lists stored secret keys", () => { + storeSecret("key1", "value1", { preferKeychain: false, encryptedFilePath: testFilePath }); + storeSecret("key2", "value2", { preferKeychain: false, encryptedFilePath: testFilePath }); + storeSecret("key3", "value3", { preferKeychain: false, encryptedFilePath: testFilePath }); + + const keys = listSecretKeys({ preferKeychain: false, encryptedFilePath: testFilePath }); + + expect(keys).toContain("key1"); + expect(keys).toContain("key2"); + expect(keys).toContain("key3"); + expect(keys.length).toBe(3); + }); + + test("overwrites existing secret", () => { + const key = "overwrite-test"; + + storeSecret(key, "original", { preferKeychain: false, encryptedFilePath: testFilePath }); + storeSecret(key, "updated", { preferKeychain: false, encryptedFilePath: testFilePath }); + + const result = getSecret(key, { preferKeychain: false, encryptedFilePath: testFilePath }); + expect(result.value).toBe("updated"); + }); + + test("creates directory if it doesn't exist", () => { + const nestedPath = path.join(testDir, "nested", "dir", "secrets.enc"); + + const result = storeSecret("nested-key", "nested-value", { + preferKeychain: false, + encryptedFilePath: nestedPath, + }); + + expect(result.success).toBe(true); + expect(fs.existsSync(nestedPath)).toBe(true); + }); + + test("sets restrictive file permissions", () => { + storeSecret("perm-test", "value", { + preferKeychain: false, + encryptedFilePath: testFilePath, + }); + + const stats = fs.statSync(testFilePath); + // Check that only owner has read/write (0o600) + const mode = stats.mode & 0o777; + expect(mode).toBe(0o600); + }); +}); + +describe("secrets-manager utilities", () => { + test("generateSecureToken returns correct length", () => { + const token32 = generateSecureToken(32); + const token64 = generateSecureToken(64); + + // Base64url encoding: 4 chars per 3 bytes + expect(token32.length).toBeGreaterThan(40); + expect(token64.length).toBeGreaterThan(80); + }); + + test("generateSecureToken is unique each time", () => { + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateSecureToken()); + } + expect(tokens.size).toBe(100); + }); + + test("hashSecret produces consistent hash", () => { + const secret = "my-secret-value"; + const hash1 = hashSecret(secret); + const hash2 = hashSecret(secret); + expect(hash1).toBe(hash2); + expect(hash1.length).toBe(64); // SHA-256 hex is 64 chars + }); + + test("hashSecret produces different hashes for different inputs", () => { + const hash1 = hashSecret("secret1"); + const hash2 = hashSecret("secret2"); + expect(hash1).not.toBe(hash2); + }); + + test("verifySecretHash returns true for matching secret", () => { + const secret = "verify-me"; + const hash = hashSecret(secret); + expect(verifySecretHash(secret, hash)).toBe(true); + }); + + test("verifySecretHash returns false for wrong secret", () => { + const hash = hashSecret("correct-secret"); + expect(verifySecretHash("wrong-secret", hash)).toBe(false); + }); + + test("hasSecret returns true when secret exists", () => { + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "has-secret-test-")); + const testFilePath = path.join(testDir, "secrets.enc"); + + try { + storeSecret("exists", "value", { preferKeychain: false, encryptedFilePath: testFilePath }); + expect(hasSecret("exists", { preferKeychain: false, encryptedFilePath: testFilePath })).toBe( + true, + ); + expect( + hasSecret("not-exists", { preferKeychain: false, encryptedFilePath: testFilePath }), + ).toBe(false); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + test("getDefaultEncryptedSecretsPath returns expected path", () => { + const defaultPath = getDefaultEncryptedSecretsPath(); + expect(defaultPath).toContain(".clawdbot"); + expect(defaultPath).toContain("secrets.enc"); + }); +}); + +describe("secrets-manager backend detection", () => { + test("detectAvailableBackends returns object with correct shape", () => { + const backends = detectAvailableBackends(); + expect(typeof backends.keychain).toBe("boolean"); + expect(typeof backends.secretService).toBe("boolean"); + expect(typeof backends.credentialManager).toBe("boolean"); + }); + + test("detectAvailableBackends reports keychain on macOS", () => { + const backends = detectAvailableBackends(); + if (process.platform === "darwin") { + // On macOS, keychain should generally be available + expect(backends.keychain).toBe(true); + } + }); + + test("detectAvailableBackends reports credential manager on Windows", () => { + const backends = detectAvailableBackends(); + if (process.platform === "win32") { + expect(backends.credentialManager).toBe(true); + } + }); +}); diff --git a/src/infra/secrets-manager.ts b/src/infra/secrets-manager.ts new file mode 100644 index 000000000..e7021f5ae --- /dev/null +++ b/src/infra/secrets-manager.ts @@ -0,0 +1,565 @@ +/** + * Secrets Manager + * + * Cross-platform secure storage for sensitive credentials using system keychains. + * Falls back to AES-256-GCM encrypted files when keychain is unavailable. + * + * Supported platforms: + * - macOS: Keychain Access via `security` command + * - Linux: Secret Service API via `secret-tool` (libsecret) + * - Windows: Credential Manager via PowerShell (future) + * - Fallback: AES-256-GCM encrypted JSON files + */ + +import crypto from "node:crypto"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("secrets"); + +/** Service name prefix for keychain entries */ +const KEYCHAIN_SERVICE_PREFIX = "Clawdbot"; + +/** Encryption algorithm for file fallback */ +const ENCRYPTION_ALGORITHM = "aes-256-gcm"; + +/** Key derivation iterations */ +const PBKDF2_ITERATIONS = 100_000; + +/** Salt length in bytes */ +const SALT_LENGTH = 32; + +/** IV length in bytes */ +const IV_LENGTH = 16; + +/** Auth tag length in bytes */ +const AUTH_TAG_LENGTH = 16; + +/** Result of a secrets operation */ +export type SecretsResult = { + success: boolean; + value?: T; + error?: string; + storage: "keychain" | "encrypted-file" | "plain-file" | "none"; +}; + +/** Configuration for secrets storage */ +export type SecretsConfig = { + /** Prefer keychain over file storage */ + preferKeychain: boolean; + /** Enable encrypted file fallback when keychain unavailable */ + enableEncryptedFallback: boolean; + /** Path to encrypted secrets file (for fallback) */ + encryptedFilePath?: string; + /** Machine-specific key for file encryption (derived from hardware ID if not provided) */ + encryptionKey?: string; +}; + +const DEFAULT_CONFIG: SecretsConfig = { + preferKeychain: true, + enableEncryptedFallback: true, +}; + +/** + * Detects available secret storage backends on the current platform. + */ +export function detectAvailableBackends(): { + keychain: boolean; + secretService: boolean; + credentialManager: boolean; +} { + const platform = process.platform; + + let keychain = false; + let secretService = false; + let credentialManager = false; + + if (platform === "darwin") { + // Check if security command is available + try { + execSync("which security", { encoding: "utf8", timeout: 2000, stdio: "pipe" }); + keychain = true; + } catch { + keychain = false; + } + } + + if (platform === "linux") { + // Check if secret-tool is available (libsecret) + try { + execSync("which secret-tool", { encoding: "utf8", timeout: 2000, stdio: "pipe" }); + secretService = true; + } catch { + secretService = false; + } + } + + if (platform === "win32") { + // Windows Credential Manager is always available via PowerShell + credentialManager = true; + } + + return { keychain, secretService, credentialManager }; +} + +/** + * Derives an encryption key from a password using PBKDF2. + */ +function deriveKey(password: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, 32, "sha256"); +} + +/** + * Gets a machine-specific identifier for encryption key derivation. + * Uses hardware IDs that are stable across reboots but unique per machine. + */ +function getMachineId(): string { + const platform = process.platform; + let machineId = ""; + + try { + if (platform === "darwin") { + // Use macOS hardware UUID + const output = execSync("ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID", { + encoding: "utf8", + timeout: 5000, + stdio: "pipe", + }); + const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); + machineId = match?.[1] ?? ""; + } else if (platform === "linux") { + // Use machine-id + if (fs.existsSync("/etc/machine-id")) { + machineId = fs.readFileSync("/etc/machine-id", "utf8").trim(); + } else if (fs.existsSync("/var/lib/dbus/machine-id")) { + machineId = fs.readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); + } + } else if (platform === "win32") { + // Use MachineGuid from registry + const output = execSync( + 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid', + { encoding: "utf8", timeout: 5000, stdio: "pipe" }, + ); + const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); + machineId = match?.[1] ?? ""; + } + } catch (err) { + log.warn("Failed to get machine ID", { error: String(err) }); + } + + // Fallback to hostname + username if no machine ID available + if (!machineId) { + machineId = `${os.hostname()}-${os.userInfo().username}`; + } + + return machineId; +} + +/** + * Encrypts data using AES-256-GCM with a machine-derived key. + */ +export function encryptData(data: string, password?: string): Buffer { + const key = password ?? getMachineId(); + const salt = crypto.randomBytes(SALT_LENGTH); + const iv = crypto.randomBytes(IV_LENGTH); + const derivedKey = deriveKey(key, salt); + + const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, derivedKey, iv); + const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + + // Format: salt (32) + iv (16) + authTag (16) + encrypted + return Buffer.concat([salt, iv, authTag, encrypted]); +} + +/** + * Decrypts data encrypted with encryptData. + */ +export function decryptData(encryptedBuffer: Buffer, password?: string): string { + const key = password ?? getMachineId(); + + // Minimum size is salt + iv + authTag (empty ciphertext is valid for empty string) + if (encryptedBuffer.length < SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error("Invalid encrypted data: too short"); + } + + const salt = encryptedBuffer.subarray(0, SALT_LENGTH); + const iv = encryptedBuffer.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const authTag = encryptedBuffer.subarray( + SALT_LENGTH + IV_LENGTH, + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH, + ); + const encrypted = encryptedBuffer.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); + + const derivedKey = deriveKey(key, salt); + const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, derivedKey, iv); + decipher.setAuthTag(authTag); + + return decipher.update(encrypted) + decipher.final("utf8"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MACOS KEYCHAIN OPERATIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Stores a secret in macOS Keychain. + */ +function storeInKeychain(service: string, account: string, secret: string): boolean { + try { + // First try to update existing entry + try { + execSync( + `security add-generic-password -U -s "${service}" -a "${account}" -w '${secret.replace(/'/g, "'\"'\"'")}'`, + { encoding: "utf8", timeout: 5000, stdio: "pipe" }, + ); + return true; + } catch { + // Entry doesn't exist, create new + execSync( + `security add-generic-password -s "${service}" -a "${account}" -w '${secret.replace(/'/g, "'\"'\"'")}'`, + { encoding: "utf8", timeout: 5000, stdio: "pipe" }, + ); + return true; + } + } catch (err) { + log.error("Failed to store in keychain", { service, error: String(err) }); + return false; + } +} + +/** + * Retrieves a secret from macOS Keychain. + */ +function readFromKeychain(service: string, account: string): string | null { + try { + const result = execSync(`security find-generic-password -s "${service}" -a "${account}" -w`, { + encoding: "utf8", + timeout: 5000, + stdio: "pipe", + }); + return result.trim(); + } catch { + return null; + } +} + +/** + * Deletes a secret from macOS Keychain. + */ +function deleteFromKeychain(service: string, account: string): boolean { + try { + execSync(`security delete-generic-password -s "${service}" -a "${account}"`, { + encoding: "utf8", + timeout: 5000, + stdio: "pipe", + }); + return true; + } catch { + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// LINUX SECRET SERVICE OPERATIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Stores a secret using Linux Secret Service (libsecret). + */ +function storeInSecretService(service: string, account: string, secret: string): boolean { + try { + execSync( + `echo -n '${secret.replace(/'/g, "'\\''")}' | secret-tool store --label="${service}" service "${service}" account "${account}"`, + { encoding: "utf8", timeout: 5000, stdio: "pipe", shell: "/bin/bash" }, + ); + return true; + } catch (err) { + log.error("Failed to store in Secret Service", { service, error: String(err) }); + return false; + } +} + +/** + * Retrieves a secret from Linux Secret Service. + */ +function readFromSecretService(service: string, account: string): string | null { + try { + const result = execSync(`secret-tool lookup service "${service}" account "${account}"`, { + encoding: "utf8", + timeout: 5000, + stdio: "pipe", + }); + return result.trim() || null; + } catch { + return null; + } +} + +/** + * Deletes a secret from Linux Secret Service. + */ +function deleteFromSecretService(service: string, account: string): boolean { + try { + execSync(`secret-tool clear service "${service}" account "${account}"`, { + encoding: "utf8", + timeout: 5000, + stdio: "pipe", + }); + return true; + } catch { + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ENCRYPTED FILE OPERATIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Gets the default path for encrypted secrets file. + */ +export function getDefaultEncryptedSecretsPath(): string { + return path.join(os.homedir(), ".clawdbot", "secrets.enc"); +} + +/** + * Reads all secrets from encrypted file. + */ +function readEncryptedSecretsFile(filePath: string): Record { + if (!fs.existsSync(filePath)) { + return {}; + } + + try { + const encrypted = fs.readFileSync(filePath); + const decrypted = decryptData(encrypted); + return JSON.parse(decrypted); + } catch (err) { + log.error("Failed to read encrypted secrets file", { error: String(err) }); + return {}; + } +} + +/** + * Writes all secrets to encrypted file. + */ +function writeEncryptedSecretsFile(filePath: string, secrets: Record): boolean { + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const encrypted = encryptData(JSON.stringify(secrets)); + fs.writeFileSync(filePath, encrypted, { mode: 0o600 }); + return true; + } catch (err) { + log.error("Failed to write encrypted secrets file", { error: String(err) }); + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PUBLIC API +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Stores a secret securely using the best available backend. + */ +export function storeSecret( + key: string, + value: string, + config: Partial = {}, +): SecretsResult { + const cfg = { ...DEFAULT_CONFIG, ...config }; + const platform = process.platform; + const service = `${KEYCHAIN_SERVICE_PREFIX}-${key}`; + const account = "default"; + + if (cfg.preferKeychain) { + // Try platform-specific keychain first + if (platform === "darwin") { + if (storeInKeychain(service, account, value)) { + log.info("Stored secret in macOS Keychain", { key }); + return { success: true, storage: "keychain" }; + } + } else if (platform === "linux") { + const backends = detectAvailableBackends(); + if (backends.secretService && storeInSecretService(service, account, value)) { + log.info("Stored secret in Secret Service", { key }); + return { success: true, storage: "keychain" }; + } + } + } + + // Fall back to encrypted file + if (cfg.enableEncryptedFallback) { + const filePath = cfg.encryptedFilePath ?? getDefaultEncryptedSecretsPath(); + const secrets = readEncryptedSecretsFile(filePath); + secrets[key] = value; + if (writeEncryptedSecretsFile(filePath, secrets)) { + log.info("Stored secret in encrypted file", { key }); + return { success: true, storage: "encrypted-file" }; + } + } + + return { success: false, error: "No storage backend available", storage: "none" }; +} + +/** + * Retrieves a secret from secure storage. + */ +export function getSecret(key: string, config: Partial = {}): SecretsResult { + const cfg = { ...DEFAULT_CONFIG, ...config }; + const platform = process.platform; + const service = `${KEYCHAIN_SERVICE_PREFIX}-${key}`; + const account = "default"; + + if (cfg.preferKeychain) { + // Try platform-specific keychain first + if (platform === "darwin") { + const value = readFromKeychain(service, account); + if (value !== null) { + return { success: true, value, storage: "keychain" }; + } + } else if (platform === "linux") { + const backends = detectAvailableBackends(); + if (backends.secretService) { + const value = readFromSecretService(service, account); + if (value !== null) { + return { success: true, value, storage: "keychain" }; + } + } + } + } + + // Fall back to encrypted file + if (cfg.enableEncryptedFallback) { + const filePath = cfg.encryptedFilePath ?? getDefaultEncryptedSecretsPath(); + const secrets = readEncryptedSecretsFile(filePath); + if (key in secrets) { + return { success: true, value: secrets[key], storage: "encrypted-file" }; + } + } + + return { success: false, error: "Secret not found", storage: "none" }; +} + +/** + * Deletes a secret from secure storage. + */ +export function deleteSecret( + key: string, + config: Partial = {}, +): SecretsResult { + const cfg = { ...DEFAULT_CONFIG, ...config }; + const platform = process.platform; + const service = `${KEYCHAIN_SERVICE_PREFIX}-${key}`; + const account = "default"; + + let deleted = false; + + // Try to delete from keychain + if (platform === "darwin") { + deleted = deleteFromKeychain(service, account) || deleted; + } else if (platform === "linux") { + const backends = detectAvailableBackends(); + if (backends.secretService) { + deleted = deleteFromSecretService(service, account) || deleted; + } + } + + // Also delete from encrypted file + if (cfg.enableEncryptedFallback) { + const filePath = cfg.encryptedFilePath ?? getDefaultEncryptedSecretsPath(); + const secrets = readEncryptedSecretsFile(filePath); + if (key in secrets) { + delete secrets[key]; + if (writeEncryptedSecretsFile(filePath, secrets)) { + deleted = true; + } + } + } + + if (deleted) { + log.info("Deleted secret", { key }); + return { success: true, storage: "keychain" }; + } + + return { success: false, error: "Secret not found or could not be deleted", storage: "none" }; +} + +/** + * Lists all stored secret keys (not values). + */ +export function listSecretKeys(config: Partial = {}): string[] { + const cfg = { ...DEFAULT_CONFIG, ...config }; + const keys: Set = new Set(); + + // Note: Listing keychain entries programmatically is complex and varies by platform. + // For now, we only list keys from the encrypted file. + + if (cfg.enableEncryptedFallback) { + const filePath = cfg.encryptedFilePath ?? getDefaultEncryptedSecretsPath(); + const secrets = readEncryptedSecretsFile(filePath); + for (const key of Object.keys(secrets)) { + keys.add(key); + } + } + + return Array.from(keys); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MIGRATION UTILITIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Migrates a plain-text config value to secure storage. + */ +export function migrateToSecureStorage( + key: string, + plainValue: string, + config: Partial = {}, +): SecretsResult { + const result = storeSecret(key, plainValue, config); + if (result.success) { + log.info("Migrated secret to secure storage", { key, storage: result.storage }); + } + return result; +} + +/** + * Checks if a secret exists in any storage backend. + */ +export function hasSecret(key: string, config: Partial = {}): boolean { + const result = getSecret(key, config); + return result.success; +} + +/** + * Generates a cryptographically secure random token. + */ +export function generateSecureToken(length: number = 32): string { + return crypto.randomBytes(length).toString("base64url"); +} + +/** + * Hashes a secret for comparison without storing the original. + */ +export function hashSecret(secret: string): string { + return crypto.createHash("sha256").update(secret).digest("hex"); +} + +/** + * Verifies a secret against a stored hash. + */ +export function verifySecretHash(secret: string, hash: string): boolean { + const computed = hashSecret(secret); + return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(hash)); +}