Skip to content

Commit 41719f2

Browse files
committed
add a credential manager utility to manage keys/tokens
1 parent 674944c commit 41719f2

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed

packages/code-infra/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"types": "./build/utils/changelog.d.mts",
2222
"default": "./src/utils/changelog.mjs"
2323
},
24+
"./credentials": {
25+
"types": "./build/utils/credentials.d.mts",
26+
"default": "./src/utils/credentials.mjs"
27+
},
2428
"./eslint": {
2529
"types": "./build/eslint/index.d.mts",
2630
"default": "./src/eslint/index.mjs"
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
/**
2+
* Secure credential storage for CLI tools
3+
*
4+
* This file was generated by Claude Code to provide encrypted credential storage
5+
* for the MUI code infrastructure tools. It uses machine-specific encryption
6+
* to balance security and usability for local development.
7+
*
8+
* @generated by Claude Code
9+
*/
10+
11+
import crypto from 'node:crypto';
12+
import fs from 'node:fs/promises';
13+
import os from 'node:os';
14+
import path from 'node:path';
15+
import { createInterface } from 'node:readline';
16+
17+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'mui-code-infra');
18+
const CONFIG_FILE = path.join(CONFIG_DIR, 'credentials.enc');
19+
const MACHINE_KEY_FILE = path.join(CONFIG_DIR, '.machine');
20+
const ALGORITHM = 'aes-256-gcm';
21+
22+
/**
23+
* Gets or creates a machine-specific key for encryption. Not the most secure method,
24+
* but balances security and usability for local development and stops malicious scripts
25+
* from reading tokens from env variables or config files.
26+
* A script will have to specifically target this machine or this package to get the keys.
27+
*
28+
* @returns {Promise<string>} The machine key
29+
*/
30+
async function getMachineKey() {
31+
try {
32+
// Try to read existing machine key
33+
const existingKey = await fs.readFile(MACHINE_KEY_FILE, 'utf8');
34+
return existingKey.trim();
35+
} catch (error) {
36+
// Generate new machine key from system info
37+
const hostname = os.hostname();
38+
const username = os.userInfo().username;
39+
const platform = os.platform();
40+
const arch = os.arch();
41+
42+
// Create a deterministic but unique key for this machine/user
43+
const machineInfo = `${hostname}:${username}:${platform}:${arch}`;
44+
const machineKey = crypto.createHash('sha256').update(machineInfo).digest('hex');
45+
46+
// Store it for consistency
47+
await fs.writeFile(MACHINE_KEY_FILE, machineKey, { mode: 0o600 });
48+
49+
return machineKey;
50+
}
51+
}
52+
53+
/**
54+
* Derives an encryption key from machine key using PBKDF2
55+
* @param {string} machineKey - The machine key to derive from
56+
* @param {Buffer} salt - The salt for key derivation
57+
* @returns {Buffer} The derived key
58+
*/
59+
function deriveKey(machineKey, salt) {
60+
return crypto.pbkdf2Sync(machineKey, salt, 100000, 32, 'sha256');
61+
}
62+
63+
/**
64+
* Encrypts data using AES-256-GCM
65+
* @param {string} data - The data to encrypt
66+
* @param {string} machineKey - The machine key to use for encryption
67+
* @returns {Object} The encrypted data with metadata
68+
*/
69+
function encryptData(data, machineKey) {
70+
const salt = crypto.randomBytes(32);
71+
const iv = crypto.randomBytes(16);
72+
const key = deriveKey(machineKey, salt);
73+
74+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
75+
cipher.setAAD(Buffer.from('mui-code-infra'));
76+
77+
let encrypted = cipher.update(data, 'utf8', 'hex');
78+
encrypted += cipher.final('hex');
79+
80+
const authTag = cipher.getAuthTag();
81+
82+
return {
83+
encrypted,
84+
salt: salt.toString('hex'),
85+
iv: iv.toString('hex'),
86+
authTag: authTag.toString('hex'),
87+
};
88+
}
89+
90+
/**
91+
* Decrypts data using AES-256-GCM
92+
* @param {{encrypted: string, salt: string, iv: string, authTag: string}} encryptedData - The encrypted data with metadata
93+
* @param {string} machineKey - The machine key to use for decryption
94+
* @returns {string} The decrypted data
95+
*/
96+
function decryptData(encryptedData, machineKey) {
97+
const salt = Buffer.from(encryptedData.salt, 'hex');
98+
const iv = Buffer.from(encryptedData.iv, 'hex');
99+
const authTag = Buffer.from(encryptedData.authTag, 'hex');
100+
const key = deriveKey(machineKey, salt);
101+
102+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
103+
decipher.setAAD(Buffer.from('mui-code-infra'));
104+
decipher.setAuthTag(authTag);
105+
106+
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
107+
decrypted += decipher.final('utf8');
108+
109+
return decrypted;
110+
}
111+
112+
/**
113+
* Prompts user for input securely (hidden input for passwords)
114+
* @param {string} question - The question to ask
115+
* @param {boolean} hidden - Whether to hide the input
116+
* @returns {Promise<string>} The user's input
117+
*/
118+
function promptUser(question, hidden = false) {
119+
return new Promise((resolve) => {
120+
if (hidden) {
121+
// For hidden input, use a simpler approach that works better across terminals
122+
process.stdout.write(question);
123+
124+
const stdin = process.stdin;
125+
stdin.setRawMode(true);
126+
stdin.resume();
127+
stdin.setEncoding('utf8');
128+
129+
let password = '';
130+
131+
/**
132+
* @param {string} char
133+
*/
134+
const onData = (char) => {
135+
switch (char) {
136+
case '\n':
137+
case '\r':
138+
case '\u0004': // Ctrl+D
139+
stdin.setRawMode(false);
140+
stdin.removeListener('data', onData);
141+
stdin.pause();
142+
process.stdout.write('\n');
143+
resolve(password);
144+
break;
145+
case '\u0003': // Ctrl+C
146+
process.exit(1);
147+
break;
148+
case '\u007f': // Backspace
149+
if (password.length > 0) {
150+
password = password.slice(0, -1);
151+
process.stdout.write('\b \b');
152+
}
153+
break;
154+
default:
155+
if (char >= ' ') {
156+
// Printable characters
157+
password += char;
158+
process.stdout.write('*');
159+
}
160+
break;
161+
}
162+
};
163+
164+
stdin.on('data', onData);
165+
} else {
166+
// For visible input, use standard readline
167+
const rl = createInterface({
168+
input: process.stdin,
169+
output: process.stdout,
170+
});
171+
172+
rl.question(question, (answer) => {
173+
rl.close();
174+
resolve(answer.trim());
175+
});
176+
}
177+
});
178+
}
179+
180+
/**
181+
* Gets a credential from encrypted storage, prompting user if not found
182+
* @param {string} key - The credential key to retrieve
183+
* @param {string} [promptMessage] - Custom prompt message for the credential
184+
* @returns {Promise<string>} The credential value
185+
*/
186+
export async function getCredential(key, promptMessage) {
187+
try {
188+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
189+
190+
// Get machine-specific encryption key
191+
const machineKey = await getMachineKey();
192+
193+
/** @type {Record<string, string>} */
194+
let credentials = {};
195+
196+
try {
197+
const encryptedContent = await fs.readFile(CONFIG_FILE, 'utf8');
198+
const encryptedData = JSON.parse(encryptedContent);
199+
200+
const decryptedContent = decryptData(encryptedData, machineKey);
201+
credentials = JSON.parse(decryptedContent);
202+
} catch (/** @type {any} */ error) {
203+
if (error.code !== 'ENOENT') {
204+
throw new Error('Failed to decrypt credentials. The credentials file may be corrupted.');
205+
}
206+
// File doesn't exist, will create it
207+
}
208+
209+
if (credentials[key]) {
210+
return credentials[key];
211+
}
212+
213+
// Credential doesn't exist, prompt user for it
214+
const defaultPrompt = `Enter ${key}: `;
215+
const userPrompt = promptMessage || defaultPrompt;
216+
const credentialValue = await promptUser(
217+
userPrompt,
218+
key.toLowerCase().includes('token') || key.toLowerCase().includes('password'),
219+
);
220+
221+
// Store the new credential
222+
credentials[key] = credentialValue;
223+
224+
const encryptedData = encryptData(JSON.stringify(credentials), machineKey);
225+
await fs.writeFile(CONFIG_FILE, JSON.stringify(encryptedData), { mode: 0o600 });
226+
227+
return credentialValue;
228+
} catch (/** @type {any} */ error) {
229+
throw new Error(`Failed to get credential '${key}': ${error.message}`);
230+
}
231+
}
232+
233+
/**
234+
* Clears all stored credentials and removes the credential storage directory
235+
* @returns {Promise<void>}
236+
*/
237+
export async function clearCredentials() {
238+
try {
239+
await fs.rm(CONFIG_DIR, { recursive: true, force: true });
240+
} catch (/** @type {any} */ error) {
241+
throw new Error(`Failed to clear credentials: ${error.message}`);
242+
}
243+
}
244+
245+
/**
246+
* Lists all stored credential keys (without showing the values)
247+
* @returns {Promise<string[]>} Array of credential keys
248+
*/
249+
export async function listCredentials() {
250+
try {
251+
// Get machine-specific encryption key
252+
const machineKey = await getMachineKey();
253+
254+
const encryptedContent = await fs.readFile(CONFIG_FILE, 'utf8');
255+
const encryptedData = JSON.parse(encryptedContent);
256+
257+
const decryptedContent = decryptData(encryptedData, machineKey);
258+
const credentials = JSON.parse(decryptedContent);
259+
260+
return Object.keys(credentials);
261+
} catch (/** @type {any} */ error) {
262+
if (error.code === 'ENOENT') {
263+
return []; // No credentials stored yet
264+
}
265+
throw new Error(`Failed to list credentials: ${error.message}`);
266+
}
267+
}
268+
269+
/**
270+
* Removes a specific credential by key
271+
* @param {string} key - The credential key to remove
272+
* @returns {Promise<boolean>} True if credential was found and removed, false if not found
273+
*/
274+
export async function removeCredential(key) {
275+
try {
276+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
277+
278+
// Get machine-specific encryption key
279+
const machineKey = await getMachineKey();
280+
281+
/** @type {Record<string, string>} */
282+
let credentials = {};
283+
284+
try {
285+
const encryptedContent = await fs.readFile(CONFIG_FILE, 'utf8');
286+
const encryptedData = JSON.parse(encryptedContent);
287+
288+
const decryptedContent = decryptData(encryptedData, machineKey);
289+
credentials = JSON.parse(decryptedContent);
290+
} catch (/** @type {any} */ error) {
291+
if (error.code === 'ENOENT') {
292+
return false; // No credentials file exists
293+
}
294+
throw error;
295+
}
296+
297+
if (!credentials[key]) {
298+
return false; // Credential doesn't exist
299+
}
300+
301+
delete credentials[key];
302+
303+
// Save updated credentials
304+
const encryptedData = encryptData(JSON.stringify(credentials), machineKey);
305+
await fs.writeFile(CONFIG_FILE, JSON.stringify(encryptedData), { mode: 0o600 });
306+
307+
return true;
308+
} catch (/** @type {any} */ error) {
309+
throw new Error(`Failed to remove credential '${key}': ${error.message}`);
310+
}
311+
}

0 commit comments

Comments
 (0)