@iterable/mcp 1.0.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -29
- package/TOOLS.md +23 -19
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -5
- package/dist/config.js.map +1 -1
- package/dist/install.d.ts +6 -0
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +174 -157
- package/dist/install.js.map +1 -1
- package/dist/key-manager.d.ts +56 -33
- package/dist/key-manager.d.ts.map +1 -1
- package/dist/key-manager.js +297 -145
- package/dist/key-manager.js.map +1 -1
- package/dist/keys-cli.d.ts.map +1 -1
- package/dist/keys-cli.js +27 -18
- package/dist/keys-cli.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +8 -2
- package/dist/server.js.map +1 -1
- package/dist/utils/formatting.d.ts +6 -1
- package/dist/utils/formatting.d.ts.map +1 -1
- package/dist/utils/formatting.js +10 -1
- package/dist/utils/formatting.js.map +1 -1
- package/dist/utils/ui.d.ts +1 -1
- package/dist/utils/ui.js +1 -1
- package/package.json +39 -30
package/dist/key-manager.js
CHANGED
|
@@ -1,67 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Secure API Key Management
|
|
2
|
+
* Secure API Key Management with cross-platform support
|
|
3
3
|
*
|
|
4
|
-
* This module provides secure storage and management of multiple Iterable API keys
|
|
5
|
-
* using macOS Keychain via the native `security` command-line tool.
|
|
4
|
+
* This module provides secure storage and management of multiple Iterable API keys.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
* - macOS:
|
|
9
|
-
* - Windows
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - API keys: Stored securely in macOS Keychain (never on disk)
|
|
13
|
-
* - Metadata: Stored in ~/.iterable-mcp/keys.json (names, IDs, endpoints, timestamps)
|
|
14
|
-
* - Lock file: ~/.iterable-mcp/keys.lock (prevents concurrent modifications)
|
|
6
|
+
* Storage Strategy:
|
|
7
|
+
* - macOS: API keys in Keychain, metadata in ~/.iterable-mcp/keys.json
|
|
8
|
+
* - Windows: API keys encrypted with DPAPI in ~/.iterable-mcp/keys.json
|
|
9
|
+
* - Linux: API keys and metadata in ~/.iterable-mcp/keys.json (mode 0o600)
|
|
10
|
+
* - Lock file: ~/.iterable-mcp/keys.lock prevents concurrent modifications
|
|
15
11
|
*
|
|
16
12
|
* Security Features:
|
|
17
|
-
* - Uses spawn() with argument arrays to prevent shell injection
|
|
18
|
-
* - File-based locking for concurrent access protection
|
|
19
|
-
* - Test-specific service name to isolate test keychain entries
|
|
20
13
|
* - API key format validation (32-char lowercase hex)
|
|
21
|
-
* - HTTPS-only URL validation
|
|
14
|
+
* - HTTPS-only URL validation (except localhost)
|
|
22
15
|
* - Duplicate key detection (both names and values)
|
|
16
|
+
* - File-based locking for concurrent access protection
|
|
17
|
+
* - Restrictive file permissions where supported (Linux/macOS)
|
|
23
18
|
*/
|
|
24
19
|
import { logger } from "@iterable/api";
|
|
25
|
-
import {
|
|
20
|
+
import { execFile } from "child_process";
|
|
26
21
|
import { randomUUID } from "crypto";
|
|
27
22
|
import { promises as fs } from "fs";
|
|
28
23
|
import os from "os";
|
|
29
24
|
import path from "path";
|
|
25
|
+
import { promisify } from "util";
|
|
30
26
|
import { isHttpsOrLocalhost, isLocalhostHost } from "./utils/url.js";
|
|
31
27
|
// Service name for keychain entries
|
|
32
28
|
// IMPORTANT: This must be constant to avoid data loss. Never use NODE_ENV here!
|
|
33
29
|
// Tests should use dependency injection with mock execSecurity instead.
|
|
34
30
|
const SERVICE_NAME = "iterable-mcp";
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
35
32
|
/**
|
|
36
33
|
* Safely execute macOS security command with proper argument escaping
|
|
37
|
-
* Uses
|
|
34
|
+
* Uses execFile to prevent shell injection vulnerabilities
|
|
38
35
|
*/
|
|
39
36
|
async function execSecurityDefault(args) {
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
stdout += data.toString();
|
|
48
|
-
});
|
|
49
|
-
child.stderr.on("data", (data) => {
|
|
50
|
-
stderr += data.toString();
|
|
51
|
-
});
|
|
52
|
-
child.on("error", (error) => {
|
|
53
|
-
reject(new Error(`Failed to execute security command: ${error.message}`));
|
|
54
|
-
});
|
|
55
|
-
child.on("close", (code) => {
|
|
56
|
-
if (code === 0) {
|
|
57
|
-
resolve(stdout.trim());
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
reject(new Error(`Security command failed with code ${code}: ${stderr.trim() || stdout.trim()}`));
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
});
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFileAsync("security", args);
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`Security command failed: ${error.stderr?.toString().trim() || error.message}`);
|
|
43
|
+
}
|
|
64
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Storage method for API keys
|
|
47
|
+
*/
|
|
48
|
+
var StorageMethod;
|
|
49
|
+
(function (StorageMethod) {
|
|
50
|
+
/** macOS Keychain - keys stored and encrypted by OS keychain service */
|
|
51
|
+
StorageMethod["KEYCHAIN"] = "keychain";
|
|
52
|
+
/** Windows DPAPI - keys encrypted by OS (user-scoped keys), stored in JSON file */
|
|
53
|
+
StorageMethod["DPAPI"] = "dpapi";
|
|
54
|
+
/** File storage - plaintext keys in JSON file with restrictive permissions (Linux/other) */
|
|
55
|
+
StorageMethod["FILE"] = "file";
|
|
56
|
+
})(StorageMethod || (StorageMethod = {}));
|
|
65
57
|
export class KeyManager {
|
|
66
58
|
configDir;
|
|
67
59
|
metadataFile;
|
|
@@ -69,25 +61,39 @@ export class KeyManager {
|
|
|
69
61
|
store = null;
|
|
70
62
|
saveLock = null;
|
|
71
63
|
execSecurity;
|
|
64
|
+
storageMethod;
|
|
72
65
|
/**
|
|
73
66
|
* Create a new KeyManager instance
|
|
74
67
|
*
|
|
75
68
|
* @param configDir - Optional custom config directory (defaults to ~/.iterable-mcp)
|
|
76
69
|
* @param execSecurity - Optional security command executor (for dependency injection in tests)
|
|
77
|
-
* @throws {Error} If not running on macOS and no mock executor is provided
|
|
78
70
|
*/
|
|
79
71
|
constructor(configDir, execSecurity) {
|
|
80
|
-
// Check if running on macOS (skip check in tests when mock is provided)
|
|
81
|
-
if (!execSecurity && process.platform !== "darwin") {
|
|
82
|
-
throw new Error("Key Manager only supports macOS. Windows and Linux users should use the ITERABLE_API_KEY environment variable.");
|
|
83
|
-
}
|
|
84
72
|
this.configDir = configDir || path.join(os.homedir(), ".iterable-mcp");
|
|
85
73
|
this.metadataFile = path.join(this.configDir, "keys.json");
|
|
86
74
|
this.lockFile = path.join(this.configDir, "keys.lock");
|
|
87
75
|
this.execSecurity = execSecurity || execSecurityDefault;
|
|
76
|
+
// Determine storage method based on platform
|
|
77
|
+
// Can be overridden via ITERABLE_MCP_FORCE_FILE_STORAGE=true
|
|
78
|
+
if (process.env.ITERABLE_MCP_FORCE_FILE_STORAGE === "true") {
|
|
79
|
+
// Force file storage mode (for testing or debugging)
|
|
80
|
+
this.storageMethod = StorageMethod.FILE;
|
|
81
|
+
}
|
|
82
|
+
else if (execSecurity || process.platform === "darwin") {
|
|
83
|
+
// Use Keychain on macOS or when execSecurity is provided (for tests)
|
|
84
|
+
this.storageMethod = StorageMethod.KEYCHAIN;
|
|
85
|
+
}
|
|
86
|
+
else if (process.platform === "win32") {
|
|
87
|
+
// Use DPAPI on Windows
|
|
88
|
+
this.storageMethod = StorageMethod.DPAPI;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Use file storage on other platforms
|
|
92
|
+
this.storageMethod = StorageMethod.FILE;
|
|
93
|
+
}
|
|
88
94
|
}
|
|
89
95
|
/**
|
|
90
|
-
* Validate metadata against keychain and clean up orphaned entries
|
|
96
|
+
* Validate metadata against keychain and clean up orphaned entries (macOS only)
|
|
91
97
|
*
|
|
92
98
|
* Checks if each key in metadata still exists in the keychain.
|
|
93
99
|
* Removes any metadata entries that no longer have corresponding keychain entries.
|
|
@@ -96,7 +102,10 @@ export class KeyManager {
|
|
|
96
102
|
* @returns Array of cleaned up key names (if any)
|
|
97
103
|
*/
|
|
98
104
|
async validateAndCleanup() {
|
|
99
|
-
|
|
105
|
+
// Only validate Keychain on macOS
|
|
106
|
+
if (this.storageMethod !== StorageMethod.KEYCHAIN ||
|
|
107
|
+
!this.store ||
|
|
108
|
+
this.store.keys.length === 0) {
|
|
100
109
|
return [];
|
|
101
110
|
}
|
|
102
111
|
const orphanedKeys = [];
|
|
@@ -393,18 +402,85 @@ export class KeyManager {
|
|
|
393
402
|
console.warn(`⚠️ Warning: Using non-standard Iterable domain: ${url.hostname}`);
|
|
394
403
|
}
|
|
395
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Encrypt data using Windows DPAPI
|
|
407
|
+
*
|
|
408
|
+
* Security: Uses native C++ bindings to Windows DPAPI (Data Protection API).
|
|
409
|
+
* - No shell execution or code evaluation
|
|
410
|
+
* - Encrypted data can only be decrypted by the same user on the same machine
|
|
411
|
+
* - Uses CurrentUser scope for user-level protection
|
|
412
|
+
* - No optional entropy parameter (null) for simplicity
|
|
413
|
+
*
|
|
414
|
+
* @param text - Plain text to encrypt (validated as 32-char hex by caller)
|
|
415
|
+
* @returns Base64-encoded encrypted blob
|
|
416
|
+
* @throws {Error} If DPAPI encryption fails or platform is not Windows
|
|
417
|
+
*/
|
|
418
|
+
async encryptWindows(text) {
|
|
419
|
+
if (process.platform !== "win32") {
|
|
420
|
+
throw new Error("DPAPI encryption is only supported on Windows");
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
// Dynamic import to avoid loading native module on non-Windows platforms
|
|
424
|
+
const { Dpapi } = await import("@primno/dpapi");
|
|
425
|
+
// Convert string to buffer (UTF-8 encoding)
|
|
426
|
+
const plainBuffer = Buffer.from(text, "utf-8");
|
|
427
|
+
// Encrypt using DPAPI (returns Uint8Array)
|
|
428
|
+
// Parameters: data, optionalEntropy (null), scope ("CurrentUser")
|
|
429
|
+
const encryptedBytes = Dpapi.protectData(plainBuffer, null, "CurrentUser");
|
|
430
|
+
// Convert to Buffer and encode as base64 for JSON storage
|
|
431
|
+
return Buffer.from(encryptedBytes).toString("base64");
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
logger.error("DPAPI encryption failed", { error });
|
|
435
|
+
throw new Error(`Failed to encrypt data with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Decrypt data using Windows DPAPI
|
|
440
|
+
*
|
|
441
|
+
* Security: Uses native C++ bindings to Windows DPAPI.
|
|
442
|
+
* - No shell execution or code evaluation
|
|
443
|
+
* - Only the user who encrypted the data can decrypt it
|
|
444
|
+
* - Validates base64 input before decryption
|
|
445
|
+
*
|
|
446
|
+
* @param encryptedBase64 - Base64-encoded encrypted blob
|
|
447
|
+
* @returns Decrypted plain text
|
|
448
|
+
* @throws {Error} If DPAPI decryption fails, data is corrupt, or platform is not Windows
|
|
449
|
+
*/
|
|
450
|
+
async decryptWindows(encryptedBase64) {
|
|
451
|
+
if (process.platform !== "win32") {
|
|
452
|
+
throw new Error("DPAPI decryption is only supported on Windows");
|
|
453
|
+
}
|
|
454
|
+
// Validate base64 format to prevent invalid data from reaching DPAPI
|
|
455
|
+
if (!/^[A-Za-z0-9+/]+=*$/.test(encryptedBase64)) {
|
|
456
|
+
throw new Error("Invalid encrypted data format (not valid base64)");
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
// Dynamic import to avoid loading native module on non-Windows platforms
|
|
460
|
+
const { Dpapi } = await import("@primno/dpapi");
|
|
461
|
+
// Decode base64 to buffer
|
|
462
|
+
const encryptedBuffer = Buffer.from(encryptedBase64, "base64");
|
|
463
|
+
// Decrypt using DPAPI (returns Uint8Array)
|
|
464
|
+
// Parameters: encryptedData, optionalEntropy (null), scope ("CurrentUser")
|
|
465
|
+
const decryptedBytes = Dpapi.unprotectData(encryptedBuffer, null, "CurrentUser");
|
|
466
|
+
// Convert to Buffer and decode as UTF-8
|
|
467
|
+
return Buffer.from(decryptedBytes).toString("utf-8");
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
logger.error("DPAPI decryption failed", { error });
|
|
471
|
+
throw new Error(`Failed to decrypt data with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
396
474
|
/**
|
|
397
475
|
* Add a new API key
|
|
398
476
|
*
|
|
399
|
-
*
|
|
400
|
-
* (name, base URL, timestamps) to disk. Each key is tightly coupled to
|
|
401
|
-
* its API endpoint.
|
|
477
|
+
* Stores an API key securely (Keychain on macOS, file on other platforms).
|
|
402
478
|
*
|
|
403
479
|
* @param name - User-friendly name for the key (must be unique)
|
|
404
480
|
* @param apiKey - 32-character lowercase hexadecimal Iterable API key
|
|
405
481
|
* @param baseUrl - Iterable API base URL (must be HTTPS)
|
|
406
482
|
* @returns The unique ID generated for this key
|
|
407
|
-
* @throws {Error} If the key name already exists, validation fails, or
|
|
483
|
+
* @throws {Error} If the key name already exists, validation fails, or storage fails
|
|
408
484
|
*/
|
|
409
485
|
async addKey(name, apiKey, baseUrl, envOverrides) {
|
|
410
486
|
if (!this.store) {
|
|
@@ -425,24 +501,6 @@ export class KeyManager {
|
|
|
425
501
|
}
|
|
426
502
|
// Generate unique ID
|
|
427
503
|
const id = this.generateId();
|
|
428
|
-
// Store in macOS Keychain using security command
|
|
429
|
-
// Using spawn with argument array to prevent shell injection
|
|
430
|
-
try {
|
|
431
|
-
await this.execSecurity([
|
|
432
|
-
"add-generic-password",
|
|
433
|
-
"-a",
|
|
434
|
-
id,
|
|
435
|
-
"-s",
|
|
436
|
-
SERVICE_NAME,
|
|
437
|
-
"-w",
|
|
438
|
-
apiKey,
|
|
439
|
-
"-U",
|
|
440
|
-
]);
|
|
441
|
-
}
|
|
442
|
-
catch (error) {
|
|
443
|
-
logger.error("Failed to store key in keychain", { error, id });
|
|
444
|
-
throw new Error(`Failed to store key in macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
|
|
445
|
-
}
|
|
446
504
|
// If this is the first key, make it active
|
|
447
505
|
const isActive = this.store.keys.length === 0;
|
|
448
506
|
// Create metadata
|
|
@@ -456,6 +514,40 @@ export class KeyManager {
|
|
|
456
514
|
? { env: envOverrides }
|
|
457
515
|
: {}),
|
|
458
516
|
};
|
|
517
|
+
// Store API key
|
|
518
|
+
switch (this.storageMethod) {
|
|
519
|
+
case StorageMethod.KEYCHAIN:
|
|
520
|
+
try {
|
|
521
|
+
await this.execSecurity([
|
|
522
|
+
"add-generic-password",
|
|
523
|
+
"-a",
|
|
524
|
+
id,
|
|
525
|
+
"-s",
|
|
526
|
+
SERVICE_NAME,
|
|
527
|
+
"-w",
|
|
528
|
+
apiKey,
|
|
529
|
+
"-U",
|
|
530
|
+
]);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
logger.error("Failed to store key in keychain", { error, id });
|
|
534
|
+
throw new Error(`Failed to store key in macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
|
|
535
|
+
}
|
|
536
|
+
break;
|
|
537
|
+
case StorageMethod.DPAPI:
|
|
538
|
+
try {
|
|
539
|
+
metadata.encryptedApiKey = await this.encryptWindows(apiKey);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
logger.error("Failed to encrypt key with DPAPI", { error, id });
|
|
543
|
+
throw new Error(`Failed to encrypt key with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
|
|
544
|
+
}
|
|
545
|
+
break;
|
|
546
|
+
default:
|
|
547
|
+
// Linux/Other: Store in JSON
|
|
548
|
+
metadata.apiKey = apiKey;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
459
551
|
// Store metadata
|
|
460
552
|
this.store.keys.push(metadata);
|
|
461
553
|
await this.saveMetadata();
|
|
@@ -499,11 +591,11 @@ export class KeyManager {
|
|
|
499
591
|
/**
|
|
500
592
|
* Get a key by ID or name
|
|
501
593
|
*
|
|
502
|
-
* Retrieves the actual API key value from
|
|
594
|
+
* Retrieves the actual API key value from storage.
|
|
503
595
|
*
|
|
504
596
|
* @param idOrName - The unique ID or user-friendly name of the key
|
|
505
597
|
* @returns The API key value, or null if not found
|
|
506
|
-
* @throws {Error} If
|
|
598
|
+
* @throws {Error} If storage access fails
|
|
507
599
|
*/
|
|
508
600
|
async getKey(idOrName) {
|
|
509
601
|
if (!this.store) {
|
|
@@ -517,31 +609,62 @@ export class KeyManager {
|
|
|
517
609
|
if (!keyMeta) {
|
|
518
610
|
return null;
|
|
519
611
|
}
|
|
520
|
-
// Get
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
612
|
+
// Get API key
|
|
613
|
+
switch (this.storageMethod) {
|
|
614
|
+
case StorageMethod.KEYCHAIN:
|
|
615
|
+
// macOS: Get from Keychain
|
|
616
|
+
try {
|
|
617
|
+
const apiKey = await this.execSecurity([
|
|
618
|
+
"find-generic-password",
|
|
619
|
+
"-a",
|
|
620
|
+
keyMeta.id,
|
|
621
|
+
"-s",
|
|
622
|
+
SERVICE_NAME,
|
|
623
|
+
"-w",
|
|
624
|
+
]);
|
|
625
|
+
if (!apiKey) {
|
|
626
|
+
logger.error("Key not found in keychain", { id: keyMeta.id });
|
|
627
|
+
throw new Error(`Key not found in macOS Keychain for ID ${keyMeta.id}`);
|
|
628
|
+
}
|
|
629
|
+
return apiKey;
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
logger.error("Failed to retrieve key from keychain", {
|
|
633
|
+
error,
|
|
634
|
+
id: keyMeta.id,
|
|
635
|
+
});
|
|
636
|
+
throw new Error(`Failed to retrieve key from macOS Keychain: ${error instanceof Error ? error.message : String(error)}`);
|
|
637
|
+
}
|
|
638
|
+
case StorageMethod.DPAPI:
|
|
639
|
+
// Windows: Decrypt from JSON
|
|
640
|
+
if (keyMeta.encryptedApiKey) {
|
|
641
|
+
try {
|
|
642
|
+
return await this.decryptWindows(keyMeta.encryptedApiKey);
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
logger.error("Failed to decrypt key with DPAPI", {
|
|
646
|
+
error,
|
|
647
|
+
id: keyMeta.id,
|
|
648
|
+
});
|
|
649
|
+
throw new Error(`Failed to decrypt key with Windows DPAPI: ${error instanceof Error ? error.message : String(error)}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
else if (keyMeta.apiKey) {
|
|
653
|
+
// Fallback for legacy keys stored in plaintext on Windows
|
|
654
|
+
return keyMeta.apiKey;
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
logger.error("Key not found in metadata", { id: keyMeta.id });
|
|
658
|
+
throw new Error(`Key not found in storage for ID ${keyMeta.id}`);
|
|
659
|
+
}
|
|
660
|
+
default:
|
|
661
|
+
// Linux/Other: Get from JSON
|
|
662
|
+
if (!keyMeta.apiKey) {
|
|
663
|
+
logger.error("Key not found in metadata", { id: keyMeta.id });
|
|
664
|
+
throw new Error(`Key not found in storage for ID ${keyMeta.id}`);
|
|
665
|
+
}
|
|
666
|
+
return keyMeta.apiKey;
|
|
543
667
|
}
|
|
544
|
-
return apiKey;
|
|
545
668
|
}
|
|
546
669
|
/**
|
|
547
670
|
* Get the currently active key
|
|
@@ -550,7 +673,7 @@ export class KeyManager {
|
|
|
550
673
|
* Only one key can be active at a time.
|
|
551
674
|
*
|
|
552
675
|
* @returns The active API key value, or null if no key is active
|
|
553
|
-
* @throws {Error} If
|
|
676
|
+
* @throws {Error} If storage access fails
|
|
554
677
|
*/
|
|
555
678
|
async getActiveKey() {
|
|
556
679
|
if (!this.store) {
|
|
@@ -569,7 +692,7 @@ export class KeyManager {
|
|
|
569
692
|
* Get the active key metadata
|
|
570
693
|
*
|
|
571
694
|
* Returns metadata for the currently active key without retrieving the
|
|
572
|
-
* actual API key value
|
|
695
|
+
* actual API key value.
|
|
573
696
|
*
|
|
574
697
|
* @returns The active key metadata, or null if no key is active
|
|
575
698
|
* @throws {Error} If the key store is not initialized
|
|
@@ -616,10 +739,9 @@ export class KeyManager {
|
|
|
616
739
|
/**
|
|
617
740
|
* Delete a key by ID
|
|
618
741
|
*
|
|
619
|
-
* Removes a key from
|
|
742
|
+
* Removes a key from storage and the metadata store.
|
|
620
743
|
* The currently active key cannot be deleted - you must activate a different
|
|
621
|
-
* key first.
|
|
622
|
-
* and a warning will be displayed.
|
|
744
|
+
* key first.
|
|
623
745
|
*
|
|
624
746
|
* Note: Only accepts key ID (not name) since IDs are guaranteed unique.
|
|
625
747
|
*
|
|
@@ -643,38 +765,46 @@ export class KeyManager {
|
|
|
643
765
|
if (keyMeta.isActive) {
|
|
644
766
|
throw new Error(`Cannot delete the currently active key "${keyMeta.name}". Please activate a different key first.`);
|
|
645
767
|
}
|
|
646
|
-
// Delete from
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
error,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
768
|
+
// Delete from storage
|
|
769
|
+
if (this.storageMethod === StorageMethod.KEYCHAIN) {
|
|
770
|
+
// macOS: Delete from Keychain
|
|
771
|
+
let keychainDeleted = false;
|
|
772
|
+
try {
|
|
773
|
+
await this.execSecurity([
|
|
774
|
+
"delete-generic-password",
|
|
775
|
+
"-a",
|
|
776
|
+
keyMeta.id,
|
|
777
|
+
"-s",
|
|
778
|
+
SERVICE_NAME,
|
|
779
|
+
]);
|
|
780
|
+
keychainDeleted = true;
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
logger.error("Failed to delete key from keychain", {
|
|
784
|
+
error,
|
|
785
|
+
id: keyMeta.id,
|
|
786
|
+
});
|
|
787
|
+
// Continue anyway to clean up metadata, but warn the user
|
|
788
|
+
}
|
|
789
|
+
// Remove from metadata
|
|
790
|
+
this.store.keys.splice(index, 1);
|
|
791
|
+
await this.saveMetadata();
|
|
792
|
+
if (!keychainDeleted) {
|
|
793
|
+
logger.warn("Key removed from metadata but may still exist in Keychain", {
|
|
794
|
+
id: keyMeta.id,
|
|
795
|
+
name: keyMeta.name,
|
|
796
|
+
});
|
|
797
|
+
console.warn(`⚠️ Warning: Key "${keyMeta.name}" removed from metadata but may still exist in Keychain.`);
|
|
798
|
+
console.warn(` To manually remove: security delete-generic-password -a "${keyMeta.id}" -s "${SERVICE_NAME}"`);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
logger.info("API key deleted", { id: keyMeta.id, name: keyMeta.name });
|
|
802
|
+
}
|
|
676
803
|
}
|
|
677
804
|
else {
|
|
805
|
+
// Windows/Linux: Just remove from JSON
|
|
806
|
+
this.store.keys.splice(index, 1);
|
|
807
|
+
await this.saveMetadata();
|
|
678
808
|
logger.info("API key deleted", { id: keyMeta.id, name: keyMeta.name });
|
|
679
809
|
}
|
|
680
810
|
}
|
|
@@ -698,12 +828,12 @@ export class KeyManager {
|
|
|
698
828
|
/**
|
|
699
829
|
* Find a key by its actual API key value
|
|
700
830
|
*
|
|
701
|
-
* Checks all stored keys
|
|
702
|
-
*
|
|
831
|
+
* Checks all stored keys to see if the given API key value already exists.
|
|
832
|
+
* Useful for preventing duplicate key values.
|
|
703
833
|
*
|
|
704
834
|
* @param apiKeyValue - The API key value to search for
|
|
705
835
|
* @returns The metadata of the matching key, or null if not found
|
|
706
|
-
* @throws {Error} If
|
|
836
|
+
* @throws {Error} If storage access fails
|
|
707
837
|
*/
|
|
708
838
|
async findKeyByValue(apiKeyValue) {
|
|
709
839
|
if (!this.store) {
|
|
@@ -715,20 +845,42 @@ export class KeyManager {
|
|
|
715
845
|
// Check each key to see if the value matches
|
|
716
846
|
for (const keyMeta of this.store.keys) {
|
|
717
847
|
try {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
848
|
+
let storedKey;
|
|
849
|
+
switch (this.storageMethod) {
|
|
850
|
+
case StorageMethod.KEYCHAIN:
|
|
851
|
+
storedKey = await this.execSecurity([
|
|
852
|
+
"find-generic-password",
|
|
853
|
+
"-a",
|
|
854
|
+
keyMeta.id,
|
|
855
|
+
"-s",
|
|
856
|
+
SERVICE_NAME,
|
|
857
|
+
"-w",
|
|
858
|
+
]);
|
|
859
|
+
break;
|
|
860
|
+
case StorageMethod.DPAPI:
|
|
861
|
+
if (keyMeta.encryptedApiKey) {
|
|
862
|
+
storedKey = await this.decryptWindows(keyMeta.encryptedApiKey);
|
|
863
|
+
}
|
|
864
|
+
else if (keyMeta.apiKey) {
|
|
865
|
+
storedKey = keyMeta.apiKey;
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
break;
|
|
871
|
+
default:
|
|
872
|
+
if (!keyMeta.apiKey) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
storedKey = keyMeta.apiKey;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
726
878
|
if (storedKey.trim() === apiKeyValue) {
|
|
727
879
|
return keyMeta;
|
|
728
880
|
}
|
|
729
881
|
}
|
|
730
882
|
catch {
|
|
731
|
-
// Skip keys that can't be retrieved (may have been manually deleted
|
|
883
|
+
// Skip keys that can't be retrieved (may have been manually deleted)
|
|
732
884
|
continue;
|
|
733
885
|
}
|
|
734
886
|
}
|
|
@@ -739,13 +891,13 @@ export class KeyManager {
|
|
|
739
891
|
*
|
|
740
892
|
* Adds an API key to the key manager with a default name if it doesn't
|
|
741
893
|
* already exist. This is used during the migration path from environment
|
|
742
|
-
* variable-based configuration to
|
|
894
|
+
* variable-based configuration to key manager storage.
|
|
743
895
|
*
|
|
744
896
|
* @param apiKey - The 32-character lowercase hexadecimal API key to migrate
|
|
745
897
|
* @param baseUrl - The Iterable API base URL for this key
|
|
746
898
|
* @param name - The name to assign (defaults to "default")
|
|
747
899
|
* @returns The unique ID of the migrated key (existing or newly created)
|
|
748
|
-
* @throws {Error} If validation fails or
|
|
900
|
+
* @throws {Error} If validation fails or storage fails
|
|
749
901
|
*/
|
|
750
902
|
async migrateLegacyKey(apiKey, baseUrl, name = "default") {
|
|
751
903
|
// Check if a key with this name already exists
|