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