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/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 needle = query.trim().toLowerCase();
110
- if (needle.length === 0)
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, needle))
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
- /** Delete an entry; returns true when it existed. */
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
- persist() {
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.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",