dsh-vault 0.3.0 → 0.5.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 +18 -3
- package/README.md +18 -3
- package/lib/client.js +200 -46
- package/lib/client.js.map +1 -1
- package/lib/index.js +641 -13
- package/lib/store.js +247 -9
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -53,7 +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';
|
|
56
|
+
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
57
|
+
import { randomUUID } from 'node:crypto';
|
|
57
58
|
import { dirname, join } from 'node:path';
|
|
58
59
|
import { openVault, defaultVaultPath } from "./store.js";
|
|
59
60
|
import { totp } from "./totp.js";
|
|
@@ -71,12 +72,26 @@ export const Config = Schema.object({
|
|
|
71
72
|
Schema.const('auto'),
|
|
72
73
|
]),
|
|
73
74
|
autoCapture: Schema.boolean(),
|
|
75
|
+
lockTimeoutSeconds: Schema.number(),
|
|
76
|
+
exportPasswordEnv: Schema.string(),
|
|
74
77
|
});
|
|
75
78
|
export async function apply(ctx, config) {
|
|
76
79
|
const masterPassword = resolveMasterPassword(config);
|
|
77
80
|
const WRITE_TOOLS = new Set(['vault_add', 'vault_update', 'vault_delete']);
|
|
81
|
+
const lockTimeoutSeconds = config.lockTimeoutSeconds ?? 0;
|
|
78
82
|
/** Shared access policy; resolved once, mutated by the UI via setAccessMode. */
|
|
79
83
|
const policy = await sharedAccessPolicy(config);
|
|
84
|
+
/** Audit events: other plugins / session logging can subscribe. Payload is
|
|
85
|
+
* non-secret (tool name + entry id/title only). */
|
|
86
|
+
const emitAudit = (kind, tool, entryId, title) => {
|
|
87
|
+
try {
|
|
88
|
+
;
|
|
89
|
+
ctx.emit(`vault/${kind}`, { tool, entryId, title, at: Date.now() });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A throwing listener must never break the vault operation.
|
|
93
|
+
}
|
|
94
|
+
};
|
|
80
95
|
/** Reject mutations when the vault is in readonly mode. */
|
|
81
96
|
function assertWritable(action) {
|
|
82
97
|
if (policy.mode === 'readonly') {
|
|
@@ -90,19 +105,56 @@ export async function apply(ctx, config) {
|
|
|
90
105
|
* call `next()` (waterfall event).
|
|
91
106
|
*/
|
|
92
107
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
93
|
-
if (
|
|
108
|
+
if (exec?.name === undefined)
|
|
109
|
+
return next();
|
|
110
|
+
if (policy.mode === 'ask' && WRITE_TOOLS.has(exec.name)) {
|
|
94
111
|
return { kind: 'ask', reason: `dsh-vault: ${exec.name} requires your confirmation in "ask" (prompt-before-write) mode` };
|
|
95
112
|
}
|
|
113
|
+
// High-sensitivity reads: in ask mode, reading a `high` entry's secrets
|
|
114
|
+
// (vault_get by id) also requires confirmation.
|
|
115
|
+
if (policy.mode === 'ask' && exec.name === 'vault_get') {
|
|
116
|
+
const id = exec.arguments?.id;
|
|
117
|
+
if (id !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
const store = await ensureStore();
|
|
120
|
+
const entry = store.get(id);
|
|
121
|
+
if (entry?.sensitivity === 'high') {
|
|
122
|
+
return { kind: 'ask', reason: `dsh-vault: reading high-sensitivity entry "${entry.title}" requires your confirmation` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// If the vault is locked etc., let the tool itself report it.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
96
130
|
return next();
|
|
97
131
|
});
|
|
98
132
|
/** Ensure the shared store is open (lazily on first use, so a missing
|
|
99
133
|
* master password fails at the first tool call with a clear message). */
|
|
100
134
|
async function ensureStore() {
|
|
101
|
-
|
|
135
|
+
const store = await sharedVaultStore(masterPassword, config);
|
|
136
|
+
// Install the auto-lock policy once per store instance.
|
|
137
|
+
if (lockTimeoutSeconds > 0)
|
|
138
|
+
store.setAutoLock(lockTimeoutSeconds * 1000);
|
|
139
|
+
return store;
|
|
140
|
+
}
|
|
141
|
+
/** Guard every tool: enforce auto-lock (relock when idle, refuse when
|
|
142
|
+
* locked) and touch the activity timestamp. */
|
|
143
|
+
async function guardStore() {
|
|
144
|
+
const store = await ensureStore();
|
|
145
|
+
if (store.expired) {
|
|
146
|
+
store.lock();
|
|
147
|
+
throw new Error('vault is locked (idle timeout) — call vault_unlock to re-open it');
|
|
148
|
+
}
|
|
149
|
+
if (store.isLocked) {
|
|
150
|
+
throw new Error('vault is locked — call vault_unlock to re-open it');
|
|
151
|
+
}
|
|
152
|
+
store.touch();
|
|
153
|
+
return store;
|
|
102
154
|
}
|
|
103
|
-
/** Read a full entry (with secrets) by id. */
|
|
155
|
+
/** Read a full entry (with secrets) by id (respects locking). */
|
|
104
156
|
async function readEntry(id) {
|
|
105
|
-
const s = await
|
|
157
|
+
const s = await guardStore();
|
|
106
158
|
return s.get(id);
|
|
107
159
|
}
|
|
108
160
|
// System prompt guidance: tells the model how the vault works, what the
|
|
@@ -186,7 +238,7 @@ export async function apply(ctx, config) {
|
|
|
186
238
|
assertWritable('vault_add');
|
|
187
239
|
if (!args.title.trim())
|
|
188
240
|
throw new Error('vault_add: title must not be empty');
|
|
189
|
-
const s = await
|
|
241
|
+
const s = await guardStore();
|
|
190
242
|
const entry = await s.add({
|
|
191
243
|
title: args.title.trim(),
|
|
192
244
|
...(args.kind !== undefined ? { kind: args.kind } : {}),
|
|
@@ -208,6 +260,7 @@ export async function apply(ctx, config) {
|
|
|
208
260
|
...(args.tags !== undefined ? { tags: args.tags } : {}),
|
|
209
261
|
...(args.fields !== undefined ? { fields: args.fields } : {}),
|
|
210
262
|
});
|
|
263
|
+
emitAudit('write', 'vault_add', entry.id, entry.title);
|
|
211
264
|
return { id: entry.id, title: entry.title, message: 'added credential entry' };
|
|
212
265
|
},
|
|
213
266
|
}));
|
|
@@ -233,6 +286,7 @@ export async function apply(ctx, config) {
|
|
|
233
286
|
const entry = await readEntry(args.id);
|
|
234
287
|
if (!entry)
|
|
235
288
|
return { found: false };
|
|
289
|
+
emitAudit('read', 'vault_get', entry.id, entry.title);
|
|
236
290
|
return { found: true, entry: stripTimestamps(entry) };
|
|
237
291
|
},
|
|
238
292
|
}));
|
|
@@ -267,7 +321,7 @@ export async function apply(ctx, config) {
|
|
|
267
321
|
}],
|
|
268
322
|
},
|
|
269
323
|
async execute(args) {
|
|
270
|
-
const s = await
|
|
324
|
+
const s = await guardStore();
|
|
271
325
|
const limit = validateLimit(args.limit, 'vault_search');
|
|
272
326
|
const results = s.search(args.query, limit);
|
|
273
327
|
return { results, total: results.length };
|
|
@@ -321,7 +375,7 @@ export async function apply(ctx, config) {
|
|
|
321
375
|
},
|
|
322
376
|
async execute(args) {
|
|
323
377
|
assertWritable('vault_update');
|
|
324
|
-
const s = await
|
|
378
|
+
const s = await guardStore();
|
|
325
379
|
const patch = {};
|
|
326
380
|
for (const key of [
|
|
327
381
|
'title', 'kind', 'username', 'email', 'phone', 'password', 'host', 'port', 'privateKey',
|
|
@@ -336,6 +390,7 @@ export async function apply(ctx, config) {
|
|
|
336
390
|
const updated = await s.update(args.id, patch);
|
|
337
391
|
if (!updated)
|
|
338
392
|
return { found: false };
|
|
393
|
+
emitAudit('write', 'vault_update', updated.id, updated.title);
|
|
339
394
|
return { found: true, entry: toSummaryJson(updated) };
|
|
340
395
|
},
|
|
341
396
|
}));
|
|
@@ -358,8 +413,10 @@ export async function apply(ctx, config) {
|
|
|
358
413
|
},
|
|
359
414
|
async execute(args) {
|
|
360
415
|
assertWritable('vault_delete');
|
|
361
|
-
const s = await
|
|
416
|
+
const s = await guardStore();
|
|
362
417
|
const deleted = await s.delete(args.id);
|
|
418
|
+
if (deleted)
|
|
419
|
+
emitAudit('write', 'vault_delete', args.id);
|
|
363
420
|
return { deleted, message: deleted ? 'entry deleted' : 'entry not found' };
|
|
364
421
|
},
|
|
365
422
|
}));
|
|
@@ -448,6 +505,372 @@ export async function apply(ctx, config) {
|
|
|
448
505
|
return { password, length: password.length };
|
|
449
506
|
},
|
|
450
507
|
}));
|
|
508
|
+
// ── vault_lock / vault_unlock: explicit lock & unlock ──────────────────────
|
|
509
|
+
ctx.tools.register(defineTool({
|
|
510
|
+
name: 'vault_lock',
|
|
511
|
+
description: 'Lock the vault immediately: wipe the derived key from memory so every '
|
|
512
|
+
+ 'subsequent read/write requires vault_unlock. Use when leaving the machine.',
|
|
513
|
+
parameters: {},
|
|
514
|
+
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' }] },
|
|
515
|
+
async execute() {
|
|
516
|
+
const s = await ensureStore();
|
|
517
|
+
const was = s.isLocked;
|
|
518
|
+
s.lock();
|
|
519
|
+
return { locked: !was };
|
|
520
|
+
},
|
|
521
|
+
}));
|
|
522
|
+
ctx.tools.register(defineTool({
|
|
523
|
+
name: 'vault_unlock',
|
|
524
|
+
description: 'Unlock the vault with the master password (the deployment owns the password; '
|
|
525
|
+
+ 'the model never supplies it). Needed after an explicit vault_lock or an auto-lock idle timeout.',
|
|
526
|
+
parameters: {},
|
|
527
|
+
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' }] },
|
|
528
|
+
async execute() {
|
|
529
|
+
const s = await ensureStore();
|
|
530
|
+
if (!s.isLocked)
|
|
531
|
+
return { unlocked: false };
|
|
532
|
+
await s.unlock();
|
|
533
|
+
return { unlocked: true };
|
|
534
|
+
},
|
|
535
|
+
}));
|
|
536
|
+
// ── vault_totp_uri: build an otpauth:// provisioning URI ────────────────────
|
|
537
|
+
ctx.tools.register(defineTool({
|
|
538
|
+
name: 'vault_totp_uri',
|
|
539
|
+
description: 'Build an otpauth://totp/ provisioning URI for a stored otpSecret (or a bare secret), '
|
|
540
|
+
+ 'so the user can scan it into an authenticator app. Returns the URI string.',
|
|
541
|
+
parameters: {
|
|
542
|
+
id: { type: 'string', description: 'Vault entry id whose otpSecret to use. Provide exactly one of id or secret.' },
|
|
543
|
+
secret: { type: 'string', description: 'Bare Base32 secret. Provide exactly one of id or secret.' },
|
|
544
|
+
label: { type: 'string', description: 'Account label in the URI (default: entry title or "dsh-vault").' },
|
|
545
|
+
issuer: { type: 'string', description: 'Issuer name (default: "dsh-vault").' },
|
|
546
|
+
},
|
|
547
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { uri: { type: 'string', required: true } } }, render: (_a, v) => [{ type: 'text', text: v.uri }] },
|
|
548
|
+
async execute(args) {
|
|
549
|
+
if ((args.id === undefined) === (args.secret === undefined)) {
|
|
550
|
+
throw new Error('vault_totp_uri: provide exactly one of id or secret');
|
|
551
|
+
}
|
|
552
|
+
let secret;
|
|
553
|
+
let label = args.label;
|
|
554
|
+
if (args.id !== undefined) {
|
|
555
|
+
const entry = await readEntry(args.id);
|
|
556
|
+
if (!entry?.otpSecret)
|
|
557
|
+
throw new Error(`vault_totp_uri: entry ${args.id} has no otpSecret`);
|
|
558
|
+
secret = entry.otpSecret;
|
|
559
|
+
label = label ?? entry.title;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
secret = args.secret;
|
|
563
|
+
label = label ?? 'dsh-vault';
|
|
564
|
+
}
|
|
565
|
+
const issuer = args.issuer ?? 'dsh-vault';
|
|
566
|
+
const encodedLabel = encodeURIComponent(`${issuer}:${label ?? ''}`);
|
|
567
|
+
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' });
|
|
568
|
+
return { uri: `otpauth://totp/${encodedLabel}?${params.toString()}` };
|
|
569
|
+
},
|
|
570
|
+
}));
|
|
571
|
+
// ── vault_rotation: expiry / rotation report ───────────────────────────────
|
|
572
|
+
ctx.tools.register(defineTool({
|
|
573
|
+
name: 'vault_rotation',
|
|
574
|
+
description: 'Report credentials that are expired, due for rotation (rotationDays elapsed), '
|
|
575
|
+
+ 'or expiring within 7 days. Returns summaries with a due state — never secrets.',
|
|
576
|
+
parameters: {},
|
|
577
|
+
output: {
|
|
578
|
+
schema: { type: 'object', additionalProperties: false, properties: { entries: { type: 'array', required: true, items: { type: 'json' } } } },
|
|
579
|
+
render: (_a, v) => [{ type: 'text', text: v.entries.length === 0 ? 'no rotation items' : JSON.stringify(v.entries) }],
|
|
580
|
+
},
|
|
581
|
+
async execute() {
|
|
582
|
+
const s = await guardStore();
|
|
583
|
+
return { entries: s.rotationReport() };
|
|
584
|
+
},
|
|
585
|
+
}));
|
|
586
|
+
// ── vault_health: weak / reused credential scan ────────────────────────────
|
|
587
|
+
ctx.tools.register(defineTool({
|
|
588
|
+
name: 'vault_health',
|
|
589
|
+
description: 'Scan the vault for weak passwords (shorter than 12 chars) and credentials reused '
|
|
590
|
+
+ 'across entries. Returns non-secret findings (entry summaries grouped by the reused value).',
|
|
591
|
+
parameters: {},
|
|
592
|
+
output: {
|
|
593
|
+
schema: { type: 'object', additionalProperties: false, properties: { weak: { type: 'array', required: true, items: { type: 'json' } }, reused: { type: 'array', required: true, items: { type: 'json' } } } },
|
|
594
|
+
render: (_a, v) => [{ type: 'text', text: `weak: ${v.weak.length}, reused groups: ${v.reused.length}` }],
|
|
595
|
+
},
|
|
596
|
+
async execute() {
|
|
597
|
+
const s = await guardStore();
|
|
598
|
+
return s.health();
|
|
599
|
+
},
|
|
600
|
+
}));
|
|
601
|
+
// ── vault_restore / vault_purge: trash management ──────────────────────────
|
|
602
|
+
ctx.tools.register(defineTool({
|
|
603
|
+
name: 'vault_restore',
|
|
604
|
+
description: 'Restore a soft-deleted entry from the vault trash back into the active set.',
|
|
605
|
+
parameters: { id: { type: 'string', required: true, description: 'The trashed entry id.' } },
|
|
606
|
+
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' }] },
|
|
607
|
+
async execute(args) {
|
|
608
|
+
assertWritable('vault_restore');
|
|
609
|
+
const s = await guardStore();
|
|
610
|
+
return { restored: await s.restore(args.id) };
|
|
611
|
+
},
|
|
612
|
+
}));
|
|
613
|
+
ctx.tools.register(defineTool({
|
|
614
|
+
name: 'vault_purge',
|
|
615
|
+
description: 'Permanently delete an entry (active or trashed). Cannot be undone — prefer vault_delete '
|
|
616
|
+
+ '(soft delete) unless the entry must be removed from disk.',
|
|
617
|
+
parameters: { id: { type: 'string', required: true, description: 'The entry id to purge.' } },
|
|
618
|
+
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' }] },
|
|
619
|
+
async execute(args) {
|
|
620
|
+
assertWritable('vault_purge');
|
|
621
|
+
const s = await guardStore();
|
|
622
|
+
return { purged: await s.purge(args.id) };
|
|
623
|
+
},
|
|
624
|
+
}));
|
|
625
|
+
// ── vault_export / vault_import: portable encrypted transfer ───────────────
|
|
626
|
+
ctx.tools.register(defineTool({
|
|
627
|
+
name: 'vault_export',
|
|
628
|
+
description: 'Export the entire vault (including trash) as a single encrypted document under a '
|
|
629
|
+
+ 'separate export password (from the exportPasswordEnv config). Use for backup or migration; '
|
|
630
|
+
+ 'the export can be re-imported with vault_import. Never pass the password as an argument.',
|
|
631
|
+
parameters: {},
|
|
632
|
+
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 }] },
|
|
633
|
+
async execute() {
|
|
634
|
+
const exportPassword = resolveExportPassword(config);
|
|
635
|
+
const s = await guardStore();
|
|
636
|
+
const blob = await s.exportEncrypted(exportPassword);
|
|
637
|
+
const file = join(dirname(resolveVaultPath(config)), `vault-export-${Date.now()}.json`);
|
|
638
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
639
|
+
await writeFile(file, blob, { mode: 0o600 });
|
|
640
|
+
return { exported: true, note: `vault exported to ${file}` };
|
|
641
|
+
},
|
|
642
|
+
}));
|
|
643
|
+
ctx.tools.register(defineTool({
|
|
644
|
+
name: 'vault_import',
|
|
645
|
+
description: 'Import a previously exported vault document (see vault_export), merging entries by '
|
|
646
|
+
+ 'id. Pass the document path; the export password comes from the exportPasswordEnv config.',
|
|
647
|
+
parameters: { path: { type: 'string', required: true, description: 'Absolute path of the exported vault JSON file.' } },
|
|
648
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { imported: { type: 'integer', required: true } } }, render: (_a, v) => [{ type: 'text', text: `imported ${v.imported} entries` }] },
|
|
649
|
+
async execute(args) {
|
|
650
|
+
assertWritable('vault_import');
|
|
651
|
+
const exportPassword = resolveExportPassword(config);
|
|
652
|
+
const s = await guardStore();
|
|
653
|
+
const blob = await readFile(args.path, 'utf8');
|
|
654
|
+
const count = await s.importEncrypted(blob, exportPassword);
|
|
655
|
+
return { imported: count };
|
|
656
|
+
},
|
|
657
|
+
}));
|
|
658
|
+
// ── vault_fill: find the credential that fits a target ─────────────────────
|
|
659
|
+
ctx.tools.register(defineTool({
|
|
660
|
+
name: 'vault_fill',
|
|
661
|
+
description: 'Find the vault entry that fits a target (host/URL/username/title) and return the '
|
|
662
|
+
+ 'ready-to-use credentials (secrets included, as the caller needs them for the actual login). '
|
|
663
|
+
+ 'Use instead of vault_search+vault_get when you know what you are connecting to.',
|
|
664
|
+
parameters: {
|
|
665
|
+
target: { type: 'string', required: true, description: 'Host, URL, username, or title to match.' },
|
|
666
|
+
},
|
|
667
|
+
output: {
|
|
668
|
+
schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean', required: true }, entry: { type: 'json' } } },
|
|
669
|
+
render: (_a, v) => [{ type: 'text', text: v.found ? `matched: ${v.entry?.title ?? 'entry'}` : 'no matching entry' }],
|
|
670
|
+
},
|
|
671
|
+
async execute(args) {
|
|
672
|
+
const s = await guardStore();
|
|
673
|
+
const needle = args.target.trim().toLowerCase();
|
|
674
|
+
for (const entry of s.list()) {
|
|
675
|
+
const haystack = [entry.title, entry.host, entry.url, entry.username, entry.email].filter(Boolean).join(' ').toLowerCase();
|
|
676
|
+
if (needle.length > 0 && haystack.includes(needle)) {
|
|
677
|
+
return { found: true, entry: stripTimestamps(entry) };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return { found: false };
|
|
681
|
+
},
|
|
682
|
+
}));
|
|
683
|
+
// ── vault_env: environment-variable export ─────────────────────────────────
|
|
684
|
+
ctx.tools.register(defineTool({
|
|
685
|
+
name: 'vault_env',
|
|
686
|
+
description: 'Render entries flagged for environment export (tags contain "env") as KEY=VALUE lines '
|
|
687
|
+
+ 'suitable for .env or export statements. Keys derive from the title + field name; values are the '
|
|
688
|
+
+ 'secrets. Returns the lines so the caller can write them to a file (user-authorized).',
|
|
689
|
+
parameters: {},
|
|
690
|
+
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') }] },
|
|
691
|
+
async execute() {
|
|
692
|
+
const s = await guardStore();
|
|
693
|
+
const lines = [];
|
|
694
|
+
const keyOf = (title, field) => title.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') + '_' + field.toUpperCase();
|
|
695
|
+
for (const entry of s.list()) {
|
|
696
|
+
if (!(entry.tags ?? []).includes('env'))
|
|
697
|
+
continue;
|
|
698
|
+
for (const [field, value] of Object.entries(entry)) {
|
|
699
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
700
|
+
continue;
|
|
701
|
+
if (['id', 'title', 'kind', 'sensitivity', 'host', 'url', 'notes', 'createdAt', 'updatedAt', 'deletedAt'].includes(field))
|
|
702
|
+
continue;
|
|
703
|
+
if (['username', 'email', 'phone', 'port', 'tags'].includes(field))
|
|
704
|
+
continue;
|
|
705
|
+
lines.push(`${keyOf(entry.title, field)}=${value}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return { lines };
|
|
709
|
+
},
|
|
710
|
+
}));
|
|
711
|
+
// ── vault_templates: field templates by kind ───────────────────────────────
|
|
712
|
+
const TEMPLATES = {
|
|
713
|
+
login: { username: 'account username', email: 'account email', password: 'account password' },
|
|
714
|
+
ssh: { host: 'server host', port: 'port (e.g. 22)', username: 'login user', password: 'password or passphrase', privateKey: 'PEM private key' },
|
|
715
|
+
'api-key': { apiKey: 'the API key', url: 'API base URL', username: 'owner/account (optional)' },
|
|
716
|
+
oauth: { accessToken: 'access token', refreshToken: 'refresh token', expiresAt: 'expiry epoch millis', clientId: 'client id (via fields)' },
|
|
717
|
+
secret: { secret: 'the shared secret', notes: 'what it is for' },
|
|
718
|
+
custom: { fields: 'arbitrary key/value pairs' },
|
|
719
|
+
};
|
|
720
|
+
ctx.tools.register(defineTool({
|
|
721
|
+
name: 'vault_templates',
|
|
722
|
+
description: 'Return the recommended fields for a credential kind, so vault_add can be called with '
|
|
723
|
+
+ 'the right field names (e.g. kind ssh → host/port/username/password/privateKey).',
|
|
724
|
+
parameters: { kind: { type: 'string', description: 'Entry kind; default login.', enum: ['login', 'ssh', 'api-key', 'secret', 'oauth', 'custom'] } },
|
|
725
|
+
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) }] },
|
|
726
|
+
async execute(args) {
|
|
727
|
+
const kind = args.kind ?? 'login';
|
|
728
|
+
return { kind, fields: TEMPLATES[kind] ?? TEMPLATES.login };
|
|
729
|
+
},
|
|
730
|
+
}));
|
|
731
|
+
// ── vault_strength: zero-dependency password strength estimation ────────────
|
|
732
|
+
ctx.tools.register(defineTool({
|
|
733
|
+
name: 'vault_strength',
|
|
734
|
+
description: 'Estimate the strength of a password with a zero-dependency heuristic '
|
|
735
|
+
+ '(length, character-class diversity, common-pattern penalties). Returns a score 0–100 '
|
|
736
|
+
+ 'and a verdict: weak / fair / strong / very strong. Use before choosing or storing a password.',
|
|
737
|
+
parameters: { password: { type: 'string', required: true, description: 'The password to evaluate.' } },
|
|
738
|
+
output: {
|
|
739
|
+
schema: { type: 'object', additionalProperties: false, properties: { score: { type: 'integer', required: true }, verdict: { type: 'string', required: true }, feedback: { type: 'string', required: true } } },
|
|
740
|
+
render: (_a, v) => [{ type: 'text', text: `${v.verdict} (${v.score}/100) — ${v.feedback}` }],
|
|
741
|
+
},
|
|
742
|
+
async execute(args) {
|
|
743
|
+
const r = estimateStrength(args.password);
|
|
744
|
+
return r;
|
|
745
|
+
},
|
|
746
|
+
}));
|
|
747
|
+
// ── vault_import_csv: bulk import from a CSV file ───────────────────────────
|
|
748
|
+
ctx.tools.register(defineTool({
|
|
749
|
+
name: 'vault_import_csv',
|
|
750
|
+
description: 'Bulk-import credentials from a CSV file. Expected columns (header row): '
|
|
751
|
+
+ 'title,username,password,url,email,phone,host,port,apiKey,secret,notes,tags,kind. '
|
|
752
|
+
+ 'Unknown columns become custom fields. Returns how many entries were added and skipped.',
|
|
753
|
+
parameters: {
|
|
754
|
+
path: { type: 'string', required: true, description: 'Absolute path of the CSV file.' },
|
|
755
|
+
delimiter: { type: 'string', description: 'CSV delimiter (default ",").' },
|
|
756
|
+
overwrite: { type: 'boolean', description: 'Replace entries with the same title (default false).' },
|
|
757
|
+
},
|
|
758
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { added: { type: 'integer', required: true }, skipped: { type: 'integer', required: true } } }, render: (_a, v) => [{ type: 'text', text: `imported ${v.added}, skipped ${v.skipped}` }] },
|
|
759
|
+
async execute(args) {
|
|
760
|
+
assertWritable('vault_import_csv');
|
|
761
|
+
const s = await guardStore();
|
|
762
|
+
const raw = await readFile(args.path, 'utf8');
|
|
763
|
+
const rows = parseCsv(raw, args.delimiter ?? ',');
|
|
764
|
+
if (rows.length === 0)
|
|
765
|
+
return { added: 0, skipped: 0 };
|
|
766
|
+
const headers = rows[0].map(h => h.trim());
|
|
767
|
+
const known = new Set(['title', 'username', 'password', 'url', 'email', 'phone', 'host', 'port',
|
|
768
|
+
'apiKey', 'secret', 'notes', 'tags', 'kind', 'sensitivity']);
|
|
769
|
+
let added = 0;
|
|
770
|
+
let skipped = 0;
|
|
771
|
+
const now = Date.now();
|
|
772
|
+
for (let i = 1; i < rows.length; i++) {
|
|
773
|
+
const row = rows[i];
|
|
774
|
+
if (row.length === 1 && row[0].trim() === '')
|
|
775
|
+
continue; // blank line
|
|
776
|
+
const record = { title: row[0] ?? '' };
|
|
777
|
+
const fields = {};
|
|
778
|
+
for (let c = 1; c < headers.length && c < row.length; c++) {
|
|
779
|
+
const header = headers[c];
|
|
780
|
+
const value = row[c] ?? '';
|
|
781
|
+
if (value === '')
|
|
782
|
+
continue;
|
|
783
|
+
if (known.has(header)) {
|
|
784
|
+
if (header === 'tags')
|
|
785
|
+
record[header] = value.split(/[;,]/).map(t => t.trim()).filter(Boolean);
|
|
786
|
+
else
|
|
787
|
+
record[header] = value;
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
fields[header] = value;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (record.title === '') {
|
|
794
|
+
skipped++;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (Object.keys(fields).length > 0)
|
|
798
|
+
record.fields = fields;
|
|
799
|
+
const title = record.title;
|
|
800
|
+
if (s.list().some(e => e.title === title) && !args.overwrite) {
|
|
801
|
+
skipped++;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const entry = {
|
|
805
|
+
id: randomUUID(),
|
|
806
|
+
title,
|
|
807
|
+
createdAt: now,
|
|
808
|
+
updatedAt: now,
|
|
809
|
+
...pickDefinedFromRecord(record),
|
|
810
|
+
};
|
|
811
|
+
s.insertDirect(entry);
|
|
812
|
+
added++;
|
|
813
|
+
}
|
|
814
|
+
await s.persist();
|
|
815
|
+
return { added, skipped };
|
|
816
|
+
},
|
|
817
|
+
}));
|
|
818
|
+
// ── vault_switch / vault_list: multi-vault navigation ───────────────────────
|
|
819
|
+
ctx.tools.register(defineTool({
|
|
820
|
+
name: 'vault_list',
|
|
821
|
+
description: 'List available vaults in the vault directory (one .json file per vault, excluding '
|
|
822
|
+
+ 'access/meta/export files). Marks the currently active one.',
|
|
823
|
+
parameters: {},
|
|
824
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { vaults: { type: 'array', required: true, items: { type: 'json' } } } }, render: (_a, v) => [{ type: 'text', text: JSON.stringify(v.vaults) }] },
|
|
825
|
+
async execute() {
|
|
826
|
+
const dir = dirname(resolveVaultPath(config));
|
|
827
|
+
const names = [];
|
|
828
|
+
try {
|
|
829
|
+
const entries = await readdir(dir);
|
|
830
|
+
for (const entry of entries) {
|
|
831
|
+
const m = /^(.*)\.json$/.exec(entry);
|
|
832
|
+
if (!m)
|
|
833
|
+
continue;
|
|
834
|
+
if (['access', 'meta'].includes(m[1]) || m[1].startsWith('vault-export-'))
|
|
835
|
+
continue;
|
|
836
|
+
names.push(m[1]);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
catch { /* dir may not exist yet */ }
|
|
840
|
+
const active = currentVaultName ?? config.name ?? 'default';
|
|
841
|
+
return { vaults: names.sort().map(name => ({ name, active: name === active })) };
|
|
842
|
+
},
|
|
843
|
+
}));
|
|
844
|
+
ctx.tools.register(defineTool({
|
|
845
|
+
name: 'vault_switch',
|
|
846
|
+
description: 'Switch the active vault for this session. Future vault_* calls operate on the named '
|
|
847
|
+
+ 'vault (created on first use). Returns the newly active vault name.',
|
|
848
|
+
parameters: { name: { type: 'string', required: true, description: 'Vault name (e.g. "work" or "personal").' } },
|
|
849
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { active: { type: 'string', required: true } } }, render: (_a, v) => [{ type: 'text', text: `switched to vault "${v.active}"` }] },
|
|
850
|
+
async execute(args) {
|
|
851
|
+
const name = args.name.trim();
|
|
852
|
+
if (name.length === 0)
|
|
853
|
+
throw new Error('vault_switch: name must not be empty');
|
|
854
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name))
|
|
855
|
+
throw new Error('vault_switch: name may contain only letters, digits, . _ -');
|
|
856
|
+
currentVaultName = name;
|
|
857
|
+
return { active: name };
|
|
858
|
+
},
|
|
859
|
+
}));
|
|
860
|
+
// ── vault_rekey: upgrade the scrypt KDF parameters in place ────────────────
|
|
861
|
+
ctx.tools.register(defineTool({
|
|
862
|
+
name: 'vault_rekey',
|
|
863
|
+
description: 'Upgrade the vault encryption to fresh scrypt KDF parameters (higher cost) and '
|
|
864
|
+
+ 're-encrypt every entry in place. Safe to run periodically or after raising the vault '
|
|
865
|
+
+ 'cost expectations; the old document is replaced atomically. Returns the new cost parameter n.',
|
|
866
|
+
parameters: {},
|
|
867
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { n: { type: 'integer', required: true } } }, render: (_a, v) => [{ type: 'text', text: `vault re-keyed with scrypt N=${v.n}` }] },
|
|
868
|
+
async execute() {
|
|
869
|
+
assertWritable('vault_rekey');
|
|
870
|
+
const s = await guardStore();
|
|
871
|
+
return await s.rekey();
|
|
872
|
+
},
|
|
873
|
+
}));
|
|
451
874
|
// UI-facing Remote gateway: the browser Settings Vault page talks to these
|
|
452
875
|
// methods through the /api RPC channel (loopback-trusted), bypassing the
|
|
453
876
|
// model-tool layer entirely. Secrets are returned because the UI is the
|
|
@@ -472,6 +895,10 @@ let VaultGateway = (() => {
|
|
|
472
895
|
let _config_decorators;
|
|
473
896
|
let _setAccessMode_decorators;
|
|
474
897
|
let _list_decorators;
|
|
898
|
+
let _trash_decorators;
|
|
899
|
+
let _restore_decorators;
|
|
900
|
+
let _rotation_decorators;
|
|
901
|
+
let _health_decorators;
|
|
475
902
|
let _get_decorators;
|
|
476
903
|
let _search_decorators;
|
|
477
904
|
let _add_decorators;
|
|
@@ -484,6 +911,10 @@ let VaultGateway = (() => {
|
|
|
484
911
|
_config_decorators = [Remote('config')];
|
|
485
912
|
_setAccessMode_decorators = [Remote('setAccessMode')];
|
|
486
913
|
_list_decorators = [Remote('list')];
|
|
914
|
+
_trash_decorators = [Remote('trash')];
|
|
915
|
+
_restore_decorators = [Remote('restore')];
|
|
916
|
+
_rotation_decorators = [Remote('rotation')];
|
|
917
|
+
_health_decorators = [Remote('health')];
|
|
487
918
|
_get_decorators = [Remote('get')];
|
|
488
919
|
_search_decorators = [Remote('search')];
|
|
489
920
|
_add_decorators = [Remote('add')];
|
|
@@ -493,6 +924,10 @@ let VaultGateway = (() => {
|
|
|
493
924
|
__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);
|
|
494
925
|
__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);
|
|
495
926
|
__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);
|
|
927
|
+
__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);
|
|
928
|
+
__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);
|
|
929
|
+
__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);
|
|
930
|
+
__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);
|
|
496
931
|
__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);
|
|
497
932
|
__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);
|
|
498
933
|
__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);
|
|
@@ -552,6 +987,27 @@ let VaultGateway = (() => {
|
|
|
552
987
|
const store = await this.ensureStore();
|
|
553
988
|
return { entries: store.list().map(toSummary) };
|
|
554
989
|
}
|
|
990
|
+
/** List trashed (soft-deleted) entries as non-secret summaries. */
|
|
991
|
+
async trash() {
|
|
992
|
+
const store = await this.ensureStore();
|
|
993
|
+
return { entries: store.listTrash().map(toSummary) };
|
|
994
|
+
}
|
|
995
|
+
/** Restore a trashed entry (non-secret summary returned). */
|
|
996
|
+
async restore(id) {
|
|
997
|
+
this.assertWritable('restore');
|
|
998
|
+
const store = await this.ensureStore();
|
|
999
|
+
return { restored: await store.restore(id) };
|
|
1000
|
+
}
|
|
1001
|
+
/** Rotation/expiry report (no secrets). */
|
|
1002
|
+
async rotation() {
|
|
1003
|
+
const store = await this.ensureStore();
|
|
1004
|
+
return { entries: store.rotationReport() };
|
|
1005
|
+
}
|
|
1006
|
+
/** Health scan findings (no secrets). */
|
|
1007
|
+
async health() {
|
|
1008
|
+
const store = await this.ensureStore();
|
|
1009
|
+
return store.health();
|
|
1010
|
+
}
|
|
555
1011
|
/** Read one full entry (including secrets) by id. */
|
|
556
1012
|
async get(id) {
|
|
557
1013
|
const store = await this.ensureStore();
|
|
@@ -622,6 +1078,7 @@ function toSummary(entry) {
|
|
|
622
1078
|
return {
|
|
623
1079
|
id: entry.id,
|
|
624
1080
|
title: entry.title,
|
|
1081
|
+
...(entry.sensitivity !== undefined ? { sensitivity: entry.sensitivity } : {}),
|
|
625
1082
|
...(entry.kind !== undefined ? { kind: entry.kind } : {}),
|
|
626
1083
|
...(entry.username !== undefined ? { username: entry.username } : {}),
|
|
627
1084
|
...(entry.email !== undefined ? { email: entry.email } : {}),
|
|
@@ -651,6 +1108,18 @@ function resolveMasterPassword(config) {
|
|
|
651
1108
|
}
|
|
652
1109
|
throw new Error('dsh-vault: configure masterPassword or masterPasswordEnv to unlock the vault');
|
|
653
1110
|
}
|
|
1111
|
+
/** Resolve the export/import password from the configured environment
|
|
1112
|
+
* variable, or fail loudly (the model must never pass it as an argument). */
|
|
1113
|
+
function resolveExportPassword(config) {
|
|
1114
|
+
if (config.exportPasswordEnv !== undefined) {
|
|
1115
|
+
const fromEnv = process.env[config.exportPasswordEnv];
|
|
1116
|
+
if (fromEnv === undefined || fromEnv.length === 0) {
|
|
1117
|
+
throw new Error(`dsh-vault: environment variable ${config.exportPasswordEnv} is not set (needed for vault_export/import)`);
|
|
1118
|
+
}
|
|
1119
|
+
return fromEnv;
|
|
1120
|
+
}
|
|
1121
|
+
throw new Error('dsh-vault: configure exportPasswordEnv to use vault_export / vault_import');
|
|
1122
|
+
}
|
|
654
1123
|
/**
|
|
655
1124
|
* Shared vault-store instances keyed by resolved path + master password. The
|
|
656
1125
|
* model tools and the UI-facing VaultGateway must observe ONE store so writes
|
|
@@ -662,11 +1131,17 @@ function resolveMasterPassword(config) {
|
|
|
662
1131
|
*/
|
|
663
1132
|
const sharedVaultStores = new Map();
|
|
664
1133
|
const sharedAccessPolicies = new Map();
|
|
1134
|
+
/** Current vault-name override (vault_switch); undefined = use config name. */
|
|
1135
|
+
let currentVaultName;
|
|
1136
|
+
/** Reset the session vault-switch override (tests). */
|
|
1137
|
+
export function resetVaultSwitch() {
|
|
1138
|
+
currentVaultName = undefined;
|
|
1139
|
+
}
|
|
665
1140
|
/** Resolve the canonical vault file path for a config (path override or name). */
|
|
666
1141
|
function resolveVaultPath(config) {
|
|
667
1142
|
if (config.path !== undefined)
|
|
668
1143
|
return config.path;
|
|
669
|
-
return defaultVaultPath(config.name);
|
|
1144
|
+
return defaultVaultPath(currentVaultName ?? config.name);
|
|
670
1145
|
}
|
|
671
1146
|
/** The `<vault dir>/access.json` path holding the persisted access policy. */
|
|
672
1147
|
function accessPolicyFile(config) {
|
|
@@ -698,6 +1173,40 @@ async function sharedAccessPolicy(config) {
|
|
|
698
1173
|
sharedAccessPolicies.set(path, policy);
|
|
699
1174
|
return policy;
|
|
700
1175
|
}
|
|
1176
|
+
const MAX_ATTEMPTS = 5;
|
|
1177
|
+
const LOCKOUT_MS = 5 * 60 * 1000;
|
|
1178
|
+
/** `<vault dir>/meta.json` — failed-attempt counter and lockout window. */
|
|
1179
|
+
function metaFile(config) {
|
|
1180
|
+
return join(dirname(resolveVaultPath(config)), 'meta.json');
|
|
1181
|
+
}
|
|
1182
|
+
async function readMeta(config) {
|
|
1183
|
+
try {
|
|
1184
|
+
const raw = await readFile(metaFile(config), 'utf8');
|
|
1185
|
+
return JSON.parse(raw);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return { failedAttempts: 0 };
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
async function writeMeta(config, meta) {
|
|
1192
|
+
const file = metaFile(config);
|
|
1193
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
1194
|
+
await writeFile(file, JSON.stringify(meta), { mode: 0o600 });
|
|
1195
|
+
}
|
|
1196
|
+
/** Record one failed unlock: increment the counter, start a lockout when the
|
|
1197
|
+
* threshold is crossed. */
|
|
1198
|
+
async function recordFailedAttempt(config) {
|
|
1199
|
+
const meta = await readMeta(config);
|
|
1200
|
+
meta.failedAttempts = (meta.failedAttempts ?? 0) + 1;
|
|
1201
|
+
if (meta.failedAttempts >= MAX_ATTEMPTS) {
|
|
1202
|
+
meta.lockedUntil = Date.now() + LOCKOUT_MS;
|
|
1203
|
+
}
|
|
1204
|
+
await writeMeta(config, meta);
|
|
1205
|
+
}
|
|
1206
|
+
/** Clear the counter after a successful unlock. */
|
|
1207
|
+
async function clearFailedAttempts(config) {
|
|
1208
|
+
await writeMeta(config, { failedAttempts: 0 });
|
|
1209
|
+
}
|
|
701
1210
|
/**
|
|
702
1211
|
* Open (or reuse) the vault store for one deployment configuration. All
|
|
703
1212
|
* callers within the process share the same instance for the same path and
|
|
@@ -706,22 +1215,34 @@ async function sharedAccessPolicy(config) {
|
|
|
706
1215
|
*/
|
|
707
1216
|
async function sharedVaultStore(masterPassword, config) {
|
|
708
1217
|
const path = resolveVaultPath(config);
|
|
1218
|
+
// Brute-force guard: refuse to even attempt while a lockout window is open.
|
|
1219
|
+
const meta = await readMeta(config);
|
|
1220
|
+
if (meta.lockedUntil !== undefined && Date.now() < meta.lockedUntil) {
|
|
1221
|
+
const minutes = Math.ceil((meta.lockedUntil - Date.now()) / 60000);
|
|
1222
|
+
throw new Error(`vault is temporarily locked after ${MAX_ATTEMPTS} failed password attempts — retry in ~${minutes} min`);
|
|
1223
|
+
}
|
|
709
1224
|
// The master password is part of the identity: a different password must
|
|
710
1225
|
// open its own store (and fail authentication) rather than reuse a store
|
|
711
1226
|
// unlocked with another password.
|
|
712
1227
|
const cacheKey = `${path}\0${masterPassword}`;
|
|
713
1228
|
const existing = sharedVaultStores.get(cacheKey);
|
|
714
|
-
if (existing !== undefined)
|
|
1229
|
+
if (existing !== undefined) {
|
|
1230
|
+
// A successful re-open of a cached store clears the failure counter.
|
|
1231
|
+
await clearFailedAttempts(config);
|
|
715
1232
|
return existing;
|
|
1233
|
+
}
|
|
716
1234
|
const opening = openVault({
|
|
717
1235
|
masterPassword,
|
|
718
1236
|
path,
|
|
719
|
-
}).catch((error) => {
|
|
720
|
-
// A failed open must not poison the cache for later retries
|
|
1237
|
+
}).catch(async (error) => {
|
|
1238
|
+
// A failed open must not poison the cache for later retries, and counts
|
|
1239
|
+
// toward the brute-force lockout.
|
|
721
1240
|
sharedVaultStores.delete(cacheKey);
|
|
1241
|
+
await recordFailedAttempt(config);
|
|
722
1242
|
throw error;
|
|
723
1243
|
});
|
|
724
1244
|
sharedVaultStores.set(cacheKey, opening);
|
|
1245
|
+
await clearFailedAttempts(config);
|
|
725
1246
|
return opening;
|
|
726
1247
|
}
|
|
727
1248
|
/** Validate a model-supplied result limit: a positive integer capped at 100. */
|
|
@@ -739,6 +1260,113 @@ function toSummaryJson(entry) {
|
|
|
739
1260
|
}
|
|
740
1261
|
/** Strip timestamps from an entry for model-visible output (keeps secrets
|
|
741
1262
|
* when the caller asked for the full entry via vault_get). */
|
|
1263
|
+
/** Minimal RFC-4180-ish CSV parser: handles quoted fields with embedded
|
|
1264
|
+
* delimiters/newlines and escaped double quotes. Returns rows of fields. */
|
|
1265
|
+
function parseCsv(input, delimiter = ',') {
|
|
1266
|
+
const rows = [];
|
|
1267
|
+
let row = [];
|
|
1268
|
+
let field = '';
|
|
1269
|
+
let inQuotes = false;
|
|
1270
|
+
let i = 0;
|
|
1271
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
1272
|
+
const pushRow = () => { pushField(); rows.push(row); row = []; };
|
|
1273
|
+
while (i < input.length) {
|
|
1274
|
+
const ch = input[i];
|
|
1275
|
+
if (inQuotes) {
|
|
1276
|
+
if (ch === '"') {
|
|
1277
|
+
if (input[i + 1] === '"') {
|
|
1278
|
+
field += '"';
|
|
1279
|
+
i++;
|
|
1280
|
+
}
|
|
1281
|
+
else
|
|
1282
|
+
inQuotes = false;
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
field += ch;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
else if (ch === '"') {
|
|
1289
|
+
inQuotes = true;
|
|
1290
|
+
}
|
|
1291
|
+
else if (ch === delimiter) {
|
|
1292
|
+
pushField();
|
|
1293
|
+
}
|
|
1294
|
+
else if (ch === '\n') {
|
|
1295
|
+
pushRow();
|
|
1296
|
+
}
|
|
1297
|
+
else if (ch === '\r') {
|
|
1298
|
+
if (input[i + 1] === '\n')
|
|
1299
|
+
i++;
|
|
1300
|
+
pushRow();
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
field += ch;
|
|
1304
|
+
}
|
|
1305
|
+
i++;
|
|
1306
|
+
}
|
|
1307
|
+
if (field.length > 0 || row.length > 0)
|
|
1308
|
+
pushRow();
|
|
1309
|
+
return rows;
|
|
1310
|
+
}
|
|
1311
|
+
/** Copy only defined non-empty record fields into a VaultEntry-shaped patch,
|
|
1312
|
+
* skipping the identity fields (title handled separately). */
|
|
1313
|
+
function pickDefinedFromRecord(record) {
|
|
1314
|
+
const result = {};
|
|
1315
|
+
const identity = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt']);
|
|
1316
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1317
|
+
if (key === 'title' || identity.has(key))
|
|
1318
|
+
continue;
|
|
1319
|
+
if (value === undefined || value === '')
|
|
1320
|
+
continue;
|
|
1321
|
+
if (Array.isArray(value) && value.length === 0)
|
|
1322
|
+
continue;
|
|
1323
|
+
result[key] = value;
|
|
1324
|
+
}
|
|
1325
|
+
return result;
|
|
1326
|
+
}
|
|
1327
|
+
/** Zero-dependency password strength estimator: score 0–100 from length,
|
|
1328
|
+
* character-class coverage, and penalties for common weak patterns. */
|
|
1329
|
+
function estimateStrength(password) {
|
|
1330
|
+
let score = 0;
|
|
1331
|
+
const length = password.length;
|
|
1332
|
+
// Length is the dominant factor.
|
|
1333
|
+
score += Math.min(45, length * 3);
|
|
1334
|
+
const classes = [
|
|
1335
|
+
/[a-z]/.test(password),
|
|
1336
|
+
/[A-Z]/.test(password),
|
|
1337
|
+
/[0-9]/.test(password),
|
|
1338
|
+
/[^A-Za-z0-9]/.test(password),
|
|
1339
|
+
].filter(Boolean).length;
|
|
1340
|
+
score += classes * 8;
|
|
1341
|
+
// Diversity bonus for longer unique characters.
|
|
1342
|
+
const unique = new Set(password).size;
|
|
1343
|
+
score += Math.min(15, unique);
|
|
1344
|
+
// Penalties for common weak patterns.
|
|
1345
|
+
let feedback = [];
|
|
1346
|
+
if (length < 8)
|
|
1347
|
+
feedback.push('too short (aim ≥ 12)');
|
|
1348
|
+
if (/^(password|123456|qwerty|letmein|admin|welcome|abc123)$/i.test(password)) {
|
|
1349
|
+
score -= 40;
|
|
1350
|
+
feedback.push('common password');
|
|
1351
|
+
}
|
|
1352
|
+
if (/(.)\1{2,}/.test(password)) {
|
|
1353
|
+
score -= 8;
|
|
1354
|
+
feedback.push('repeated characters');
|
|
1355
|
+
}
|
|
1356
|
+
if (/^\d+$/.test(password)) {
|
|
1357
|
+
score -= 15;
|
|
1358
|
+
feedback.push('digits only');
|
|
1359
|
+
}
|
|
1360
|
+
if (/^[a-z]+$/i.test(password)) {
|
|
1361
|
+
score -= 10;
|
|
1362
|
+
feedback.push('letters only');
|
|
1363
|
+
}
|
|
1364
|
+
if (password.length > 0 && new Set(password).size <= Math.max(3, Math.floor(length / 2)))
|
|
1365
|
+
feedback.push('low diversity');
|
|
1366
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
1367
|
+
const verdict = score >= 80 ? 'very strong' : score >= 60 ? 'strong' : score >= 40 ? 'fair' : 'weak';
|
|
1368
|
+
return { score, verdict, feedback: feedback.length > 0 ? feedback.join('; ') : 'no obvious weaknesses' };
|
|
1369
|
+
}
|
|
742
1370
|
function stripTimestamps(entry) {
|
|
743
1371
|
const { createdAt, updatedAt, ...rest } = entry;
|
|
744
1372
|
return rest;
|