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/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,17 +138,28 @@ 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
165
  /** Search entries across text fields; returns summaries without secrets. */
@@ -163,8 +221,27 @@ export class VaultStore {
163
221
  await this.persist();
164
222
  return updated;
165
223
  }
166
- /** Delete an entry; returns true when it existed. */
224
+ /** Soft-delete an entry (move to trash); returns true when it existed.
225
+ * The entry stays encrypted on disk until purged or restored. */
167
226
  async delete(id) {
227
+ const entry = this.entries.get(id);
228
+ if (!entry || entry.deletedAt !== undefined)
229
+ return false;
230
+ entry.deletedAt = Date.now();
231
+ await this.persist();
232
+ return true;
233
+ }
234
+ /** Restore a trashed entry; returns true when it existed in trash. */
235
+ async restore(id) {
236
+ const entry = this.entries.get(id);
237
+ if (!entry || entry.deletedAt === undefined)
238
+ return false;
239
+ delete entry.deletedAt;
240
+ await this.persist();
241
+ return true;
242
+ }
243
+ /** Permanently remove a trashed (or active) entry; returns true when it existed. */
244
+ async purge(id) {
168
245
  const existed = this.entries.delete(id);
169
246
  if (existed)
170
247
  await this.persist();
@@ -178,6 +255,128 @@ export class VaultStore {
178
255
  get unlocked() {
179
256
  return this.key !== undefined;
180
257
  }
258
+ /**
259
+ * Rotation & expiry report: entries whose `rotationDays` elapsed since
260
+ * their last update, or whose `expiresAt` is near/past. Returns only
261
+ * summaries plus the computed due state (no secrets).
262
+ */
263
+ rotationReport(now = Date.now()) {
264
+ const report = [];
265
+ for (const entry of this.list()) {
266
+ const base = entry.updatedAt ?? entry.createdAt;
267
+ const rotationAt = entry.rotationDays !== undefined ? base + entry.rotationDays * 86_400_000 : undefined;
268
+ const expiresAt = entry.expiresAt;
269
+ let due;
270
+ let daysLeft;
271
+ if (rotationAt !== undefined && now >= rotationAt) {
272
+ due = 'due';
273
+ daysLeft = 0;
274
+ }
275
+ else if (expiresAt !== undefined && now >= expiresAt) {
276
+ due = 'expired';
277
+ daysLeft = 0;
278
+ }
279
+ else if (expiresAt !== undefined) {
280
+ daysLeft = Math.ceil((expiresAt - now) / 86_400_000);
281
+ if (daysLeft <= 7) {
282
+ due = 'soon';
283
+ }
284
+ }
285
+ else if (rotationAt !== undefined) {
286
+ daysLeft = Math.ceil((rotationAt - now) / 86_400_000);
287
+ if (daysLeft <= 7) {
288
+ due = 'soon';
289
+ }
290
+ }
291
+ else {
292
+ continue;
293
+ }
294
+ if (due === undefined)
295
+ continue;
296
+ report.push({ ...toSummary(entry), due, daysLeft });
297
+ }
298
+ return report;
299
+ }
300
+ /**
301
+ * Health scan: weak passwords (too short), and passwords/API keys reused
302
+ * across entries. Returns non-secret findings keyed by entry id.
303
+ */
304
+ health() {
305
+ const weak = [];
306
+ const passwordCounts = new Map();
307
+ const keyCounts = new Map();
308
+ for (const entry of this.list()) {
309
+ const summary = toSummary(entry);
310
+ if (entry.password !== undefined) {
311
+ if (entry.password.length < VaultStore.MIN_PASSWORD_LENGTH)
312
+ weak.push(summary);
313
+ const list = passwordCounts.get(entry.password) ?? [];
314
+ list.push(summary);
315
+ passwordCounts.set(entry.password, list);
316
+ }
317
+ for (const key of [entry.apiKey, entry.accessToken, entry.refreshToken, entry.secret]) {
318
+ if (key === undefined)
319
+ continue;
320
+ const list = keyCounts.get(key) ?? [];
321
+ list.push(summary);
322
+ keyCounts.set(key, list);
323
+ }
324
+ }
325
+ const reused = [
326
+ ...[...passwordCounts.entries()].filter(([, v]) => v.length > 1),
327
+ ...[...keyCounts.entries()].filter(([, v]) => v.length > 1),
328
+ ].map(([value, entries]) => ({ value, entries }));
329
+ return { weak, reused };
330
+ }
331
+ /**
332
+ * Export the whole vault (including trash) as a single encrypted blob under
333
+ * a separate export password: a portable, machine-independent document that
334
+ * can be re-imported elsewhere. Returns the armored JSON string.
335
+ */
336
+ async exportEncrypted(exportPassword, now = Date.now()) {
337
+ if (exportPassword.length === 0)
338
+ throw new Error('vault: export password must not be empty');
339
+ const exportKdf = newKdfParams();
340
+ const exportKey = await deriveKey(exportPassword, exportKdf);
341
+ const payload = {
342
+ exportedAt: now,
343
+ kdf: exportKdf,
344
+ entries: [...this.entries.values()].map(entry => ({
345
+ id: entry.id,
346
+ ...encrypt(Buffer.from(JSON.stringify(entry), 'utf8'), exportKey),
347
+ })),
348
+ };
349
+ exportKey.fill(0);
350
+ return JSON.stringify(payload);
351
+ }
352
+ /**
353
+ * Import an exported vault blob, merging entries by id (existing entries win
354
+ * unless `overwrite`). Returns the number of entries added.
355
+ */
356
+ async importEncrypted(blob, exportPassword, overwrite = false) {
357
+ if (exportPassword.length === 0)
358
+ throw new Error('vault: export password must not be empty');
359
+ const parsed = JSON.parse(blob);
360
+ if (!parsed.kdf || !Array.isArray(parsed.entries)) {
361
+ throw new Error('vault: invalid export document');
362
+ }
363
+ const exportKey = await deriveKey(exportPassword, parsed.kdf);
364
+ let added = 0;
365
+ for (const blobEntry of parsed.entries) {
366
+ const plaintext = decrypt(blobEntry, exportKey);
367
+ const entry = JSON.parse(plaintext.toString('utf8'));
368
+ const existing = this.entries.get(entry.id);
369
+ if (existing !== undefined && !overwrite)
370
+ continue;
371
+ // Imported entries come back as active (clear any soft-delete marker).
372
+ const { deletedAt, ...active } = entry;
373
+ this.entries.set(entry.id, { ...active, updatedAt: Date.now() });
374
+ added++;
375
+ }
376
+ exportKey.fill(0);
377
+ await this.persist();
378
+ return added;
379
+ }
181
380
  /**
182
381
  * Persist the current in-memory state: encrypt every entry under the vault
183
382
  * key, then atomically replace the document under the cross-process lock.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vault",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",