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 <noreply@anthropic.com>
This commit is contained in:
parent
6a960c5137
commit
86261f4860
294
src/infra/secrets-manager.test.ts
Normal file
294
src/infra/secrets-manager.test.ts
Normal file
@ -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<string>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
565
src/infra/secrets-manager.ts
Normal file
565
src/infra/secrets-manager.ts
Normal file
@ -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<T> = {
|
||||
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<string, string> {
|
||||
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<string, string>): 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<SecretsConfig> = {},
|
||||
): SecretsResult<void> {
|
||||
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<SecretsConfig> = {}): SecretsResult<string> {
|
||||
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<SecretsConfig> = {},
|
||||
): SecretsResult<void> {
|
||||
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<SecretsConfig> = {}): string[] {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
const keys: Set<string> = 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<SecretsConfig> = {},
|
||||
): SecretsResult<void> {
|
||||
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<SecretsConfig> = {}): 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));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user