dsh-vault 0.2.0 → 0.4.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-zh.md +14 -4
- package/README.md +14 -4
- package/lib/client.js +179 -27
- package/lib/client.js.map +1 -1
- package/lib/index.js +396 -27
- package/lib/store.js +203 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -53,6 +53,8 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
53
53
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
54
54
|
import Schema from '@deepseek-ai/schemastery';
|
|
55
55
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
56
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
57
|
+
import { dirname, join } from 'node:path';
|
|
56
58
|
import { openVault, defaultVaultPath } from "./store.js";
|
|
57
59
|
import { totp } from "./totp.js";
|
|
58
60
|
import { generatePassword } from "./password.js";
|
|
@@ -65,28 +67,63 @@ export const Config = Schema.object({
|
|
|
65
67
|
name: Schema.string(),
|
|
66
68
|
accessMode: Schema.union([
|
|
67
69
|
Schema.const('readonly'),
|
|
68
|
-
Schema.const('
|
|
70
|
+
Schema.const('ask'),
|
|
71
|
+
Schema.const('auto'),
|
|
69
72
|
]),
|
|
70
73
|
autoCapture: Schema.boolean(),
|
|
74
|
+
lockTimeoutSeconds: Schema.number(),
|
|
75
|
+
exportPasswordEnv: Schema.string(),
|
|
71
76
|
});
|
|
72
|
-
export function apply(ctx, config) {
|
|
77
|
+
export async function apply(ctx, config) {
|
|
73
78
|
const masterPassword = resolveMasterPassword(config);
|
|
74
|
-
const
|
|
75
|
-
const
|
|
79
|
+
const WRITE_TOOLS = new Set(['vault_add', 'vault_update', 'vault_delete']);
|
|
80
|
+
const lockTimeoutSeconds = config.lockTimeoutSeconds ?? 0;
|
|
81
|
+
/** Shared access policy; resolved once, mutated by the UI via setAccessMode. */
|
|
82
|
+
const policy = await sharedAccessPolicy(config);
|
|
76
83
|
/** Reject mutations when the vault is in readonly mode. */
|
|
77
84
|
function assertWritable(action) {
|
|
78
|
-
if (
|
|
79
|
-
throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode
|
|
85
|
+
if (policy.mode === 'readonly') {
|
|
86
|
+
throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode to "ask" or "auto" to enable)`);
|
|
80
87
|
}
|
|
81
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Route writes through the harness approval channel in `ask` mode: the user
|
|
91
|
+
* confirms every add/update/delete ("prompt before writing"). `auto` allows
|
|
92
|
+
* without a prompt; `readonly` denies in assertWritable. This listener must
|
|
93
|
+
* call `next()` (waterfall event).
|
|
94
|
+
*/
|
|
95
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
96
|
+
if (policy.mode === 'ask' && exec?.name !== undefined && WRITE_TOOLS.has(exec.name)) {
|
|
97
|
+
return { kind: 'ask', reason: `dsh-vault: ${exec.name} requires your confirmation in "ask" (prompt-before-write) mode` };
|
|
98
|
+
}
|
|
99
|
+
return next();
|
|
100
|
+
});
|
|
82
101
|
/** Ensure the shared store is open (lazily on first use, so a missing
|
|
83
102
|
* master password fails at the first tool call with a clear message). */
|
|
84
103
|
async function ensureStore() {
|
|
85
|
-
|
|
104
|
+
const store = await sharedVaultStore(masterPassword, config);
|
|
105
|
+
// Install the auto-lock policy once per store instance.
|
|
106
|
+
if (lockTimeoutSeconds > 0)
|
|
107
|
+
store.setAutoLock(lockTimeoutSeconds * 1000);
|
|
108
|
+
return store;
|
|
86
109
|
}
|
|
87
|
-
/**
|
|
110
|
+
/** Guard every tool: enforce auto-lock (relock when idle, refuse when
|
|
111
|
+
* locked) and touch the activity timestamp. */
|
|
112
|
+
async function guardStore() {
|
|
113
|
+
const store = await ensureStore();
|
|
114
|
+
if (store.expired) {
|
|
115
|
+
store.lock();
|
|
116
|
+
throw new Error('vault is locked (idle timeout) — call vault_unlock to re-open it');
|
|
117
|
+
}
|
|
118
|
+
if (store.isLocked) {
|
|
119
|
+
throw new Error('vault is locked — call vault_unlock to re-open it');
|
|
120
|
+
}
|
|
121
|
+
store.touch();
|
|
122
|
+
return store;
|
|
123
|
+
}
|
|
124
|
+
/** Read a full entry (with secrets) by id (respects locking). */
|
|
88
125
|
async function readEntry(id) {
|
|
89
|
-
const s = await
|
|
126
|
+
const s = await guardStore();
|
|
90
127
|
return s.get(id);
|
|
91
128
|
}
|
|
92
129
|
// System prompt guidance: tells the model how the vault works, what the
|
|
@@ -99,12 +136,16 @@ export function apply(ctx, config) {
|
|
|
99
136
|
text: () => {
|
|
100
137
|
const lines = [
|
|
101
138
|
'## Encrypted credential vault (dsh-vault)',
|
|
102
|
-
|
|
139
|
+
policy.mode === 'readonly'
|
|
140
|
+
? 'Access mode: READONLY — you may search/read/generate codes but MUST NOT add, update, or delete entries.'
|
|
141
|
+
: policy.mode === 'ask'
|
|
142
|
+
? 'Access mode: ASK (prompt-before-write) — reads are free; every add/update/delete will ask the user for confirmation through the approval channel.'
|
|
143
|
+
: 'Access mode: AUTO (automatic read-write) — you may add, update, delete, search, and read entries without a per-call prompt.',
|
|
103
144
|
'Credentials are encrypted at rest (AES-256-GCM) under a master password the user configured; never ask for that password.',
|
|
104
145
|
'Use vault_search to find entries by title/username/host and vault_get (by id) to read full credentials when the task needs them.',
|
|
105
146
|
'Do not repeat secrets in the conversation when a credential was obtained via vault_get.',
|
|
106
147
|
];
|
|
107
|
-
if (autoCapture) {
|
|
148
|
+
if (policy.autoCapture) {
|
|
108
149
|
lines.push('Auto-capture is ON: when the user shares an API key, token, password, or other credential in conversation', '(e.g. "my npm token is npm_…", "use this GitHub PAT"), offer to store it with vault_add under a clear title.', 'Capture user preference: if they agree (or have previously agreed to auto-save), call vault_add immediately;', 'if they decline or it is unclear, do NOT store it. Never auto-save credentials that were not explicitly shared.');
|
|
109
150
|
}
|
|
110
151
|
else {
|
|
@@ -166,7 +207,7 @@ export function apply(ctx, config) {
|
|
|
166
207
|
assertWritable('vault_add');
|
|
167
208
|
if (!args.title.trim())
|
|
168
209
|
throw new Error('vault_add: title must not be empty');
|
|
169
|
-
const s = await
|
|
210
|
+
const s = await guardStore();
|
|
170
211
|
const entry = await s.add({
|
|
171
212
|
title: args.title.trim(),
|
|
172
213
|
...(args.kind !== undefined ? { kind: args.kind } : {}),
|
|
@@ -247,7 +288,7 @@ export function apply(ctx, config) {
|
|
|
247
288
|
}],
|
|
248
289
|
},
|
|
249
290
|
async execute(args) {
|
|
250
|
-
const s = await
|
|
291
|
+
const s = await guardStore();
|
|
251
292
|
const limit = validateLimit(args.limit, 'vault_search');
|
|
252
293
|
const results = s.search(args.query, limit);
|
|
253
294
|
return { results, total: results.length };
|
|
@@ -301,7 +342,7 @@ export function apply(ctx, config) {
|
|
|
301
342
|
},
|
|
302
343
|
async execute(args) {
|
|
303
344
|
assertWritable('vault_update');
|
|
304
|
-
const s = await
|
|
345
|
+
const s = await guardStore();
|
|
305
346
|
const patch = {};
|
|
306
347
|
for (const key of [
|
|
307
348
|
'title', 'kind', 'username', 'email', 'phone', 'password', 'host', 'port', 'privateKey',
|
|
@@ -338,7 +379,7 @@ export function apply(ctx, config) {
|
|
|
338
379
|
},
|
|
339
380
|
async execute(args) {
|
|
340
381
|
assertWritable('vault_delete');
|
|
341
|
-
const s = await
|
|
382
|
+
const s = await guardStore();
|
|
342
383
|
const deleted = await s.delete(args.id);
|
|
343
384
|
return { deleted, message: deleted ? 'entry deleted' : 'entry not found' };
|
|
344
385
|
},
|
|
@@ -428,6 +469,194 @@ export function apply(ctx, config) {
|
|
|
428
469
|
return { password, length: password.length };
|
|
429
470
|
},
|
|
430
471
|
}));
|
|
472
|
+
// ── vault_lock / vault_unlock: explicit lock & unlock ──────────────────────
|
|
473
|
+
ctx.tools.register(defineTool({
|
|
474
|
+
name: 'vault_lock',
|
|
475
|
+
description: 'Lock the vault immediately: wipe the derived key from memory so every '
|
|
476
|
+
+ 'subsequent read/write requires vault_unlock. Use when leaving the machine.',
|
|
477
|
+
parameters: {},
|
|
478
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { locked: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.locked ? 'vault locked' : 'vault not unlocked' }] },
|
|
479
|
+
async execute() {
|
|
480
|
+
const s = await ensureStore();
|
|
481
|
+
const was = s.isLocked;
|
|
482
|
+
s.lock();
|
|
483
|
+
return { locked: !was };
|
|
484
|
+
},
|
|
485
|
+
}));
|
|
486
|
+
ctx.tools.register(defineTool({
|
|
487
|
+
name: 'vault_unlock',
|
|
488
|
+
description: 'Unlock the vault with the master password (the deployment owns the password; '
|
|
489
|
+
+ 'the model never supplies it). Needed after an explicit vault_lock or an auto-lock idle timeout.',
|
|
490
|
+
parameters: {},
|
|
491
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { unlocked: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.unlocked ? 'vault unlocked' : 'vault already unlocked' }] },
|
|
492
|
+
async execute() {
|
|
493
|
+
const s = await ensureStore();
|
|
494
|
+
if (!s.isLocked)
|
|
495
|
+
return { unlocked: false };
|
|
496
|
+
await s.unlock();
|
|
497
|
+
return { unlocked: true };
|
|
498
|
+
},
|
|
499
|
+
}));
|
|
500
|
+
// ── vault_rotation: expiry / rotation report ───────────────────────────────
|
|
501
|
+
ctx.tools.register(defineTool({
|
|
502
|
+
name: 'vault_rotation',
|
|
503
|
+
description: 'Report credentials that are expired, due for rotation (rotationDays elapsed), '
|
|
504
|
+
+ 'or expiring within 7 days. Returns summaries with a due state — never secrets.',
|
|
505
|
+
parameters: {},
|
|
506
|
+
output: {
|
|
507
|
+
schema: { type: 'object', additionalProperties: false, properties: { entries: { type: 'array', required: true, items: { type: 'json' } } } },
|
|
508
|
+
render: (_a, v) => [{ type: 'text', text: v.entries.length === 0 ? 'no rotation items' : JSON.stringify(v.entries) }],
|
|
509
|
+
},
|
|
510
|
+
async execute() {
|
|
511
|
+
const s = await guardStore();
|
|
512
|
+
return { entries: s.rotationReport() };
|
|
513
|
+
},
|
|
514
|
+
}));
|
|
515
|
+
// ── vault_health: weak / reused credential scan ────────────────────────────
|
|
516
|
+
ctx.tools.register(defineTool({
|
|
517
|
+
name: 'vault_health',
|
|
518
|
+
description: 'Scan the vault for weak passwords (shorter than 12 chars) and credentials reused '
|
|
519
|
+
+ 'across entries. Returns non-secret findings (entry summaries grouped by the reused value).',
|
|
520
|
+
parameters: {},
|
|
521
|
+
output: {
|
|
522
|
+
schema: { type: 'object', additionalProperties: false, properties: { weak: { type: 'array', required: true, items: { type: 'json' } }, reused: { type: 'array', required: true, items: { type: 'json' } } } },
|
|
523
|
+
render: (_a, v) => [{ type: 'text', text: `weak: ${v.weak.length}, reused groups: ${v.reused.length}` }],
|
|
524
|
+
},
|
|
525
|
+
async execute() {
|
|
526
|
+
const s = await guardStore();
|
|
527
|
+
return s.health();
|
|
528
|
+
},
|
|
529
|
+
}));
|
|
530
|
+
// ── vault_restore / vault_purge: trash management ──────────────────────────
|
|
531
|
+
ctx.tools.register(defineTool({
|
|
532
|
+
name: 'vault_restore',
|
|
533
|
+
description: 'Restore a soft-deleted entry from the vault trash back into the active set.',
|
|
534
|
+
parameters: { id: { type: 'string', required: true, description: 'The trashed entry id.' } },
|
|
535
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { restored: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.restored ? 'entry restored' : 'entry not found in trash' }] },
|
|
536
|
+
async execute(args) {
|
|
537
|
+
assertWritable('vault_restore');
|
|
538
|
+
const s = await guardStore();
|
|
539
|
+
return { restored: await s.restore(args.id) };
|
|
540
|
+
},
|
|
541
|
+
}));
|
|
542
|
+
ctx.tools.register(defineTool({
|
|
543
|
+
name: 'vault_purge',
|
|
544
|
+
description: 'Permanently delete an entry (active or trashed). Cannot be undone — prefer vault_delete '
|
|
545
|
+
+ '(soft delete) unless the entry must be removed from disk.',
|
|
546
|
+
parameters: { id: { type: 'string', required: true, description: 'The entry id to purge.' } },
|
|
547
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { purged: { type: 'boolean', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.purged ? 'entry purged' : 'entry not found' }] },
|
|
548
|
+
async execute(args) {
|
|
549
|
+
assertWritable('vault_purge');
|
|
550
|
+
const s = await guardStore();
|
|
551
|
+
return { purged: await s.purge(args.id) };
|
|
552
|
+
},
|
|
553
|
+
}));
|
|
554
|
+
// ── vault_export / vault_import: portable encrypted transfer ───────────────
|
|
555
|
+
ctx.tools.register(defineTool({
|
|
556
|
+
name: 'vault_export',
|
|
557
|
+
description: 'Export the entire vault (including trash) as a single encrypted document under a '
|
|
558
|
+
+ 'separate export password (from the exportPasswordEnv config). Use for backup or migration; '
|
|
559
|
+
+ 'the export can be re-imported with vault_import. Never pass the password as an argument.',
|
|
560
|
+
parameters: {},
|
|
561
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { exported: { type: 'boolean', required: true }, note: { type: 'string', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.note }] },
|
|
562
|
+
async execute() {
|
|
563
|
+
const exportPassword = resolveExportPassword(config);
|
|
564
|
+
const s = await guardStore();
|
|
565
|
+
const blob = await s.exportEncrypted(exportPassword);
|
|
566
|
+
const file = join(dirname(resolveVaultPath(config)), `vault-export-${Date.now()}.json`);
|
|
567
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
568
|
+
await writeFile(file, blob, { mode: 0o600 });
|
|
569
|
+
return { exported: true, note: `vault exported to ${file}` };
|
|
570
|
+
},
|
|
571
|
+
}));
|
|
572
|
+
ctx.tools.register(defineTool({
|
|
573
|
+
name: 'vault_import',
|
|
574
|
+
description: 'Import a previously exported vault document (see vault_export), merging entries by '
|
|
575
|
+
+ 'id. Pass the document path; the export password comes from the exportPasswordEnv config.',
|
|
576
|
+
parameters: { path: { type: 'string', required: true, description: 'Absolute path of the exported vault JSON file.' } },
|
|
577
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { imported: { type: 'integer', required: true } } }, render: (_a, v) => [{ type: 'text', text: `imported ${v.imported} entries` }] },
|
|
578
|
+
async execute(args) {
|
|
579
|
+
assertWritable('vault_import');
|
|
580
|
+
const exportPassword = resolveExportPassword(config);
|
|
581
|
+
const s = await guardStore();
|
|
582
|
+
const blob = await readFile(args.path, 'utf8');
|
|
583
|
+
const count = await s.importEncrypted(blob, exportPassword);
|
|
584
|
+
return { imported: count };
|
|
585
|
+
},
|
|
586
|
+
}));
|
|
587
|
+
// ── vault_fill: find the credential that fits a target ─────────────────────
|
|
588
|
+
ctx.tools.register(defineTool({
|
|
589
|
+
name: 'vault_fill',
|
|
590
|
+
description: 'Find the vault entry that fits a target (host/URL/username/title) and return the '
|
|
591
|
+
+ 'ready-to-use credentials (secrets included, as the caller needs them for the actual login). '
|
|
592
|
+
+ 'Use instead of vault_search+vault_get when you know what you are connecting to.',
|
|
593
|
+
parameters: {
|
|
594
|
+
target: { type: 'string', required: true, description: 'Host, URL, username, or title to match.' },
|
|
595
|
+
},
|
|
596
|
+
output: {
|
|
597
|
+
schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean', required: true }, entry: { type: 'json' } } },
|
|
598
|
+
render: (_a, v) => [{ type: 'text', text: v.found ? `matched: ${v.entry?.title ?? 'entry'}` : 'no matching entry' }],
|
|
599
|
+
},
|
|
600
|
+
async execute(args) {
|
|
601
|
+
const s = await guardStore();
|
|
602
|
+
const needle = args.target.trim().toLowerCase();
|
|
603
|
+
for (const entry of s.list()) {
|
|
604
|
+
const haystack = [entry.title, entry.host, entry.url, entry.username, entry.email].filter(Boolean).join(' ').toLowerCase();
|
|
605
|
+
if (needle.length > 0 && haystack.includes(needle)) {
|
|
606
|
+
return { found: true, entry: stripTimestamps(entry) };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return { found: false };
|
|
610
|
+
},
|
|
611
|
+
}));
|
|
612
|
+
// ── vault_env: environment-variable export ─────────────────────────────────
|
|
613
|
+
ctx.tools.register(defineTool({
|
|
614
|
+
name: 'vault_env',
|
|
615
|
+
description: 'Render entries flagged for environment export (tags contain "env") as KEY=VALUE lines '
|
|
616
|
+
+ 'suitable for .env or export statements. Keys derive from the title + field name; values are the '
|
|
617
|
+
+ 'secrets. Returns the lines so the caller can write them to a file (user-authorized).',
|
|
618
|
+
parameters: {},
|
|
619
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { lines: { type: 'array', required: true, items: { type: 'string' } } } }, render: (_a, v) => [{ type: 'text', text: v.lines.join('\n') }] },
|
|
620
|
+
async execute() {
|
|
621
|
+
const s = await guardStore();
|
|
622
|
+
const lines = [];
|
|
623
|
+
const keyOf = (title, field) => title.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_' + field.toUpperCase();
|
|
624
|
+
for (const entry of s.list()) {
|
|
625
|
+
if (!(entry.tags ?? []).includes('env'))
|
|
626
|
+
continue;
|
|
627
|
+
for (const [field, value] of Object.entries(entry)) {
|
|
628
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
629
|
+
continue;
|
|
630
|
+
if (['id', 'title', 'kind', 'sensitivity', 'host', 'url', 'notes', 'createdAt', 'updatedAt', 'deletedAt'].includes(field))
|
|
631
|
+
continue;
|
|
632
|
+
if (['username', 'email', 'phone', 'port', 'tags'].includes(field))
|
|
633
|
+
continue;
|
|
634
|
+
lines.push(`${keyOf(entry.title, field)}=${value}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return { lines };
|
|
638
|
+
},
|
|
639
|
+
}));
|
|
640
|
+
// ── vault_templates: field templates by kind ───────────────────────────────
|
|
641
|
+
const TEMPLATES = {
|
|
642
|
+
login: { username: 'account username', email: 'account email', password: 'account password' },
|
|
643
|
+
ssh: { host: 'server host', port: 'port (e.g. 22)', username: 'login user', password: 'password or passphrase', privateKey: 'PEM private key' },
|
|
644
|
+
'api-key': { apiKey: 'the API key', url: 'API base URL', username: 'owner/account (optional)' },
|
|
645
|
+
oauth: { accessToken: 'access token', refreshToken: 'refresh token', expiresAt: 'expiry epoch millis', clientId: 'client id (via fields)' },
|
|
646
|
+
secret: { secret: 'the shared secret', notes: 'what it is for' },
|
|
647
|
+
custom: { fields: 'arbitrary key/value pairs' },
|
|
648
|
+
};
|
|
649
|
+
ctx.tools.register(defineTool({
|
|
650
|
+
name: 'vault_templates',
|
|
651
|
+
description: 'Return the recommended fields for a credential kind, so vault_add can be called with '
|
|
652
|
+
+ 'the right field names (e.g. kind ssh → host/port/username/password/privateKey).',
|
|
653
|
+
parameters: { kind: { type: 'string', description: 'Entry kind; default login.', enum: ['login', 'ssh', 'api-key', 'secret', 'oauth', 'custom'] } },
|
|
654
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', required: true }, fields: { type: 'json', required: true } } }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v.fields) }] },
|
|
655
|
+
async execute(args) {
|
|
656
|
+
const kind = args.kind ?? 'login';
|
|
657
|
+
return { kind, fields: TEMPLATES[kind] ?? TEMPLATES.login };
|
|
658
|
+
},
|
|
659
|
+
}));
|
|
431
660
|
// UI-facing Remote gateway: the browser Settings Vault page talks to these
|
|
432
661
|
// methods through the /api RPC channel (loopback-trusted), bypassing the
|
|
433
662
|
// model-tool layer entirely. Secrets are returned because the UI is the
|
|
@@ -437,8 +666,7 @@ export function apply(ctx, config) {
|
|
|
437
666
|
masterPassword,
|
|
438
667
|
...(config.path !== undefined ? { path: config.path } : {}),
|
|
439
668
|
...(config.name !== undefined ? { name: config.name } : {}),
|
|
440
|
-
|
|
441
|
-
autoCapture,
|
|
669
|
+
accessPolicy: policy,
|
|
442
670
|
});
|
|
443
671
|
}
|
|
444
672
|
/**
|
|
@@ -451,7 +679,12 @@ let VaultGateway = (() => {
|
|
|
451
679
|
let _classSuper = TypertRemoteService;
|
|
452
680
|
let _instanceExtraInitializers = [];
|
|
453
681
|
let _config_decorators;
|
|
682
|
+
let _setAccessMode_decorators;
|
|
454
683
|
let _list_decorators;
|
|
684
|
+
let _trash_decorators;
|
|
685
|
+
let _restore_decorators;
|
|
686
|
+
let _rotation_decorators;
|
|
687
|
+
let _health_decorators;
|
|
455
688
|
let _get_decorators;
|
|
456
689
|
let _search_decorators;
|
|
457
690
|
let _add_decorators;
|
|
@@ -462,7 +695,12 @@ let VaultGateway = (() => {
|
|
|
462
695
|
static {
|
|
463
696
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
464
697
|
_config_decorators = [Remote('config')];
|
|
698
|
+
_setAccessMode_decorators = [Remote('setAccessMode')];
|
|
465
699
|
_list_decorators = [Remote('list')];
|
|
700
|
+
_trash_decorators = [Remote('trash')];
|
|
701
|
+
_restore_decorators = [Remote('restore')];
|
|
702
|
+
_rotation_decorators = [Remote('rotation')];
|
|
703
|
+
_health_decorators = [Remote('health')];
|
|
466
704
|
_get_decorators = [Remote('get')];
|
|
467
705
|
_search_decorators = [Remote('search')];
|
|
468
706
|
_add_decorators = [Remote('add')];
|
|
@@ -470,7 +708,12 @@ let VaultGateway = (() => {
|
|
|
470
708
|
_delete_decorators = [Remote('delete')];
|
|
471
709
|
_totp_decorators = [Remote('totp')];
|
|
472
710
|
__esDecorate(this, null, _config_decorators, { kind: "method", name: "config", static: false, private: false, access: { has: obj => "config" in obj, get: obj => obj.config }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
711
|
+
__esDecorate(this, null, _setAccessMode_decorators, { kind: "method", name: "setAccessMode", static: false, private: false, access: { has: obj => "setAccessMode" in obj, get: obj => obj.setAccessMode }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
473
712
|
__esDecorate(this, null, _list_decorators, { kind: "method", name: "list", static: false, private: false, access: { has: obj => "list" in obj, get: obj => obj.list }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
713
|
+
__esDecorate(this, null, _trash_decorators, { kind: "method", name: "trash", static: false, private: false, access: { has: obj => "trash" in obj, get: obj => obj.trash }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
714
|
+
__esDecorate(this, null, _restore_decorators, { kind: "method", name: "restore", static: false, private: false, access: { has: obj => "restore" in obj, get: obj => obj.restore }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
715
|
+
__esDecorate(this, null, _rotation_decorators, { kind: "method", name: "rotation", static: false, private: false, access: { has: obj => "rotation" in obj, get: obj => obj.rotation }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
716
|
+
__esDecorate(this, null, _health_decorators, { kind: "method", name: "health", static: false, private: false, access: { has: obj => "health" in obj, get: obj => obj.health }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
474
717
|
__esDecorate(this, null, _get_decorators, { kind: "method", name: "get", static: false, private: false, access: { has: obj => "get" in obj, get: obj => obj.get }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
475
718
|
__esDecorate(this, null, _search_decorators, { kind: "method", name: "search", static: false, private: false, access: { has: obj => "search" in obj, get: obj => obj.search }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
476
719
|
__esDecorate(this, null, _add_decorators, { kind: "method", name: "add", static: false, private: false, access: { has: obj => "add" in obj, get: obj => obj.add }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -483,15 +726,13 @@ let VaultGateway = (() => {
|
|
|
483
726
|
masterPassword = __runInitializers(this, _instanceExtraInitializers);
|
|
484
727
|
vaultPath;
|
|
485
728
|
vaultName;
|
|
486
|
-
|
|
487
|
-
autoCapture;
|
|
729
|
+
accessPolicy;
|
|
488
730
|
constructor(ctx, config) {
|
|
489
731
|
super(ctx, 'vault');
|
|
490
732
|
this.masterPassword = config.masterPassword ?? resolveMasterPassword(config);
|
|
491
733
|
this.vaultPath = config.path;
|
|
492
734
|
this.vaultName = config.name;
|
|
493
|
-
this.
|
|
494
|
-
this.autoCapture = config.autoCapture ?? false;
|
|
735
|
+
this.accessPolicy = config.accessPolicy ?? { mode: config.accessMode ?? 'ask', autoCapture: config.autoCapture ?? false };
|
|
495
736
|
}
|
|
496
737
|
async ensureStore() {
|
|
497
738
|
return sharedVaultStore(this.masterPassword, {
|
|
@@ -501,19 +742,58 @@ let VaultGateway = (() => {
|
|
|
501
742
|
}
|
|
502
743
|
/** Reject mutations when the vault is in readonly mode (UI surface). */
|
|
503
744
|
assertWritable(action) {
|
|
504
|
-
if (this.
|
|
505
|
-
throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode
|
|
745
|
+
if (this.accessPolicy.mode === 'readonly') {
|
|
746
|
+
throw new Error(`vault: ${action} is disabled in readonly mode (set accessMode to "ask" or "auto" to enable)`);
|
|
506
747
|
}
|
|
507
748
|
}
|
|
508
749
|
/** Current access policy and capture preference, for the Settings UI. */
|
|
509
750
|
async config() {
|
|
510
|
-
return { accessMode: this.
|
|
751
|
+
return { accessMode: this.accessPolicy.mode, autoCapture: this.accessPolicy.autoCapture };
|
|
752
|
+
}
|
|
753
|
+
/** Switch the runtime access mode from the Settings UI and persist it. */
|
|
754
|
+
async setAccessMode(mode) {
|
|
755
|
+
if (mode !== 'readonly' && mode !== 'ask' && mode !== 'auto') {
|
|
756
|
+
throw new Error(`vault: invalid accessMode "${String(mode)}" (expected readonly, ask, or auto)`);
|
|
757
|
+
}
|
|
758
|
+
this.accessPolicy.mode = mode;
|
|
759
|
+
await this.persistPolicy();
|
|
760
|
+
return { accessMode: this.accessPolicy.mode, autoCapture: this.accessPolicy.autoCapture };
|
|
761
|
+
}
|
|
762
|
+
/** Persist the current policy to `<vault dir>/access.json`. */
|
|
763
|
+
async persistPolicy() {
|
|
764
|
+
const file = accessPolicyFile({
|
|
765
|
+
...(this.vaultPath !== undefined ? { path: this.vaultPath } : {}),
|
|
766
|
+
...(this.vaultName !== undefined ? { name: this.vaultName } : {}),
|
|
767
|
+
});
|
|
768
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
769
|
+
await writeFile(file, JSON.stringify(this.accessPolicy, null, 2), { mode: 0o600 });
|
|
511
770
|
}
|
|
512
771
|
/** List every entry as a non-secret summary. */
|
|
513
772
|
async list() {
|
|
514
773
|
const store = await this.ensureStore();
|
|
515
774
|
return { entries: store.list().map(toSummary) };
|
|
516
775
|
}
|
|
776
|
+
/** List trashed (soft-deleted) entries as non-secret summaries. */
|
|
777
|
+
async trash() {
|
|
778
|
+
const store = await this.ensureStore();
|
|
779
|
+
return { entries: store.listTrash().map(toSummary) };
|
|
780
|
+
}
|
|
781
|
+
/** Restore a trashed entry (non-secret summary returned). */
|
|
782
|
+
async restore(id) {
|
|
783
|
+
this.assertWritable('restore');
|
|
784
|
+
const store = await this.ensureStore();
|
|
785
|
+
return { restored: await store.restore(id) };
|
|
786
|
+
}
|
|
787
|
+
/** Rotation/expiry report (no secrets). */
|
|
788
|
+
async rotation() {
|
|
789
|
+
const store = await this.ensureStore();
|
|
790
|
+
return { entries: store.rotationReport() };
|
|
791
|
+
}
|
|
792
|
+
/** Health scan findings (no secrets). */
|
|
793
|
+
async health() {
|
|
794
|
+
const store = await this.ensureStore();
|
|
795
|
+
return store.health();
|
|
796
|
+
}
|
|
517
797
|
/** Read one full entry (including secrets) by id. */
|
|
518
798
|
async get(id) {
|
|
519
799
|
const store = await this.ensureStore();
|
|
@@ -613,6 +893,18 @@ function resolveMasterPassword(config) {
|
|
|
613
893
|
}
|
|
614
894
|
throw new Error('dsh-vault: configure masterPassword or masterPasswordEnv to unlock the vault');
|
|
615
895
|
}
|
|
896
|
+
/** Resolve the export/import password from the configured environment
|
|
897
|
+
* variable, or fail loudly (the model must never pass it as an argument). */
|
|
898
|
+
function resolveExportPassword(config) {
|
|
899
|
+
if (config.exportPasswordEnv !== undefined) {
|
|
900
|
+
const fromEnv = process.env[config.exportPasswordEnv];
|
|
901
|
+
if (fromEnv === undefined || fromEnv.length === 0) {
|
|
902
|
+
throw new Error(`dsh-vault: environment variable ${config.exportPasswordEnv} is not set (needed for vault_export/import)`);
|
|
903
|
+
}
|
|
904
|
+
return fromEnv;
|
|
905
|
+
}
|
|
906
|
+
throw new Error('dsh-vault: configure exportPasswordEnv to use vault_export / vault_import');
|
|
907
|
+
}
|
|
616
908
|
/**
|
|
617
909
|
* Shared vault-store instances keyed by resolved path + master password. The
|
|
618
910
|
* model tools and the UI-facing VaultGateway must observe ONE store so writes
|
|
@@ -623,12 +915,77 @@ function resolveMasterPassword(config) {
|
|
|
623
915
|
* (and silently "unlocking") each other's store.
|
|
624
916
|
*/
|
|
625
917
|
const sharedVaultStores = new Map();
|
|
918
|
+
const sharedAccessPolicies = new Map();
|
|
626
919
|
/** Resolve the canonical vault file path for a config (path override or name). */
|
|
627
920
|
function resolveVaultPath(config) {
|
|
628
921
|
if (config.path !== undefined)
|
|
629
922
|
return config.path;
|
|
630
923
|
return defaultVaultPath(config.name);
|
|
631
924
|
}
|
|
925
|
+
/** The `<vault dir>/access.json` path holding the persisted access policy. */
|
|
926
|
+
function accessPolicyFile(config) {
|
|
927
|
+
return join(dirname(resolveVaultPath(config)), 'access.json');
|
|
928
|
+
}
|
|
929
|
+
/** Read a persisted access policy, defaulting to the configured values. */
|
|
930
|
+
async function loadAccessPolicy(config) {
|
|
931
|
+
const fallback = { mode: config.accessMode ?? 'ask', autoCapture: config.autoCapture ?? false };
|
|
932
|
+
const file = accessPolicyFile(config);
|
|
933
|
+
try {
|
|
934
|
+
const raw = await readFile(file, 'utf8');
|
|
935
|
+
const parsed = JSON.parse(raw);
|
|
936
|
+
return {
|
|
937
|
+
mode: parsed.mode === 'readonly' || parsed.mode === 'ask' || parsed.mode === 'auto' ? parsed.mode : fallback.mode,
|
|
938
|
+
autoCapture: typeof parsed.autoCapture === 'boolean' ? parsed.autoCapture : fallback.autoCapture,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return fallback;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/** Open (or reuse) the shared access policy for one vault path. */
|
|
946
|
+
async function sharedAccessPolicy(config) {
|
|
947
|
+
const path = resolveVaultPath(config);
|
|
948
|
+
const existing = sharedAccessPolicies.get(path);
|
|
949
|
+
if (existing !== undefined)
|
|
950
|
+
return existing;
|
|
951
|
+
const policy = await loadAccessPolicy(config);
|
|
952
|
+
sharedAccessPolicies.set(path, policy);
|
|
953
|
+
return policy;
|
|
954
|
+
}
|
|
955
|
+
const MAX_ATTEMPTS = 5;
|
|
956
|
+
const LOCKOUT_MS = 5 * 60 * 1000;
|
|
957
|
+
/** `<vault dir>/meta.json` — failed-attempt counter and lockout window. */
|
|
958
|
+
function metaFile(config) {
|
|
959
|
+
return join(dirname(resolveVaultPath(config)), 'meta.json');
|
|
960
|
+
}
|
|
961
|
+
async function readMeta(config) {
|
|
962
|
+
try {
|
|
963
|
+
const raw = await readFile(metaFile(config), 'utf8');
|
|
964
|
+
return JSON.parse(raw);
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
return { failedAttempts: 0 };
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
async function writeMeta(config, meta) {
|
|
971
|
+
const file = metaFile(config);
|
|
972
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
973
|
+
await writeFile(file, JSON.stringify(meta), { mode: 0o600 });
|
|
974
|
+
}
|
|
975
|
+
/** Record one failed unlock: increment the counter, start a lockout when the
|
|
976
|
+
* threshold is crossed. */
|
|
977
|
+
async function recordFailedAttempt(config) {
|
|
978
|
+
const meta = await readMeta(config);
|
|
979
|
+
meta.failedAttempts = (meta.failedAttempts ?? 0) + 1;
|
|
980
|
+
if (meta.failedAttempts >= MAX_ATTEMPTS) {
|
|
981
|
+
meta.lockedUntil = Date.now() + LOCKOUT_MS;
|
|
982
|
+
}
|
|
983
|
+
await writeMeta(config, meta);
|
|
984
|
+
}
|
|
985
|
+
/** Clear the counter after a successful unlock. */
|
|
986
|
+
async function clearFailedAttempts(config) {
|
|
987
|
+
await writeMeta(config, { failedAttempts: 0 });
|
|
988
|
+
}
|
|
632
989
|
/**
|
|
633
990
|
* Open (or reuse) the vault store for one deployment configuration. All
|
|
634
991
|
* callers within the process share the same instance for the same path and
|
|
@@ -637,22 +994,34 @@ function resolveVaultPath(config) {
|
|
|
637
994
|
*/
|
|
638
995
|
async function sharedVaultStore(masterPassword, config) {
|
|
639
996
|
const path = resolveVaultPath(config);
|
|
997
|
+
// Brute-force guard: refuse to even attempt while a lockout window is open.
|
|
998
|
+
const meta = await readMeta(config);
|
|
999
|
+
if (meta.lockedUntil !== undefined && Date.now() < meta.lockedUntil) {
|
|
1000
|
+
const minutes = Math.ceil((meta.lockedUntil - Date.now()) / 60000);
|
|
1001
|
+
throw new Error(`vault is temporarily locked after ${MAX_ATTEMPTS} failed password attempts — retry in ~${minutes} min`);
|
|
1002
|
+
}
|
|
640
1003
|
// The master password is part of the identity: a different password must
|
|
641
1004
|
// open its own store (and fail authentication) rather than reuse a store
|
|
642
1005
|
// unlocked with another password.
|
|
643
1006
|
const cacheKey = `${path}\0${masterPassword}`;
|
|
644
1007
|
const existing = sharedVaultStores.get(cacheKey);
|
|
645
|
-
if (existing !== undefined)
|
|
1008
|
+
if (existing !== undefined) {
|
|
1009
|
+
// A successful re-open of a cached store clears the failure counter.
|
|
1010
|
+
await clearFailedAttempts(config);
|
|
646
1011
|
return existing;
|
|
1012
|
+
}
|
|
647
1013
|
const opening = openVault({
|
|
648
1014
|
masterPassword,
|
|
649
1015
|
path,
|
|
650
|
-
}).catch((error) => {
|
|
651
|
-
// A failed open must not poison the cache for later retries
|
|
1016
|
+
}).catch(async (error) => {
|
|
1017
|
+
// A failed open must not poison the cache for later retries, and counts
|
|
1018
|
+
// toward the brute-force lockout.
|
|
652
1019
|
sharedVaultStores.delete(cacheKey);
|
|
1020
|
+
await recordFailedAttempt(config);
|
|
653
1021
|
throw error;
|
|
654
1022
|
});
|
|
655
1023
|
sharedVaultStores.set(cacheKey, opening);
|
|
1024
|
+
await clearFailedAttempts(config);
|
|
656
1025
|
return opening;
|
|
657
1026
|
}
|
|
658
1027
|
/** Validate a model-supplied result limit: a positive integer capped at 100. */
|