lockbox-vault 1.0.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 +180 -0
- package/dist/cli/env-utils.d.ts +46 -0
- package/dist/cli/env-utils.d.ts.map +1 -0
- package/dist/cli/env-utils.js +97 -0
- package/dist/cli/env-utils.js.map +1 -0
- package/dist/cli/helpers.d.ts +49 -0
- package/dist/cli/helpers.d.ts.map +1 -0
- package/dist/cli/helpers.js +168 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +728 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/session.d.ts +52 -0
- package/dist/cli/session.d.ts.map +1 -0
- package/dist/cli/session.js +188 -0
- package/dist/cli/session.js.map +1 -0
- package/dist/crypto/encryption.d.ts +32 -0
- package/dist/crypto/encryption.d.ts.map +1 -0
- package/dist/crypto/encryption.js +54 -0
- package/dist/crypto/encryption.js.map +1 -0
- package/dist/crypto/keyDerivation.d.ts +22 -0
- package/dist/crypto/keyDerivation.d.ts.map +1 -0
- package/dist/crypto/keyDerivation.js +47 -0
- package/dist/crypto/keyDerivation.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/audit.d.ts +31 -0
- package/dist/mcp/audit.d.ts.map +1 -0
- package/dist/mcp/audit.js +69 -0
- package/dist/mcp/audit.js.map +1 -0
- package/dist/mcp/server.d.ts +22 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +189 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/vault/config.d.ts +29 -0
- package/dist/vault/config.d.ts.map +1 -0
- package/dist/vault/config.js +66 -0
- package/dist/vault/config.js.map +1 -0
- package/dist/vault/vault.d.ts +130 -0
- package/dist/vault/vault.d.ts.map +1 -0
- package/dist/vault/vault.js +296 -0
- package/dist/vault/vault.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command registration — wires up all lockbox commands
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { extname } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import { Vault } from '../vault/vault.js';
|
|
9
|
+
import { loadConfig, getPaths } from '../vault/config.js';
|
|
10
|
+
import { saveSession, loadSession, clearSession, sessionTimeRemaining, checkRateLimit, recordFailedAttempt, clearRateLimit, } from './session.js';
|
|
11
|
+
import { promptPassword, promptPasswordWithConfirm, promptConfirm, promptChoice, copyAndScheduleClear, formatTable, shortDate, exitWithError, chalk, } from './helpers.js';
|
|
12
|
+
import { envKeyName, splitKeyName, parseEnvFile as parseEnvImport, parseJsonImport, parseProxyUri, buildProxyUri, PROXY_PREFIX, } from './env-utils.js';
|
|
13
|
+
import { auditLog, readAuditLog } from '../mcp/audit.js';
|
|
14
|
+
// ─── Helper: load vault from active session ──────────────────────────────────
|
|
15
|
+
function loadVaultFromSession() {
|
|
16
|
+
const session = loadSession();
|
|
17
|
+
if (!session) {
|
|
18
|
+
throw new Error('Vault is locked. Run `lockbox unlock` first.');
|
|
19
|
+
}
|
|
20
|
+
return Vault.openWithKey(session.vaultPath, session.key);
|
|
21
|
+
}
|
|
22
|
+
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
23
|
+
export function registerCommands(program) {
|
|
24
|
+
// ── init ─────────────────────────────────────────────────────────────────
|
|
25
|
+
program
|
|
26
|
+
.command('init')
|
|
27
|
+
.description('Create a new encrypted vault')
|
|
28
|
+
.addHelpText('after', `
|
|
29
|
+
Examples:
|
|
30
|
+
$ lockbox init
|
|
31
|
+
`)
|
|
32
|
+
.action(async () => {
|
|
33
|
+
try {
|
|
34
|
+
const config = loadConfig();
|
|
35
|
+
const { vaultFile } = getPaths(config);
|
|
36
|
+
// Check for existing vault
|
|
37
|
+
if (existsSync(vaultFile)) {
|
|
38
|
+
const overwrite = await promptConfirm(chalk.yellow('A vault already exists. Overwrite it?'));
|
|
39
|
+
if (!overwrite) {
|
|
40
|
+
process.stderr.write(chalk.dim('Aborted.\n'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Get master password
|
|
45
|
+
const password = await promptPasswordWithConfirm();
|
|
46
|
+
const spinner = ora('Creating encrypted vault...').start();
|
|
47
|
+
const vault = await Vault.create(vaultFile, password);
|
|
48
|
+
// Auto-unlock after init
|
|
49
|
+
saveSession(vault.getDerivedKey(), vaultFile);
|
|
50
|
+
vault.lock();
|
|
51
|
+
spinner.succeed('Vault created and unlocked!');
|
|
52
|
+
auditLog('init', { vaultPath: vaultFile }, 'cli');
|
|
53
|
+
process.stderr.write(chalk.dim(` Location: ${vaultFile}\n`));
|
|
54
|
+
process.stderr.write(chalk.dim(` Auto-locks in 15 minutes.\n`));
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
exitWithError(err.message);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
// ── unlock ───────────────────────────────────────────────────────────────
|
|
61
|
+
program
|
|
62
|
+
.command('unlock')
|
|
63
|
+
.description('Unlock the vault with your master password')
|
|
64
|
+
.addHelpText('after', `
|
|
65
|
+
Examples:
|
|
66
|
+
$ lockbox unlock
|
|
67
|
+
$ lockbox unlock # session lasts 15 minutes
|
|
68
|
+
`)
|
|
69
|
+
.action(async () => {
|
|
70
|
+
try {
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
const { vaultFile } = getPaths(config);
|
|
73
|
+
if (!existsSync(vaultFile)) {
|
|
74
|
+
throw new Error('No vault found. Run `lockbox init` first.');
|
|
75
|
+
}
|
|
76
|
+
// Check rate limit
|
|
77
|
+
checkRateLimit();
|
|
78
|
+
const password = await promptPassword(chalk.cyan('Master password: '));
|
|
79
|
+
const spinner = ora('Deriving encryption key...').start();
|
|
80
|
+
let vault;
|
|
81
|
+
try {
|
|
82
|
+
vault = await Vault.open(vaultFile, password);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
spinner.fail('Unlock failed.');
|
|
86
|
+
recordFailedAttempt();
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
// Success — save session and clear rate limit
|
|
90
|
+
saveSession(vault.getDerivedKey(), vaultFile);
|
|
91
|
+
clearRateLimit();
|
|
92
|
+
vault.lock();
|
|
93
|
+
spinner.succeed('Vault unlocked!');
|
|
94
|
+
auditLog('unlock', {}, 'cli');
|
|
95
|
+
process.stderr.write(chalk.dim(' Auto-locks in 15 minutes.\n'));
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
exitWithError(err.message);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// ── lock ─────────────────────────────────────────────────────────────────
|
|
102
|
+
program
|
|
103
|
+
.command('lock')
|
|
104
|
+
.description('Lock the vault and clear session')
|
|
105
|
+
.addHelpText('after', `
|
|
106
|
+
Examples:
|
|
107
|
+
$ lockbox lock
|
|
108
|
+
`)
|
|
109
|
+
.action(() => {
|
|
110
|
+
try {
|
|
111
|
+
clearSession();
|
|
112
|
+
auditLog('lock', {}, 'cli');
|
|
113
|
+
process.stderr.write(chalk.green('✓ Vault locked.\n'));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
exitWithError(err.message);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
// ── add ──────────────────────────────────────────────────────────────────
|
|
120
|
+
program
|
|
121
|
+
.command('add')
|
|
122
|
+
.description('Add a secret to the vault')
|
|
123
|
+
.argument('<service>', 'Service name (e.g. openai)')
|
|
124
|
+
.argument('<key-name>', 'Key identifier (e.g. API_KEY)')
|
|
125
|
+
.argument('[value]', 'Secret value (prompted if omitted)')
|
|
126
|
+
.option('-p, --project <name>', 'Project name', 'default')
|
|
127
|
+
.option('-n, --notes <text>', 'Notes about this key', '')
|
|
128
|
+
.option('--expires <date>', 'Expiration date (ISO format)')
|
|
129
|
+
.addHelpText('after', `
|
|
130
|
+
Examples:
|
|
131
|
+
$ lockbox add openai API_KEY sk-abc123
|
|
132
|
+
$ lockbox add stripe SECRET_KEY --project myapp
|
|
133
|
+
$ lockbox add aws ACCESS_KEY # prompts for value
|
|
134
|
+
$ lockbox add github TOKEN --notes "PAT for CI"
|
|
135
|
+
`)
|
|
136
|
+
.action(async (service, keyName, value, opts) => {
|
|
137
|
+
try {
|
|
138
|
+
const vault = loadVaultFromSession();
|
|
139
|
+
// If no value provided, prompt for it (hidden)
|
|
140
|
+
if (!value) {
|
|
141
|
+
value = await promptPassword(chalk.cyan('Secret value: '));
|
|
142
|
+
if (!value)
|
|
143
|
+
throw new Error('Value cannot be empty');
|
|
144
|
+
}
|
|
145
|
+
vault.addKey(service, keyName, value, {
|
|
146
|
+
project: opts.project,
|
|
147
|
+
notes: opts.notes,
|
|
148
|
+
expiresAt: opts.expires ?? null,
|
|
149
|
+
});
|
|
150
|
+
await vault.save();
|
|
151
|
+
vault.lock();
|
|
152
|
+
auditLog('add', { service, key_name: keyName, project: opts.project }, 'cli');
|
|
153
|
+
process.stderr.write(chalk.green(`✓ Added ${service}/${keyName} to vault\n`));
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
exitWithError(err.message);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// ── get ──────────────────────────────────────────────────────────────────
|
|
160
|
+
program
|
|
161
|
+
.command('get')
|
|
162
|
+
.description('Retrieve a secret from the vault')
|
|
163
|
+
.argument('<service>', 'Service name')
|
|
164
|
+
.argument('<key-name>', 'Key identifier')
|
|
165
|
+
.option('-c, --copy', 'Copy to clipboard (auto-clears after 30s)')
|
|
166
|
+
.addHelpText('after', `
|
|
167
|
+
Examples:
|
|
168
|
+
$ lockbox get openai API_KEY
|
|
169
|
+
$ lockbox get openai API_KEY --copy # copies to clipboard, clears in 30s
|
|
170
|
+
$ lockbox get stripe SECRET | pbcopy # pipe to other commands
|
|
171
|
+
`)
|
|
172
|
+
.action(async (service, keyName, opts) => {
|
|
173
|
+
try {
|
|
174
|
+
const vault = loadVaultFromSession();
|
|
175
|
+
const value = vault.getKey(service, keyName);
|
|
176
|
+
vault.lock();
|
|
177
|
+
auditLog('get', { service, key_name: keyName, copy: !!opts.copy }, 'cli');
|
|
178
|
+
if (opts.copy) {
|
|
179
|
+
await copyAndScheduleClear(value, 30);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Output plain value to stdout (for piping)
|
|
183
|
+
process.stdout.write(value);
|
|
184
|
+
// Add newline only if stdout is a terminal
|
|
185
|
+
if (process.stdout.isTTY) {
|
|
186
|
+
process.stdout.write('\n');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
exitWithError(err.message);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
// ── list ─────────────────────────────────────────────────────────────────
|
|
195
|
+
program
|
|
196
|
+
.command('list')
|
|
197
|
+
.description('List all stored keys (names only, never values)')
|
|
198
|
+
.option('-p, --project <name>', 'Filter by project name')
|
|
199
|
+
.addHelpText('after', `
|
|
200
|
+
Examples:
|
|
201
|
+
$ lockbox list
|
|
202
|
+
$ lockbox list --project myapp
|
|
203
|
+
`)
|
|
204
|
+
.action((opts) => {
|
|
205
|
+
try {
|
|
206
|
+
const vault = loadVaultFromSession();
|
|
207
|
+
const keys = vault.listKeys(opts.project);
|
|
208
|
+
vault.lock();
|
|
209
|
+
auditLog('list', { project: opts.project ?? 'all' }, 'cli');
|
|
210
|
+
if (keys.length === 0) {
|
|
211
|
+
process.stderr.write(chalk.dim(opts.project
|
|
212
|
+
? `No keys found in project "${opts.project}".\n`
|
|
213
|
+
: 'Vault is empty. Add keys with `lockbox add`.\n'));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const rows = keys.map((k) => {
|
|
217
|
+
const [service, ...rest] = k.name.split('/');
|
|
218
|
+
return [service, rest.join('/'), k.project, shortDate(k.createdAt), shortDate(k.expiresAt)];
|
|
219
|
+
});
|
|
220
|
+
process.stderr.write(formatTable(['Service', 'Key Name', 'Project', 'Added', 'Expires'], rows) + '\n');
|
|
221
|
+
process.stderr.write(chalk.dim(`\n${keys.length} key(s) total\n`));
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
exitWithError(err.message);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
// ── remove ───────────────────────────────────────────────────────────────
|
|
228
|
+
program
|
|
229
|
+
.command('remove')
|
|
230
|
+
.description('Remove a secret from the vault')
|
|
231
|
+
.argument('<service>', 'Service name')
|
|
232
|
+
.argument('<key-name>', 'Key identifier')
|
|
233
|
+
.option('-f, --force', 'Skip confirmation')
|
|
234
|
+
.addHelpText('after', `
|
|
235
|
+
Examples:
|
|
236
|
+
$ lockbox remove openai API_KEY
|
|
237
|
+
$ lockbox remove stripe SECRET --force # skip confirmation
|
|
238
|
+
`)
|
|
239
|
+
.action(async (service, keyName, opts) => {
|
|
240
|
+
try {
|
|
241
|
+
const vault = loadVaultFromSession();
|
|
242
|
+
const fullKey = `${service}/${keyName}`;
|
|
243
|
+
// Confirm deletion unless --force
|
|
244
|
+
if (!opts.force) {
|
|
245
|
+
const confirmed = await promptConfirm(chalk.yellow(`Remove ${fullKey}?`));
|
|
246
|
+
if (!confirmed) {
|
|
247
|
+
vault.lock();
|
|
248
|
+
process.stderr.write(chalk.dim('Aborted.\n'));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const removed = vault.removeKey(service, keyName);
|
|
253
|
+
if (!removed) {
|
|
254
|
+
vault.lock();
|
|
255
|
+
throw new Error(`Key not found: ${fullKey}`);
|
|
256
|
+
}
|
|
257
|
+
await vault.save();
|
|
258
|
+
vault.lock();
|
|
259
|
+
auditLog('remove', { service, key_name: keyName }, 'cli');
|
|
260
|
+
process.stderr.write(chalk.green(`✓ Removed ${fullKey}\n`));
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
exitWithError(err.message);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
// ── search ───────────────────────────────────────────────────────────────
|
|
267
|
+
program
|
|
268
|
+
.command('search')
|
|
269
|
+
.description('Search keys by name, service, project, or notes')
|
|
270
|
+
.argument('<query>', 'Search term')
|
|
271
|
+
.addHelpText('after', `
|
|
272
|
+
Examples:
|
|
273
|
+
$ lockbox search openai
|
|
274
|
+
$ lockbox search production
|
|
275
|
+
`)
|
|
276
|
+
.action((query) => {
|
|
277
|
+
try {
|
|
278
|
+
const vault = loadVaultFromSession();
|
|
279
|
+
const allKeys = vault.listKeys();
|
|
280
|
+
vault.lock();
|
|
281
|
+
const q = query.toLowerCase();
|
|
282
|
+
const matches = allKeys.filter((k) => {
|
|
283
|
+
const searchable = [k.name, k.project, k.notes].join(' ').toLowerCase();
|
|
284
|
+
return searchable.includes(q);
|
|
285
|
+
});
|
|
286
|
+
if (matches.length === 0) {
|
|
287
|
+
process.stderr.write(chalk.dim(`No keys matching "${query}".\n`));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const rows = matches.map((k) => {
|
|
291
|
+
const [service, ...rest] = k.name.split('/');
|
|
292
|
+
return [service, rest.join('/'), k.project, k.notes || '—'];
|
|
293
|
+
});
|
|
294
|
+
process.stderr.write(formatTable(['Service', 'Key Name', 'Project', 'Notes'], rows) + '\n');
|
|
295
|
+
process.stderr.write(chalk.dim(`\n${matches.length} result(s)\n`));
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
exitWithError(err.message);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// ── status ───────────────────────────────────────────────────────────────
|
|
302
|
+
program
|
|
303
|
+
.command('status')
|
|
304
|
+
.description('Show vault lock state, key count, and session info')
|
|
305
|
+
.addHelpText('after', `
|
|
306
|
+
Examples:
|
|
307
|
+
$ lockbox status
|
|
308
|
+
`)
|
|
309
|
+
.action(() => {
|
|
310
|
+
try {
|
|
311
|
+
const config = loadConfig();
|
|
312
|
+
const { vaultFile } = getPaths(config);
|
|
313
|
+
process.stderr.write(chalk.bold('Lockbox Status\n\n'));
|
|
314
|
+
// Vault path
|
|
315
|
+
process.stderr.write(` Vault: ${vaultFile}\n`);
|
|
316
|
+
// File exists?
|
|
317
|
+
if (!existsSync(vaultFile)) {
|
|
318
|
+
process.stderr.write(chalk.yellow(' Status: No vault found. Run `lockbox init`.\n'));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// File size
|
|
322
|
+
const stats = statSync(vaultFile);
|
|
323
|
+
const sizeKB = (stats.size / 1024).toFixed(1);
|
|
324
|
+
process.stderr.write(` Size: ${sizeKB} KB\n`);
|
|
325
|
+
// Lock state
|
|
326
|
+
const session = loadSession();
|
|
327
|
+
if (session) {
|
|
328
|
+
const remaining = sessionTimeRemaining();
|
|
329
|
+
const mins = remaining ? Math.floor(remaining / 60) : 0;
|
|
330
|
+
const secs = remaining ? remaining % 60 : 0;
|
|
331
|
+
process.stderr.write(chalk.green(` Status: Unlocked`) +
|
|
332
|
+
chalk.dim(` (auto-locks in ${mins}m ${secs}s)\n`));
|
|
333
|
+
// Key count and project count
|
|
334
|
+
try {
|
|
335
|
+
const vault = Vault.openWithKey(session.vaultPath, session.key);
|
|
336
|
+
const keys = vault.listKeys();
|
|
337
|
+
const projects = vault.listProjects();
|
|
338
|
+
vault.lock();
|
|
339
|
+
process.stderr.write(` Keys: ${keys.length}\n`);
|
|
340
|
+
process.stderr.write(` Projects: ${projects.length}\n`);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Session may be stale
|
|
344
|
+
process.stderr.write(chalk.dim(' (Could not read vault details)\n'));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
process.stderr.write(chalk.red(' Status: Locked\n'));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
exitWithError(err.message);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
// ── export ─────────────────────────────────────────────────────────────
|
|
356
|
+
program
|
|
357
|
+
.command('export')
|
|
358
|
+
.description('Export keys to stdout (env, json, or shell format)')
|
|
359
|
+
.option('-p, --project <name>', 'Only export keys from this project')
|
|
360
|
+
.option('-f, --format <format>', 'Output format: env, json, shell', 'env')
|
|
361
|
+
.addHelpText('after', `
|
|
362
|
+
Examples:
|
|
363
|
+
$ lockbox export > .env
|
|
364
|
+
$ lockbox export --project myapp --format json
|
|
365
|
+
$ lockbox export --format shell >> ~/.bashrc
|
|
366
|
+
`)
|
|
367
|
+
.action((opts) => {
|
|
368
|
+
try {
|
|
369
|
+
const vault = loadVaultFromSession();
|
|
370
|
+
const pairs = vault.exportKeys(opts.project);
|
|
371
|
+
vault.lock();
|
|
372
|
+
auditLog('export', { project: opts.project ?? 'all', format: opts.format, count: pairs.length }, 'cli');
|
|
373
|
+
if (pairs.length === 0) {
|
|
374
|
+
process.stderr.write(chalk.dim(opts.project
|
|
375
|
+
? `No keys found in project "${opts.project}".\n`
|
|
376
|
+
: 'Vault is empty.\n'));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const format = opts.format;
|
|
380
|
+
let output;
|
|
381
|
+
switch (format) {
|
|
382
|
+
case 'json': {
|
|
383
|
+
const obj = {};
|
|
384
|
+
for (const { name, value } of pairs) {
|
|
385
|
+
// Use the full "service/KEY_NAME" as the JSON key
|
|
386
|
+
obj[name] = value;
|
|
387
|
+
}
|
|
388
|
+
output = JSON.stringify(obj, null, 2) + '\n';
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case 'shell': {
|
|
392
|
+
output = pairs
|
|
393
|
+
.map(({ name, value }) => {
|
|
394
|
+
// Escape double quotes and backslashes inside the value
|
|
395
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
396
|
+
return `export ${envKeyName(name)}="${escaped}"`;
|
|
397
|
+
})
|
|
398
|
+
.join('\n') + '\n';
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
case 'env':
|
|
402
|
+
default: {
|
|
403
|
+
output = pairs
|
|
404
|
+
.map(({ name, value }) => `${envKeyName(name)}=${value}`)
|
|
405
|
+
.join('\n') + '\n';
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Write to stdout so users can pipe: lockbox export > .env
|
|
410
|
+
process.stdout.write(output);
|
|
411
|
+
process.stderr.write(chalk.dim(`Exported ${pairs.length} key(s) in ${format} format.\n`));
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
exitWithError(err.message);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
// ── import ─────────────────────────────────────────────────────────────
|
|
418
|
+
program
|
|
419
|
+
.command('import')
|
|
420
|
+
.description('Import keys from a .env or JSON file')
|
|
421
|
+
.argument('<file>', 'Path to .env or .json file')
|
|
422
|
+
.option('-p, --project <name>', 'Assign imported keys to this project', 'default')
|
|
423
|
+
.addHelpText('after', `
|
|
424
|
+
Examples:
|
|
425
|
+
$ lockbox import .env
|
|
426
|
+
$ lockbox import credentials.json --project myapp
|
|
427
|
+
`)
|
|
428
|
+
.action(async (file, opts) => {
|
|
429
|
+
try {
|
|
430
|
+
if (!existsSync(file)) {
|
|
431
|
+
throw new Error(`File not found: ${file}`);
|
|
432
|
+
}
|
|
433
|
+
const vault = loadVaultFromSession();
|
|
434
|
+
const raw = readFileSync(file, 'utf8');
|
|
435
|
+
const ext = extname(file).toLowerCase();
|
|
436
|
+
// Parse the file
|
|
437
|
+
let entries;
|
|
438
|
+
if (ext === '.json') {
|
|
439
|
+
entries = parseJsonImport(raw);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
entries = parseEnvImport(raw);
|
|
443
|
+
}
|
|
444
|
+
if (entries.length === 0) {
|
|
445
|
+
vault.lock();
|
|
446
|
+
process.stderr.write(chalk.dim('No keys found in file.\n'));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
let imported = 0;
|
|
450
|
+
let skipped = 0;
|
|
451
|
+
for (const { key, value } of entries) {
|
|
452
|
+
// Determine service/keyName — if key contains '/', use as-is; else use 'imported/KEY'
|
|
453
|
+
const [service, keyName] = splitKeyName(key);
|
|
454
|
+
if (vault.hasKey(service, keyName)) {
|
|
455
|
+
// Key already exists — ask user
|
|
456
|
+
const choice = await promptChoice(chalk.yellow(`Key ${service}/${keyName} already exists`), [
|
|
457
|
+
{ key: 'o', label: 'Overwrite' },
|
|
458
|
+
{ key: 's', label: 'Skip' },
|
|
459
|
+
]);
|
|
460
|
+
if (choice === 's') {
|
|
461
|
+
skipped++;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
vault.addKey(service, keyName, value, { project: opts.project });
|
|
466
|
+
imported++;
|
|
467
|
+
}
|
|
468
|
+
await vault.save();
|
|
469
|
+
vault.lock();
|
|
470
|
+
auditLog('import', { file, project: opts.project, imported, skipped }, 'cli');
|
|
471
|
+
process.stderr.write(chalk.green(`✓ Imported ${imported} key(s) into project '${opts.project}'`)
|
|
472
|
+
+ (skipped > 0 ? chalk.dim(` (${skipped} skipped)`) : '')
|
|
473
|
+
+ '\n');
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
exitWithError(err.message);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
// ── run ───────────────────────────────────────────────────────────────
|
|
480
|
+
program
|
|
481
|
+
.command('run')
|
|
482
|
+
.description('Run a command with lockbox:// proxy env vars resolved from the vault')
|
|
483
|
+
.argument('<command...>', 'Command to run (e.g. "npm start")')
|
|
484
|
+
.option('--env-file <path>', 'Path to env file', '.env')
|
|
485
|
+
.addHelpText('after', `
|
|
486
|
+
Examples:
|
|
487
|
+
$ lockbox run "npm start"
|
|
488
|
+
$ lockbox run --env-file .env.proxy "python app.py"
|
|
489
|
+
$ lockbox run "env | grep OPENAI" # verify resolution
|
|
490
|
+
`)
|
|
491
|
+
.action(async (commandParts, opts) => {
|
|
492
|
+
try {
|
|
493
|
+
const envFilePath = opts.envFile;
|
|
494
|
+
// 1. Read the env file
|
|
495
|
+
if (!existsSync(envFilePath)) {
|
|
496
|
+
throw new Error(`Env file not found: ${envFilePath}`);
|
|
497
|
+
}
|
|
498
|
+
const raw = readFileSync(envFilePath, 'utf8');
|
|
499
|
+
const entries = parseEnvImport(raw);
|
|
500
|
+
if (entries.length === 0) {
|
|
501
|
+
throw new Error(`No environment variables found in ${envFilePath}`);
|
|
502
|
+
}
|
|
503
|
+
// 2. Separate proxy refs from passthrough values
|
|
504
|
+
const proxyEntries = [];
|
|
505
|
+
const env = { ...process.env };
|
|
506
|
+
for (const { key, value } of entries) {
|
|
507
|
+
const proxy = parseProxyUri(value);
|
|
508
|
+
if (proxy) {
|
|
509
|
+
proxyEntries.push({ envKey: key, ...proxy });
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
env[key] = value;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// 3. Resolve proxy references from the vault
|
|
516
|
+
if (proxyEntries.length > 0) {
|
|
517
|
+
const vault = loadVaultFromSession();
|
|
518
|
+
for (const { envKey, service, keyName } of proxyEntries) {
|
|
519
|
+
if (!vault.hasKey(service, keyName)) {
|
|
520
|
+
vault.lock();
|
|
521
|
+
throw new Error(`Proxy reference ${PROXY_PREFIX}${service}/${keyName} not found in vault`);
|
|
522
|
+
}
|
|
523
|
+
env[envKey] = vault.getKey(service, keyName);
|
|
524
|
+
}
|
|
525
|
+
vault.lock();
|
|
526
|
+
auditLog('run', {
|
|
527
|
+
envFile: envFilePath,
|
|
528
|
+
proxiesResolved: proxyEntries.length,
|
|
529
|
+
keys: proxyEntries.map(({ service, keyName }) => `${service}/${keyName}`),
|
|
530
|
+
}, 'cli');
|
|
531
|
+
process.stderr.write(chalk.dim(`Resolved ${proxyEntries.length} proxy reference(s) from vault.\n`));
|
|
532
|
+
}
|
|
533
|
+
// 4. Spawn the child process
|
|
534
|
+
const cmd = commandParts.join(' ');
|
|
535
|
+
const { status } = spawnSync(cmd, {
|
|
536
|
+
shell: true,
|
|
537
|
+
stdio: 'inherit',
|
|
538
|
+
env,
|
|
539
|
+
});
|
|
540
|
+
process.exit(status ?? 1);
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
exitWithError(err.message);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
// ── proxy-init ────────────────────────────────────────────────────────
|
|
547
|
+
program
|
|
548
|
+
.command('proxy-init')
|
|
549
|
+
.description('Generate a .env.proxy file with lockbox:// references (safe to commit)')
|
|
550
|
+
.requiredOption('-p, --project <name>', 'Project name to generate proxy for')
|
|
551
|
+
.option('-o, --output <path>', 'Output file path', '.env.proxy')
|
|
552
|
+
.addHelpText('after', `
|
|
553
|
+
Examples:
|
|
554
|
+
$ lockbox proxy-init --project myapp
|
|
555
|
+
$ lockbox proxy-init -p myapp -o .env.staging
|
|
556
|
+
$ git add .env.proxy # safe to commit!
|
|
557
|
+
`)
|
|
558
|
+
.action(async (opts) => {
|
|
559
|
+
try {
|
|
560
|
+
const vault = loadVaultFromSession();
|
|
561
|
+
const keys = vault.listKeys(opts.project);
|
|
562
|
+
vault.lock();
|
|
563
|
+
if (keys.length === 0) {
|
|
564
|
+
throw new Error(`No keys found in project "${opts.project}"`);
|
|
565
|
+
}
|
|
566
|
+
// Build the proxy env file content
|
|
567
|
+
const lines = [
|
|
568
|
+
`# Lockbox proxy env — project: ${opts.project}`,
|
|
569
|
+
`# Generated: ${new Date().toISOString()}`,
|
|
570
|
+
`# Safe to commit — contains only lockbox:// references, no real secrets`,
|
|
571
|
+
'',
|
|
572
|
+
];
|
|
573
|
+
for (const key of keys) {
|
|
574
|
+
const slashIdx = key.name.indexOf('/');
|
|
575
|
+
const service = key.name.slice(0, slashIdx);
|
|
576
|
+
const keyName = key.name.slice(slashIdx + 1);
|
|
577
|
+
const envName = envKeyName(key.name);
|
|
578
|
+
lines.push(`${envName}=${buildProxyUri(service, keyName)}`);
|
|
579
|
+
}
|
|
580
|
+
lines.push(''); // trailing newline
|
|
581
|
+
const content = lines.join('\n');
|
|
582
|
+
const outputPath = opts.output;
|
|
583
|
+
// Check if file already exists
|
|
584
|
+
if (existsSync(outputPath)) {
|
|
585
|
+
const overwrite = await promptConfirm(chalk.yellow(`${outputPath} already exists. Overwrite?`));
|
|
586
|
+
if (!overwrite) {
|
|
587
|
+
process.stderr.write(chalk.dim('Aborted.\n'));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
writeFileSync(outputPath, content, 'utf8');
|
|
592
|
+
auditLog('proxy-init', { project: opts.project, output: outputPath, count: keys.length }, 'cli');
|
|
593
|
+
process.stderr.write(chalk.green(`✓ Generated ${outputPath}\n`));
|
|
594
|
+
process.stderr.write(chalk.dim(` ${keys.length} proxy reference(s) for project "${opts.project}"\n`));
|
|
595
|
+
process.stderr.write(chalk.dim(` Run: lockbox run --env-file ${outputPath} "your-command"\n`));
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
exitWithError(err.message);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
// ── audit ──────────────────────────────────────────────────────────────
|
|
602
|
+
program
|
|
603
|
+
.command('audit')
|
|
604
|
+
.description('Show recent audit log entries')
|
|
605
|
+
.option('--since <date>', 'Only show entries after this date (ISO 8601)')
|
|
606
|
+
.option('-n, --limit <number>', 'Number of entries to show', '50')
|
|
607
|
+
.addHelpText('after', `
|
|
608
|
+
Examples:
|
|
609
|
+
$ lockbox audit
|
|
610
|
+
$ lockbox audit --since 2025-01-01
|
|
611
|
+
$ lockbox audit -n 10
|
|
612
|
+
`)
|
|
613
|
+
.action((opts) => {
|
|
614
|
+
try {
|
|
615
|
+
const limit = parseInt(opts.limit, 10) || 50;
|
|
616
|
+
const entries = readAuditLog(opts.since, limit);
|
|
617
|
+
if (entries.length === 0) {
|
|
618
|
+
process.stderr.write(chalk.dim(opts.since
|
|
619
|
+
? `No audit entries found since ${opts.since}.\n`
|
|
620
|
+
: 'No audit entries found.\n'));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const rows = entries.map((e) => [
|
|
624
|
+
shortDate(e.timestamp) + ' ' + e.timestamp.slice(11, 19),
|
|
625
|
+
e.source,
|
|
626
|
+
e.operation,
|
|
627
|
+
e.params.length > 60 ? e.params.slice(0, 57) + '...' : e.params,
|
|
628
|
+
]);
|
|
629
|
+
process.stderr.write(formatTable(['Time', 'Source', 'Operation', 'Details'], rows) + '\n');
|
|
630
|
+
process.stderr.write(chalk.dim(`\n${entries.length} entries shown\n`));
|
|
631
|
+
}
|
|
632
|
+
catch (err) {
|
|
633
|
+
exitWithError(err.message);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
// ── doctor ─────────────────────────────────────────────────────────────
|
|
637
|
+
program
|
|
638
|
+
.command('doctor')
|
|
639
|
+
.description('Run health checks on the vault')
|
|
640
|
+
.addHelpText('after', `
|
|
641
|
+
Examples:
|
|
642
|
+
$ lockbox doctor
|
|
643
|
+
`)
|
|
644
|
+
.action(async () => {
|
|
645
|
+
let issues = 0;
|
|
646
|
+
process.stderr.write(chalk.bold('Lockbox Doctor\n\n'));
|
|
647
|
+
// 1. Check vault file exists and is readable
|
|
648
|
+
const config = loadConfig();
|
|
649
|
+
const { vaultFile } = getPaths(config);
|
|
650
|
+
if (!existsSync(vaultFile)) {
|
|
651
|
+
process.stderr.write(chalk.red(' ✗ Vault file not found\n'));
|
|
652
|
+
process.stderr.write(chalk.dim(` Expected at: ${vaultFile}\n`));
|
|
653
|
+
process.stderr.write(chalk.dim(' Run `lockbox init` to create a vault.\n'));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const stats = statSync(vaultFile);
|
|
657
|
+
if (stats.size === 0) {
|
|
658
|
+
process.stderr.write(chalk.red(' ✗ Vault file is empty\n'));
|
|
659
|
+
issues++;
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
process.stderr.write(chalk.green(' ✓ Vault file exists and is readable\n'));
|
|
663
|
+
}
|
|
664
|
+
// 2. Check vault JSON is parseable
|
|
665
|
+
let vaultJson = null;
|
|
666
|
+
try {
|
|
667
|
+
const raw = readFileSync(vaultFile, 'utf8');
|
|
668
|
+
vaultJson = JSON.parse(raw);
|
|
669
|
+
const requiredFields = ['salt', 'iv', 'tag', 'ciphertext', 'hmac'];
|
|
670
|
+
const missing = requiredFields.filter((f) => !(f in vaultJson));
|
|
671
|
+
if (missing.length > 0) {
|
|
672
|
+
process.stderr.write(chalk.red(` ✗ Vault file missing fields: ${missing.join(', ')}\n`));
|
|
673
|
+
issues++;
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
process.stderr.write(chalk.green(' ✓ Vault file structure is valid\n'));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
process.stderr.write(chalk.red(' ✗ Vault file is not valid JSON\n'));
|
|
681
|
+
issues++;
|
|
682
|
+
}
|
|
683
|
+
// 3. Verify HMAC integrity (requires unlocked session)
|
|
684
|
+
const session = loadSession();
|
|
685
|
+
if (session && vaultJson) {
|
|
686
|
+
try {
|
|
687
|
+
const vault = Vault.openWithKey(session.vaultPath, session.key);
|
|
688
|
+
vault.lock();
|
|
689
|
+
process.stderr.write(chalk.green(' ✓ HMAC integrity verified\n'));
|
|
690
|
+
process.stderr.write(chalk.green(' ✓ Decryption round-trip successful\n'));
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
const msg = err.message;
|
|
694
|
+
if (msg.includes('integrity') || msg.includes('tampered')) {
|
|
695
|
+
process.stderr.write(chalk.red(' ✗ Vault file has been tampered with or corrupted\n'));
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
process.stderr.write(chalk.red(` ✗ Vault decryption failed: ${msg}\n`));
|
|
699
|
+
}
|
|
700
|
+
issues++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
else if (!session) {
|
|
704
|
+
process.stderr.write(chalk.yellow(' ⚠ Vault is locked — cannot verify HMAC or decryption\n'));
|
|
705
|
+
process.stderr.write(chalk.dim(' Run `lockbox unlock` to enable full health check.\n'));
|
|
706
|
+
}
|
|
707
|
+
// 4. Check session file status
|
|
708
|
+
if (session) {
|
|
709
|
+
const remaining = sessionTimeRemaining();
|
|
710
|
+
const mins = remaining ? Math.floor(remaining / 60) : 0;
|
|
711
|
+
const secs = remaining ? remaining % 60 : 0;
|
|
712
|
+
process.stderr.write(chalk.green(` ✓ Session active (${mins}m ${secs}s remaining)\n`));
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
process.stderr.write(chalk.dim(' ○ No active session (vault is locked)\n'));
|
|
716
|
+
}
|
|
717
|
+
// 5. Summary
|
|
718
|
+
process.stderr.write('\n');
|
|
719
|
+
if (issues === 0) {
|
|
720
|
+
process.stderr.write(chalk.green.bold(' ✓ Vault is healthy\n'));
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
process.stderr.write(chalk.red.bold(` ✗ ${issues} issue(s) found\n`));
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=index.js.map
|