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/store.js
CHANGED
|
@@ -57,10 +57,55 @@ export class VaultStore {
|
|
|
57
57
|
* contend for the cross-process file lock and every write sees the latest
|
|
58
58
|
* in-memory state. */
|
|
59
59
|
persistChain = Promise.resolve();
|
|
60
|
+
/** Auto-lock: after this many ms of inactivity the vault re-locks (key
|
|
61
|
+
* wiped). `0`/undefined disables auto-lock. */
|
|
62
|
+
lockTimeoutMs;
|
|
63
|
+
/** Epoch millis of the last operation; used for the auto-lock timer. */
|
|
64
|
+
lastActivity = Date.now();
|
|
65
|
+
/** Whether the vault is currently locked (key wiped, requires re-unlock). */
|
|
66
|
+
locked = false;
|
|
67
|
+
/** Weak-password heuristic: too short or in a tiny common list. */
|
|
68
|
+
static MIN_PASSWORD_LENGTH = 12;
|
|
60
69
|
constructor(path, masterPassword) {
|
|
61
70
|
this.path = path;
|
|
62
71
|
this.masterPassword = masterPassword;
|
|
63
72
|
}
|
|
73
|
+
/** Enable auto-lock with the given idle timeout (ms). */
|
|
74
|
+
setAutoLock(timeoutMs) {
|
|
75
|
+
this.lockTimeoutMs = timeoutMs;
|
|
76
|
+
this.lastActivity = Date.now();
|
|
77
|
+
}
|
|
78
|
+
/** Whether the vault is currently locked. */
|
|
79
|
+
get isLocked() {
|
|
80
|
+
return this.locked;
|
|
81
|
+
}
|
|
82
|
+
/** Touch the activity timestamp; call before every read/write. */
|
|
83
|
+
touch() {
|
|
84
|
+
this.lastActivity = Date.now();
|
|
85
|
+
}
|
|
86
|
+
/** Whether the auto-lock idle window has elapsed. */
|
|
87
|
+
get expired() {
|
|
88
|
+
if (this.lockTimeoutMs === undefined || this.lockTimeoutMs <= 0)
|
|
89
|
+
return false;
|
|
90
|
+
return Date.now() - this.lastActivity > this.lockTimeoutMs;
|
|
91
|
+
}
|
|
92
|
+
/** Lock the vault: wipe the derived key and require re-unlock. */
|
|
93
|
+
lock() {
|
|
94
|
+
this.key?.fill(0);
|
|
95
|
+
this.key = undefined;
|
|
96
|
+
this.locked = true;
|
|
97
|
+
this.lastActivity = Date.now();
|
|
98
|
+
}
|
|
99
|
+
/** Re-derive the key from the master password (after a lock). */
|
|
100
|
+
async unlock() {
|
|
101
|
+
if (this.kdf === undefined) {
|
|
102
|
+
await this.load();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.key = await deriveKey(this.masterPassword, this.kdf);
|
|
106
|
+
this.locked = false;
|
|
107
|
+
this.lastActivity = Date.now();
|
|
108
|
+
}
|
|
64
109
|
/**
|
|
65
110
|
* Load the vault document from disk (creating an empty one on first use)
|
|
66
111
|
* and derive the vault key from the document's fixed KDF parameters.
|
|
@@ -79,6 +124,8 @@ export class VaultStore {
|
|
|
79
124
|
// First run: persist the empty document so the file exists with the
|
|
80
125
|
// chosen KDF (and its verify envelope) before any entry is added.
|
|
81
126
|
await this.persist();
|
|
127
|
+
this.locked = false;
|
|
128
|
+
this.lastActivity = Date.now();
|
|
82
129
|
return;
|
|
83
130
|
}
|
|
84
131
|
for (const blob of file.entries) {
|
|
@@ -91,29 +138,41 @@ export class VaultStore {
|
|
|
91
138
|
if (!safeEqual(verify, Buffer.from(VERIFY_PLAINTEXT, 'utf8'))) {
|
|
92
139
|
throw new Error('vault master password is incorrect');
|
|
93
140
|
}
|
|
141
|
+
this.locked = false;
|
|
142
|
+
this.lastActivity = Date.now();
|
|
94
143
|
}
|
|
95
144
|
/** The vault file path (useful for messages and debugging). */
|
|
96
145
|
get filePath() {
|
|
97
146
|
return this.path;
|
|
98
147
|
}
|
|
99
|
-
/** All entries, in insertion order. */
|
|
148
|
+
/** All active (non-trashed) entries, in insertion order. */
|
|
100
149
|
list() {
|
|
101
|
-
return [...this.entries.values()];
|
|
150
|
+
return [...this.entries.values()].filter(entry => entry.deletedAt === undefined);
|
|
151
|
+
}
|
|
152
|
+
/** All trashed entries (soft-deleted, awaiting purge or restore). */
|
|
153
|
+
listTrash() {
|
|
154
|
+
return [...this.entries.values()].filter(entry => entry.deletedAt !== undefined);
|
|
102
155
|
}
|
|
103
|
-
/** Read one entry by id. */
|
|
156
|
+
/** Read one active entry by id. */
|
|
104
157
|
get(id) {
|
|
158
|
+
const entry = this.entries.get(id);
|
|
159
|
+
return entry !== undefined && entry.deletedAt === undefined ? entry : undefined;
|
|
160
|
+
}
|
|
161
|
+
/** Read one entry by id, including trashed entries. */
|
|
162
|
+
getIncludingTrash(id) {
|
|
105
163
|
return this.entries.get(id);
|
|
106
164
|
}
|
|
107
|
-
/** Search entries across text fields; returns summaries without secrets.
|
|
165
|
+
/** Search entries across text fields; returns summaries without secrets.
|
|
166
|
+
* Multiple whitespace-separated terms match when ANY term hits (OR). */
|
|
108
167
|
search(query, limit = 20) {
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
168
|
+
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
169
|
+
if (terms.length === 0)
|
|
111
170
|
return [];
|
|
112
171
|
const results = [];
|
|
113
172
|
for (const entry of this.list()) {
|
|
114
173
|
if (results.length >= limit)
|
|
115
174
|
break;
|
|
116
|
-
if (matches(entry,
|
|
175
|
+
if (terms.some(term => matches(entry, term)))
|
|
117
176
|
results.push(toSummary(entry));
|
|
118
177
|
}
|
|
119
178
|
return results;
|
|
@@ -134,6 +193,10 @@ export class VaultStore {
|
|
|
134
193
|
await this.persist();
|
|
135
194
|
return entry;
|
|
136
195
|
}
|
|
196
|
+
/** Insert an entry directly (used by bulk import); caller owns timestamps. */
|
|
197
|
+
insertDirect(entry) {
|
|
198
|
+
this.entries.set(entry.id, entry);
|
|
199
|
+
}
|
|
137
200
|
/** Update an existing entry's fields; returns the updated entry or undefined.
|
|
138
201
|
* Every defined field in `patch` replaces the stored value; an empty string
|
|
139
202
|
* clears (removes) that field. `id`/`createdAt` can never change. */
|
|
@@ -163,8 +226,27 @@ export class VaultStore {
|
|
|
163
226
|
await this.persist();
|
|
164
227
|
return updated;
|
|
165
228
|
}
|
|
166
|
-
/**
|
|
229
|
+
/** Soft-delete an entry (move to trash); returns true when it existed.
|
|
230
|
+
* The entry stays encrypted on disk until purged or restored. */
|
|
167
231
|
async delete(id) {
|
|
232
|
+
const entry = this.entries.get(id);
|
|
233
|
+
if (!entry || entry.deletedAt !== undefined)
|
|
234
|
+
return false;
|
|
235
|
+
entry.deletedAt = Date.now();
|
|
236
|
+
await this.persist();
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
/** Restore a trashed entry; returns true when it existed in trash. */
|
|
240
|
+
async restore(id) {
|
|
241
|
+
const entry = this.entries.get(id);
|
|
242
|
+
if (!entry || entry.deletedAt === undefined)
|
|
243
|
+
return false;
|
|
244
|
+
delete entry.deletedAt;
|
|
245
|
+
await this.persist();
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/** Permanently remove a trashed (or active) entry; returns true when it existed. */
|
|
249
|
+
async purge(id) {
|
|
168
250
|
const existed = this.entries.delete(id);
|
|
169
251
|
if (existed)
|
|
170
252
|
await this.persist();
|
|
@@ -178,6 +260,128 @@ export class VaultStore {
|
|
|
178
260
|
get unlocked() {
|
|
179
261
|
return this.key !== undefined;
|
|
180
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Rotation & expiry report: entries whose `rotationDays` elapsed since
|
|
265
|
+
* their last update, or whose `expiresAt` is near/past. Returns only
|
|
266
|
+
* summaries plus the computed due state (no secrets).
|
|
267
|
+
*/
|
|
268
|
+
rotationReport(now = Date.now()) {
|
|
269
|
+
const report = [];
|
|
270
|
+
for (const entry of this.list()) {
|
|
271
|
+
const base = entry.updatedAt ?? entry.createdAt;
|
|
272
|
+
const rotationAt = entry.rotationDays !== undefined ? base + entry.rotationDays * 86_400_000 : undefined;
|
|
273
|
+
const expiresAt = entry.expiresAt;
|
|
274
|
+
let due;
|
|
275
|
+
let daysLeft;
|
|
276
|
+
if (rotationAt !== undefined && now >= rotationAt) {
|
|
277
|
+
due = 'due';
|
|
278
|
+
daysLeft = 0;
|
|
279
|
+
}
|
|
280
|
+
else if (expiresAt !== undefined && now >= expiresAt) {
|
|
281
|
+
due = 'expired';
|
|
282
|
+
daysLeft = 0;
|
|
283
|
+
}
|
|
284
|
+
else if (expiresAt !== undefined) {
|
|
285
|
+
daysLeft = Math.ceil((expiresAt - now) / 86_400_000);
|
|
286
|
+
if (daysLeft <= 7) {
|
|
287
|
+
due = 'soon';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else if (rotationAt !== undefined) {
|
|
291
|
+
daysLeft = Math.ceil((rotationAt - now) / 86_400_000);
|
|
292
|
+
if (daysLeft <= 7) {
|
|
293
|
+
due = 'soon';
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (due === undefined)
|
|
300
|
+
continue;
|
|
301
|
+
report.push({ ...toSummary(entry), due, daysLeft });
|
|
302
|
+
}
|
|
303
|
+
return report;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Health scan: weak passwords (too short), and passwords/API keys reused
|
|
307
|
+
* across entries. Returns non-secret findings keyed by entry id.
|
|
308
|
+
*/
|
|
309
|
+
health() {
|
|
310
|
+
const weak = [];
|
|
311
|
+
const passwordCounts = new Map();
|
|
312
|
+
const keyCounts = new Map();
|
|
313
|
+
for (const entry of this.list()) {
|
|
314
|
+
const summary = toSummary(entry);
|
|
315
|
+
if (entry.password !== undefined) {
|
|
316
|
+
if (entry.password.length < VaultStore.MIN_PASSWORD_LENGTH)
|
|
317
|
+
weak.push(summary);
|
|
318
|
+
const list = passwordCounts.get(entry.password) ?? [];
|
|
319
|
+
list.push(summary);
|
|
320
|
+
passwordCounts.set(entry.password, list);
|
|
321
|
+
}
|
|
322
|
+
for (const key of [entry.apiKey, entry.accessToken, entry.refreshToken, entry.secret]) {
|
|
323
|
+
if (key === undefined)
|
|
324
|
+
continue;
|
|
325
|
+
const list = keyCounts.get(key) ?? [];
|
|
326
|
+
list.push(summary);
|
|
327
|
+
keyCounts.set(key, list);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const reused = [
|
|
331
|
+
...[...passwordCounts.entries()].filter(([, v]) => v.length > 1),
|
|
332
|
+
...[...keyCounts.entries()].filter(([, v]) => v.length > 1),
|
|
333
|
+
].map(([value, entries]) => ({ value, entries }));
|
|
334
|
+
return { weak, reused };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Export the whole vault (including trash) as a single encrypted blob under
|
|
338
|
+
* a separate export password: a portable, machine-independent document that
|
|
339
|
+
* can be re-imported elsewhere. Returns the armored JSON string.
|
|
340
|
+
*/
|
|
341
|
+
async exportEncrypted(exportPassword, now = Date.now()) {
|
|
342
|
+
if (exportPassword.length === 0)
|
|
343
|
+
throw new Error('vault: export password must not be empty');
|
|
344
|
+
const exportKdf = newKdfParams();
|
|
345
|
+
const exportKey = await deriveKey(exportPassword, exportKdf);
|
|
346
|
+
const payload = {
|
|
347
|
+
exportedAt: now,
|
|
348
|
+
kdf: exportKdf,
|
|
349
|
+
entries: [...this.entries.values()].map(entry => ({
|
|
350
|
+
id: entry.id,
|
|
351
|
+
...encrypt(Buffer.from(JSON.stringify(entry), 'utf8'), exportKey),
|
|
352
|
+
})),
|
|
353
|
+
};
|
|
354
|
+
exportKey.fill(0);
|
|
355
|
+
return JSON.stringify(payload);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Import an exported vault blob, merging entries by id (existing entries win
|
|
359
|
+
* unless `overwrite`). Returns the number of entries added.
|
|
360
|
+
*/
|
|
361
|
+
async importEncrypted(blob, exportPassword, overwrite = false) {
|
|
362
|
+
if (exportPassword.length === 0)
|
|
363
|
+
throw new Error('vault: export password must not be empty');
|
|
364
|
+
const parsed = JSON.parse(blob);
|
|
365
|
+
if (!parsed.kdf || !Array.isArray(parsed.entries)) {
|
|
366
|
+
throw new Error('vault: invalid export document');
|
|
367
|
+
}
|
|
368
|
+
const exportKey = await deriveKey(exportPassword, parsed.kdf);
|
|
369
|
+
let added = 0;
|
|
370
|
+
for (const blobEntry of parsed.entries) {
|
|
371
|
+
const plaintext = decrypt(blobEntry, exportKey);
|
|
372
|
+
const entry = JSON.parse(plaintext.toString('utf8'));
|
|
373
|
+
const existing = this.entries.get(entry.id);
|
|
374
|
+
if (existing !== undefined && !overwrite)
|
|
375
|
+
continue;
|
|
376
|
+
// Imported entries come back as active (clear any soft-delete marker).
|
|
377
|
+
const { deletedAt, ...active } = entry;
|
|
378
|
+
this.entries.set(entry.id, { ...active, updatedAt: Date.now() });
|
|
379
|
+
added++;
|
|
380
|
+
}
|
|
381
|
+
exportKey.fill(0);
|
|
382
|
+
await this.persist();
|
|
383
|
+
return added;
|
|
384
|
+
}
|
|
181
385
|
/**
|
|
182
386
|
* Persist the current in-memory state: encrypt every entry under the vault
|
|
183
387
|
* key, then atomically replace the document under the cross-process lock.
|
|
@@ -187,7 +391,40 @@ export class VaultStore {
|
|
|
187
391
|
* The derived key is cached after load and reused, so a mutation does not
|
|
188
392
|
* re-run scrypt; only a fresh vault (no cached key yet) derives once.
|
|
189
393
|
*/
|
|
190
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Re-key the vault with fresh scrypt KDF parameters (cost upgrade): derive a
|
|
396
|
+
* new key from the master password under `newKdfParams()` and re-encrypt
|
|
397
|
+
* every entry under it, atomically. The old key buffer is wiped. Returns the
|
|
398
|
+
* new cost parameter `n` for reporting.
|
|
399
|
+
*/
|
|
400
|
+
async rekey() {
|
|
401
|
+
if (this.key === undefined) {
|
|
402
|
+
await this.load();
|
|
403
|
+
}
|
|
404
|
+
const newKdf = newKdfParams();
|
|
405
|
+
const newKey = await deriveKey(this.masterPassword, newKdf);
|
|
406
|
+
// Encrypt every entry under the new key first (in memory), so a failure
|
|
407
|
+
// mid-encrypt leaves the on-disk document untouched.
|
|
408
|
+
const reEncrypted = [...this.entries.values()].map(entry => ({
|
|
409
|
+
id: entry.id,
|
|
410
|
+
...encrypt(Buffer.from(JSON.stringify(entry), 'utf8'), newKey),
|
|
411
|
+
}));
|
|
412
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
413
|
+
await withFileLock(this.path, async () => {
|
|
414
|
+
const file = {
|
|
415
|
+
version: VAULT_FORMAT_VERSION,
|
|
416
|
+
kdf: newKdf,
|
|
417
|
+
verify: encrypt(Buffer.from(VERIFY_PLAINTEXT, 'utf8'), newKey),
|
|
418
|
+
entries: reEncrypted,
|
|
419
|
+
};
|
|
420
|
+
await writeFileAtomic(this.path, JSON.stringify(file), { mode: 0o600, dirMode: 0o700 });
|
|
421
|
+
});
|
|
422
|
+
this.kdf = newKdf;
|
|
423
|
+
this.key?.fill(0);
|
|
424
|
+
this.key = newKey;
|
|
425
|
+
return { n: newKdf.n };
|
|
426
|
+
}
|
|
427
|
+
async persist() {
|
|
191
428
|
const run = async () => {
|
|
192
429
|
const kdf = this.kdf ?? newKdfParams();
|
|
193
430
|
this.kdf = kdf;
|
|
@@ -289,6 +526,7 @@ function toSummary(entry) {
|
|
|
289
526
|
return {
|
|
290
527
|
id: entry.id,
|
|
291
528
|
title: entry.title,
|
|
529
|
+
...(entry.sensitivity !== undefined ? { sensitivity: entry.sensitivity } : {}),
|
|
292
530
|
...(entry.kind !== undefined ? { kind: entry.kind } : {}),
|
|
293
531
|
...(entry.username !== undefined ? { username: entry.username } : {}),
|
|
294
532
|
...(entry.email !== undefined ? { email: entry.email } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Encrypted credential vault for DeepSeek Harness: store and retrieve usernames, emails, phone numbers, passwords, TOTP secrets, SSH/API-key/OAuth developer credentials through model tools and a Settings UI page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|