From 7735f9a2169f3bf5b169721b6caa9035daf424d1 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Tue, 18 Nov 2025 18:13:45 -0800 Subject: [PATCH] use key manager on all platforms --- src/install.ts | 277 +++++++++++++++++++++------------------------ src/key-manager.ts | 47 ++++---- 2 files changed, 150 insertions(+), 174 deletions(-) diff --git a/src/install.ts b/src/install.ts index 6fc6b88..d78a555 100644 --- a/src/install.ts +++ b/src/install.ts @@ -8,6 +8,8 @@ import path from "path"; import { fileURLToPath } from "url"; import { promisify } from "util"; +import { getKeyManager } from "./key-manager.js"; + const { dirname, join } = path; // IMPORTANT: UI imports are loaded lazily inside functions to avoid ESM issues in Jest/CommonJS. @@ -328,8 +330,11 @@ export const setupMcpServer = async (): Promise => { "Security Features", [ "• API keys prompted interactively (never in shell history)", - "• macOS: Keys stored securely in Keychain", - "• Windows/Linux: Keys stored in ~/.iterable-mcp with file permissions", + process.platform === "darwin" + ? "• Keys are stored securely in the macOS Keychain" + : process.platform === "win32" + ? "• Keys are stored in ~/.iterable-mcp/keys.json" + : "• Keys are stored in ~/.iterable-mcp/keys.json with restricted permissions", "• Each key coupled to its endpoint (US/EU/custom)", ], { icon: icons.lock, theme: "info", padding: 1 } @@ -427,9 +432,8 @@ export const setupMcpServer = async (): Promise => { } } - // 2) If not using env key, offer using an existing Keychain key (macOS) - if (!apiKey && process.platform === "darwin") { - const { getKeyManager } = await import("./key-manager.js"); + // 2) If not using env key, offer using an existing stored key + if (!apiKey) { const km = getKeyManager(); const keys = await km.listKeys().catch(() => [] as any[]); if (Array.isArray(keys) && keys.length > 0) { @@ -437,7 +441,7 @@ export const setupMcpServer = async (): Promise => { { type: "confirm", name: "useExisting", - message: `Found ${keys.length} existing Iterable API key${keys.length === 1 ? "" : "s"} in macOS Keychain. Use one of these?`, + message: `Found ${keys.length} existing Iterable API key${keys.length === 1 ? "" : "s"} in key storage. Use one of these?`, default: true, }, ]); @@ -474,7 +478,7 @@ export const setupMcpServer = async (): Promise => { showError( e instanceof Error ? e.message - : "Failed to load selected key from Keychain" + : "Failed to load selected key from storage" ); process.exit(1); } @@ -535,159 +539,143 @@ export const setupMcpServer = async (): Promise => { ); console.log(); - // Step 3: Store the key securely (macOS only). On other platforms, use env vars. + // Step 3: Store the key securely let existingKeyWithValue: any = null; - if (process.platform === "darwin") { - const { getKeyManager } = await import("./key-manager.js"); - const keyManager = getKeyManager(); - - spinner.start("Initializing secure key storage..."); - await keyManager.initialize(); - spinner.succeed("Key storage ready"); - - // Check if key already exists - if (!usedExistingKey && apiKey) { - spinner.start("Checking for existing keys..."); - existingKeyWithValue = await keyManager.findKeyByValue( - apiKey as string + const keyManager = getKeyManager(); + + spinner.start("Initializing secure key storage..."); + await keyManager.initialize(); + spinner.succeed("Key storage ready"); + + // Check if key already exists + if (!usedExistingKey && apiKey) { + spinner.start("Checking for existing keys..."); + existingKeyWithValue = await keyManager.findKeyByValue(apiKey as string); + spinner.stop(); + } + + if (usedExistingKey) { + // Offer to activate the selected existing key if not already active + if (!selectedExistingMeta?.isActive) { + const { activateExisting } = await inquirer.prompt([ + { + type: "confirm", + name: "activateExisting", + message: `Set "${selectedExistingMeta?.name}" as your active API key?`, + default: true, + }, + ]); + if (activateExisting) { + await keyManager.setActiveKey(selectedExistingMeta!.id); + showSuccess(`"${selectedExistingMeta?.name}" is now your active key`); + } else { + showInfo("Keeping your current active key"); + } + } else { + showSuccess( + `"${selectedExistingMeta?.name}" is already your active key` ); - spinner.stop(); } + } else if (existingKeyWithValue) { + showInfo( + `This API key is already stored as "${existingKeyWithValue.name}"` + ); + usedKeyName = existingKeyWithValue.name; - if (usedExistingKey) { - // Offer to activate the selected existing key if not already active - if (!selectedExistingMeta?.isActive) { - const { activateExisting } = await inquirer.prompt([ - { - type: "confirm", - name: "activateExisting", - message: `Set "${selectedExistingMeta?.name}" as your active API key?`, - default: true, - }, - ]); - if (activateExisting) { - await keyManager.setActiveKey(selectedExistingMeta!.id); - showSuccess( - `"${selectedExistingMeta?.name}" is now your active key` - ); - } else { - showInfo("Keeping your current active key"); - } + if (!existingKeyWithValue.isActive) { + const { activateExisting } = await inquirer.prompt([ + { + type: "confirm", + name: "activateExisting", + message: `Set "${existingKeyWithValue.name}" as your active API key?`, + default: true, + }, + ]); + + if (activateExisting) { + await keyManager.setActiveKey(existingKeyWithValue.id); + showSuccess(`"${existingKeyWithValue.name}" is now your active key`); } else { - showSuccess( - `"${selectedExistingMeta?.name}" is already your active key` - ); + showInfo("Keeping your current active key"); } - } else if (existingKeyWithValue) { - showInfo( - `This API key is already stored as "${existingKeyWithValue.name}"` + } else { + showSuccess( + `"${existingKeyWithValue.name}" is already your active key` ); - usedKeyName = existingKeyWithValue.name; + } + } else { + // New key - prompt for name + const { newKeyName } = await inquirer.prompt([ + { + type: "input", + name: "newKeyName", + message: "Enter a name for this API key:", + default: "default", + validate: (input: string) => { + if (input && input.length > 50) + return "Key name must be 50 characters or less"; + return true; + }, + }, + ]); + + keyName = newKeyName || "default"; + + spinner.start("Storing API key securely..."); + try { + const newKeyId = await keyManager.addKey( + keyName, + apiKey as string, + baseUrl as string + ); + spinner.succeed("API key stored successfully!"); - if (!existingKeyWithValue.isActive) { - const { activateExisting } = await inquirer.prompt([ + console.log(); + console.log(formatKeyValue("Name", keyName, chalk.white.bold)); + console.log(formatKeyValue("ID", newKeyId, chalk.gray)); + console.log(formatKeyValue("Endpoint", baseUrl as string, chalk.cyan)); + console.log(); + usedKeyName = keyName; + + // Check if we should activate this key + const allKeys = await keyManager.listKeys(); + if (allKeys.length > 1) { + const { activateNew } = await inquirer.prompt([ { type: "confirm", - name: "activateExisting", - message: `Set "${existingKeyWithValue.name}" as your active API key?`, + name: "activateNew", + message: `Set "${keyName}" as your active API key?`, default: true, }, ]); - if (activateExisting) { - await keyManager.setActiveKey(existingKeyWithValue.id); - showSuccess( - `"${existingKeyWithValue.name}" is now your active key` - ); + if (activateNew) { + await keyManager.setActiveKey(newKeyId); + showSuccess(`"${keyName}" is now your active key`); } else { - showInfo("Keeping your current active key"); - } - } else { - showSuccess( - `"${existingKeyWithValue.name}" is already your active key` - ); - } - } else { - // New key - prompt for name - const { newKeyName } = await inquirer.prompt([ - { - type: "input", - name: "newKeyName", - message: "Enter a name for this API key:", - default: "default", - validate: (input: string) => { - if (input && input.length > 50) - return "Key name must be 50 characters or less"; - return true; - }, - }, - ]); - - keyName = newKeyName || "default"; - - spinner.start("Storing API key securely in macOS Keychain..."); - try { - const newKeyId = await keyManager.addKey( - keyName, - apiKey as string, - baseUrl as string - ); - spinner.succeed("API key stored successfully!"); - - console.log(); - console.log(formatKeyValue("Name", keyName, chalk.white.bold)); - console.log(formatKeyValue("ID", newKeyId, chalk.gray)); - console.log( - formatKeyValue("Endpoint", baseUrl as string, chalk.cyan) - ); - console.log(); - usedKeyName = keyName; - - // Check if we should activate this key - const allKeys = await keyManager.listKeys(); - if (allKeys.length > 1) { - const { activateNew } = await inquirer.prompt([ - { - type: "confirm", - name: "activateNew", - message: `Set "${keyName}" as your active API key?`, - default: true, - }, - ]); - - if (activateNew) { - await keyManager.setActiveKey(newKeyId); - showSuccess(`"${keyName}" is now your active key`); - } else { - showInfo( - `Keeping your current active key. Run 'iterable-mcp keys activate "${keyName}"' to switch later.` - ); - } + showInfo( + `Keeping your current active key. Run 'iterable-mcp keys activate "${keyName}"' to switch later.` + ); } - } catch (error) { - const { sanitizeString } = await import("./utils/sanitize.js"); - spinner.fail("Failed to store API key"); - console.log(); - const msg = - error instanceof Error - ? sanitizeString(error.message) - : "Unknown error"; - showError(msg); - console.log(); - showInfo( - "Your API key was not stored. You can re-run 'iterable-mcp setup' to try again." - ); - showInfo( - "If the problem persists, verify Keychain access and disk permissions." - ); - process.exit(1); } + } catch (error) { + const { sanitizeString } = await import("./utils/sanitize.js"); + spinner.fail("Failed to store API key"); + console.log(); + const msg = + error instanceof Error + ? sanitizeString(error.message) + : "Unknown error"; + showError(msg); + console.log(); + showInfo( + "Your API key was not stored. You can re-run 'iterable-mcp setup' to try again." + ); + showInfo( + "If the problem persists, verify storage access and disk permissions." + ); + process.exit(1); } - } else { - // Non-macOS: do not use Keychain; rely on environment variables instead - usedKeyName = - usedKeyName || - (process.env.ITERABLE_API_KEY ? "Environment" : undefined); } // Step 4: Privacy & Security Settings (Basic by default; Advanced via --advanced) @@ -795,11 +783,8 @@ export const setupMcpServer = async (): Promise => { console.log(); // If we used an existing key, update its env overrides with chosen settings - if (usedKeyName && process.platform === "darwin") { + if (usedKeyName) { try { - const { getKeyManager } = await import("./key-manager.js"); - const keyManager = getKeyManager(); - await keyManager.initialize(); await keyManager.updateKeyEnv( usedKeyName, pickPersistablePermissionEnv(mcpEnv) diff --git a/src/key-manager.ts b/src/key-manager.ts index 32f2eaf..6589b29 100644 --- a/src/key-manager.ts +++ b/src/key-manager.ts @@ -3,22 +3,18 @@ * * This module provides secure storage and management of multiple Iterable API keys. * - * Platform Support: - * - macOS: Keys stored in Keychain, metadata in ~/.iterable-mcp/keys.json - * - Windows/Linux: Keys stored in ~/.iterable-mcp/keys.json with file permissions (0o600) - * - * Storage: - * - macOS: API keys in Keychain, metadata in keys.json - * - Windows/Linux: API keys and metadata in keys.json - * - Lock file: ~/.iterable-mcp/keys.lock (prevents concurrent modifications) + * Storage Strategy: + * - macOS: API keys in Keychain, metadata in ~/.iterable-mcp/keys.json + * - Windows: API keys and metadata in ~/.iterable-mcp/keys.json + * - Linux: API keys and metadata in ~/.iterable-mcp/keys.json (mode 0o600) + * - Lock file: ~/.iterable-mcp/keys.lock prevents concurrent modifications * * Security Features: - * - Uses spawn() with argument arrays to prevent shell injection (macOS) - * - File-based locking for concurrent access protection - * - Restrictive file permissions (0o600) on keys.json * - API key format validation (32-char lowercase hex) - * - HTTPS-only URL validation + * - HTTPS-only URL validation (except localhost) * - Duplicate key detection (both names and values) + * - File-based locking for concurrent access protection + * - Restrictive file permissions where supported (Linux/macOS) */ import { logger } from "@iterable/api"; @@ -90,7 +86,7 @@ export interface ApiKeyMetadata { isActive: boolean; /** Optional per-key environment overrides (extensible for future vars) */ env?: Record; - /** API key value (stored only on Windows/Linux, not macOS which uses Keychain) */ + /** API key value (only present when not using Keychain storage) */ apiKey?: string; } @@ -121,9 +117,8 @@ export class KeyManager { this.metadataFile = path.join(this.configDir, "keys.json"); this.lockFile = path.join(this.configDir, "keys.lock"); this.execSecurity = execSecurity || execSecurityDefault; - // Use Keychain on macOS, JSON file storage on Windows/Linux - // If mock execSecurity provided (tests), use Keychain mode - // Override via ITERABLE_MCP_FORCE_FILE_STORAGE for testing file storage + // Use Keychain on macOS (or when mock execSecurity provided for tests) + // Can be overridden via ITERABLE_MCP_FORCE_FILE_STORAGE=true this.useKeychain = (!!execSecurity || process.platform === "darwin") && process.env.ITERABLE_MCP_FORCE_FILE_STORAGE !== "true"; @@ -487,9 +482,7 @@ export class KeyManager { /** * Add a new API key * - * Stores an API key securely and saves its metadata to disk. - * - macOS: API key in Keychain, metadata in keys.json - * - Windows/Linux: API key and metadata in keys.json + * Stores an API key securely (Keychain on macOS, file on other platforms). * * @param name - User-friendly name for the key (must be unique) * @param apiKey - 32-character lowercase hexadecimal Iterable API key @@ -542,7 +535,7 @@ export class KeyManager { : {}), }; - // Store API key based on platform + // Store API key if (this.useKeychain) { // macOS: Store in Keychain try { @@ -622,8 +615,6 @@ export class KeyManager { * Get a key by ID or name * * Retrieves the actual API key value from storage. - * - macOS: Reads from Keychain - * - Windows/Linux: Reads from keys.json * * @param idOrName - The unique ID or user-friendly name of the key * @returns The API key value, or null if not found @@ -647,7 +638,7 @@ export class KeyManager { return null; } - // Get API key based on platform + // Get API key if (this.useKeychain) { // macOS: Get from Keychain try { @@ -694,7 +685,7 @@ export class KeyManager { * Only one key can be active at a time. * * @returns The active API key value, or null if no key is active - * @throws {Error} If keychain access fails + * @throws {Error} If storage access fails */ async getActiveKey(): Promise { if (!this.store) { @@ -717,7 +708,7 @@ export class KeyManager { * Get the active key metadata * * Returns metadata for the currently active key without retrieving the - * actual API key value from Keychain. + * actual API key value. * * @returns The active key metadata, or null if no key is active * @throws {Error} If the key store is not initialized @@ -810,7 +801,7 @@ export class KeyManager { ); } - // Delete from storage based on platform + // Delete from storage if (this.useKeychain) { // macOS: Delete from Keychain let keychainDeleted = false; @@ -939,13 +930,13 @@ export class KeyManager { * * Adds an API key to the key manager with a default name if it doesn't * already exist. This is used during the migration path from environment - * variable-based configuration to secure keychain storage. + * variable-based configuration to key manager storage. * * @param apiKey - The 32-character lowercase hexadecimal API key to migrate * @param baseUrl - The Iterable API base URL for this key * @param name - The name to assign (defaults to "default") * @returns The unique ID of the migrated key (existing or newly created) - * @throws {Error} If validation fails or keychain storage fails + * @throws {Error} If validation fails or storage fails */ async migrateLegacyKey( apiKey: string,