forked from portel-dev/ncp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecure-credential-store.ts
More file actions
333 lines (291 loc) · 9.82 KB
/
secure-credential-store.ts
File metadata and controls
333 lines (291 loc) · 9.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
/**
* Secure Credential Store with OS Keychain Integration
*
* Stores credentials securely using:
* 1. OS Keychain (macOS Keychain, Windows Credential Store, Linux Secret Service)
* 2. Fallback to AES-256 encrypted file storage if keychain unavailable
*
* Profile JSON files only store credential references, not actual secrets.
*/
import { logger } from '../utils/logger.js';
import { getTokenStore } from './token-store.js';
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'os';
const SERVICE_NAME = '@portel/ncp';
const CREDENTIAL_INDEX_FILE = path.join(homedir(), '.ncp', 'credentials.json');
export type CredentialType = 'bearer_token' | 'api_key' | 'oauth_token' | 'basic_auth' | 'custom';
export interface CredentialMetadata {
mcpName: string;
type: CredentialType;
description?: string;
createdAt: number;
updatedAt: number;
}
export interface BasicAuthCredential {
username: string;
password: string;
}
/**
* Secure credential storage using OS keychain with encrypted file fallback
*/
export class SecureCredentialStore {
private keychainAvailable: boolean = false;
private Entry: any = null;
private index: Map<string, CredentialMetadata> = new Map();
private initPromise: Promise<void>;
constructor() {
this.loadIndex();
this.initPromise = this.initializeKeychain();
}
/**
* Ensure keychain is initialized before operations
*/
private async ensureInitialized(): Promise<void> {
await this.initPromise;
}
/**
* Initialize OS keychain support
*/
private async initializeKeychain(): Promise<void> {
try {
// On Linux without a display session, libsecret's D-Bus call to the Secret
// Service blocks the Node.js event loop indefinitely — the keyring daemon is
// running but locked, and the GUI unlock dialog never appears in SSH/headless
// environments. Since @napi-rs/keyring uses native N-API (not async I/O),
// Promise.race cannot interrupt it. Bail out early instead.
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
this.keychainAvailable = false;
logger.debug('Skipping OS keychain: Linux without display (headless/SSH) — using encrypted file storage');
return;
}
// Dynamically import keyring
const keyring = await import('@napi-rs/keyring');
this.Entry = keyring.Entry;
// Test if keychain is accessible
const testEntry = new this.Entry(SERVICE_NAME, '_ncp_test_');
await testEntry.setPassword('test');
await testEntry.getPassword();
await testEntry.deletePassword();
this.keychainAvailable = true;
logger.debug('OS keychain initialized successfully');
} catch (error) {
this.keychainAvailable = false;
logger.debug(`OS keychain unavailable, using encrypted file storage: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Load credential index from disk
*/
private loadIndex(): void {
try {
if (fs.existsSync(CREDENTIAL_INDEX_FILE)) {
const data = JSON.parse(fs.readFileSync(CREDENTIAL_INDEX_FILE, 'utf-8'));
this.index = new Map(Object.entries(data));
}
} catch (error) {
logger.error('Failed to load credential index:', error);
this.index = new Map();
}
}
/**
* Save credential index to disk
*/
private saveIndex(): void {
try {
const dir = path.dirname(CREDENTIAL_INDEX_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
const data = Object.fromEntries(this.index);
fs.writeFileSync(CREDENTIAL_INDEX_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
} catch (error) {
logger.error('Failed to save credential index:', error);
}
}
/**
* Generate account identifier for keychain
*/
private getAccountId(mcpName: string, type: CredentialType): string {
return `${mcpName}:${type}`;
}
/**
* Store credential securely
*/
async setCredential(
mcpName: string,
type: CredentialType,
credential: string | BasicAuthCredential,
description?: string
): Promise<boolean> {
await this.ensureInitialized();
const accountId = this.getAccountId(mcpName, type);
try {
// Serialize credential
const credentialString = typeof credential === 'string'
? credential
: JSON.stringify(credential);
if (this.keychainAvailable && this.Entry) {
// Store in OS keychain
const entry = new this.Entry(SERVICE_NAME, accountId);
await entry.setPassword(credentialString);
logger.debug(`Stored credential in OS keychain: ${accountId}`);
} else {
// Fallback to encrypted file storage
const tokenStore = getTokenStore();
await tokenStore.storeToken(accountId, {
access_token: credentialString,
expires_in: 315360000, // 10 years (basically never expires)
token_type: type
});
logger.debug(`Stored credential in encrypted file: ${accountId}`);
}
// Update index
this.index.set(accountId, {
mcpName,
type,
description,
createdAt: this.index.get(accountId)?.createdAt || Date.now(),
updatedAt: Date.now()
});
this.saveIndex();
return true;
} catch (error) {
logger.error(`Failed to store credential for ${accountId}:`, error);
return false;
}
}
/**
* Retrieve credential securely
*/
async getCredential(mcpName: string, type: CredentialType): Promise<string | BasicAuthCredential | null> {
await this.ensureInitialized();
const accountId = this.getAccountId(mcpName, type);
try {
let credentialString: string | null = null;
if (this.keychainAvailable && this.Entry) {
// Retrieve from OS keychain
const entry = new this.Entry(SERVICE_NAME, accountId);
credentialString = await entry.getPassword();
} else {
// Fallback to encrypted file storage
const tokenStore = getTokenStore();
const token = await tokenStore.getToken(accountId);
credentialString = token?.access_token || null;
}
if (!credentialString) {
return null;
}
// Deserialize if basic auth
if (type === 'basic_auth') {
try {
return JSON.parse(credentialString);
} catch {
// If parsing fails, assume it's a plain token
return credentialString;
}
}
return credentialString;
} catch (error) {
logger.error(`Failed to retrieve credential for ${accountId}:`, error);
return null;
}
}
/**
* Delete credential securely
*/
async deleteCredential(mcpName: string, type: CredentialType): Promise<boolean> {
await this.ensureInitialized();
const accountId = this.getAccountId(mcpName, type);
try {
if (this.keychainAvailable && this.Entry) {
// Delete from OS keychain
const entry = new this.Entry(SERVICE_NAME, accountId);
await entry.deletePassword();
} else {
// Delete from encrypted file storage
const tokenStore = getTokenStore();
await tokenStore.deleteToken(accountId);
}
// Remove from index
this.index.delete(accountId);
this.saveIndex();
logger.debug(`Deleted credential: ${accountId}`);
return true;
} catch (error) {
logger.error(`Failed to delete credential for ${accountId}:`, error);
return false;
}
}
/**
* List all stored credentials (metadata only)
*/
async listCredentials(mcpName?: string): Promise<CredentialMetadata[]> {
const credentials: CredentialMetadata[] = [];
for (const [accountId, metadata] of this.index.entries()) {
if (!mcpName || metadata.mcpName === mcpName) {
credentials.push(metadata);
}
}
return credentials.sort((a, b) => a.mcpName.localeCompare(b.mcpName));
}
/**
* Check if credential exists
*/
async hasCredential(mcpName: string, type: CredentialType): Promise<boolean> {
const accountId = this.getAccountId(mcpName, type);
return this.index.has(accountId);
}
/**
* Migrate plain-text credentials from profile to secure storage
*/
async migrateFromPlainText(
mcpName: string,
credential: { type?: string; token?: string; username?: string; password?: string }
): Promise<boolean> {
await this.ensureInitialized();
try {
if (credential.token) {
// Determine type from auth configuration
const type: CredentialType = credential.type === 'bearer' ? 'bearer_token' : 'api_key';
await this.setCredential(mcpName, type, credential.token, `Migrated from profile`);
logger.info(`Migrated ${type} for ${mcpName} to secure storage`);
return true;
}
if (credential.username && credential.password) {
await this.setCredential(
mcpName,
'basic_auth',
{ username: credential.username, password: credential.password },
'Migrated from profile'
);
logger.info(`Migrated basic auth for ${mcpName} to secure storage`);
return true;
}
return false;
} catch (error) {
logger.error(`Failed to migrate credentials for ${mcpName}:`, error);
return false;
}
}
/**
* Check if keychain is available
*/
isKeychainAvailable(): boolean {
return this.keychainAvailable;
}
/**
* Get storage method being used
*/
getStorageMethod(): 'keychain' | 'encrypted_file' {
return this.keychainAvailable ? 'keychain' : 'encrypted_file';
}
}
// Singleton instance
let credentialStoreInstance: SecureCredentialStore | null = null;
export function getSecureCredentialStore(): SecureCredentialStore {
if (!credentialStoreInstance) {
credentialStoreInstance = new SecureCredentialStore();
}
return credentialStoreInstance;
}