@mstone6969/vault 0.2.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.
Files changed (58) hide show
  1. package/README.md +198 -6
  2. package/dist/crypto.d.ts +99 -2
  3. package/dist/crypto.d.ts.map +1 -1
  4. package/dist/errors.d.ts +122 -4
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +447 -33
  9. package/dist/index.js.map +9 -7
  10. package/dist/providers.d.ts +131 -0
  11. package/dist/providers.d.ts.map +1 -0
  12. package/dist/stores/file.d.ts +190 -0
  13. package/dist/stores/file.d.ts.map +1 -0
  14. package/dist/stores/file.js +1 -0
  15. package/dist/stores/memory.d.ts +98 -7
  16. package/dist/stores/memory.d.ts.map +1 -1
  17. package/dist/stores/sqlite.d.ts +140 -9
  18. package/dist/stores/sqlite.d.ts.map +1 -1
  19. package/dist/stores/sqlite.js +47 -12
  20. package/dist/stores/sqlite.js.map +3 -3
  21. package/dist/types.d.ts +408 -17
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/vault.d.ts +554 -19
  24. package/dist/vault.d.ts.map +1 -1
  25. package/docs/README.md +10 -0
  26. package/docs/index/README.md +48 -0
  27. package/docs/index/classes/FileStore.md +341 -0
  28. package/docs/index/classes/MemoryStore.md +240 -0
  29. package/docs/index/classes/Vault.md +805 -0
  30. package/docs/index/classes/VaultError.md +371 -0
  31. package/docs/index/classes/VaultKeyError.md +370 -0
  32. package/docs/index/functions/envKey.md +43 -0
  33. package/docs/index/functions/fileKey.md +46 -0
  34. package/docs/index/functions/generateKey.md +39 -0
  35. package/docs/index/functions/importKey.md +49 -0
  36. package/docs/index/functions/isKeyProvider.md +43 -0
  37. package/docs/index/functions/open.md +67 -0
  38. package/docs/index/functions/randomValue.md +53 -0
  39. package/docs/index/functions/seal.md +56 -0
  40. package/docs/index/functions/staticKey.md +39 -0
  41. package/docs/index/type-aliases/Generator.md +58 -0
  42. package/docs/index/type-aliases/HistoryEntry.md +65 -0
  43. package/docs/index/type-aliases/KeyProvider.md +74 -0
  44. package/docs/index/type-aliases/PutOptions.md +141 -0
  45. package/docs/index/type-aliases/RekeyReport.md +37 -0
  46. package/docs/index/type-aliases/RotationContext.md +49 -0
  47. package/docs/index/type-aliases/RotationPolicy.md +142 -0
  48. package/docs/index/type-aliases/SecretRecord.md +214 -0
  49. package/docs/index/type-aliases/SecretSummary.md +56 -0
  50. package/docs/index/type-aliases/VaultEvent.md +95 -0
  51. package/docs/index/type-aliases/VaultOptions.md +142 -0
  52. package/docs/index/type-aliases/VaultStore.md +156 -0
  53. package/docs/index/variables/DEFAULT_ALPHABET.md +29 -0
  54. package/docs/index/variables/DEFAULT_HISTORY_LIMIT.md +28 -0
  55. package/docs/index/variables/DEFAULT_PREFIX.md +23 -0
  56. package/docs/stores/sqlite/README.md +11 -0
  57. package/docs/stores/sqlite/classes/SqliteStore.md +307 -0
  58. package/package.json +15 -5
package/dist/index.js CHANGED
@@ -46,47 +46,269 @@ async function open(key, sealed) {
46
46
  }
47
47
  }
48
48
 
49
+ // src/providers.ts
50
+ function staticKey(key) {
51
+ return { key: () => key };
52
+ }
53
+ function envKey(name) {
54
+ return {
55
+ key() {
56
+ const value = process.env[name];
57
+ if (!value) {
58
+ throw new VaultKeyError(`${name} is not set, so there is no key to open with.`);
59
+ }
60
+ return value;
61
+ }
62
+ };
63
+ }
64
+ function fileKey(path) {
65
+ return {
66
+ async key() {
67
+ const file = Bun.file(path);
68
+ if (!await file.exists()) {
69
+ throw new VaultKeyError(`No key file at ${path}.`);
70
+ }
71
+ const contents = (await file.text()).trim();
72
+ if (!contents)
73
+ throw new VaultKeyError(`The key file at ${path} is empty.`);
74
+ return contents;
75
+ }
76
+ };
77
+ }
78
+ function isKeyProvider(value) {
79
+ return typeof value?.key === "function";
80
+ }
81
+
49
82
  // src/vault.ts
50
83
  var DEFAULT_PREFIX = "@vault:";
84
+ var DEFAULT_HISTORY_LIMIT = 5;
51
85
  var NAME_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
86
+ var DEFAULT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
87
+ function randomValue(length = 32, alphabet = DEFAULT_ALPHABET) {
88
+ if (length < 1)
89
+ throw new VaultError("A generated value needs at least one character.");
90
+ if (alphabet.length < 2) {
91
+ throw new VaultError("A generated value needs an alphabet of at least two characters.");
92
+ }
93
+ const ceiling = Math.floor(256 / alphabet.length) * alphabet.length;
94
+ let value = "";
95
+ while (value.length < length) {
96
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
97
+ for (const byte of bytes) {
98
+ if (byte >= ceiling)
99
+ continue;
100
+ value += alphabet[byte % alphabet.length];
101
+ if (value.length === length)
102
+ break;
103
+ }
104
+ }
105
+ return value;
106
+ }
107
+ function toKey(key) {
108
+ const provider = isKeyProvider(key) ? key : staticKey(key);
109
+ return Promise.resolve(provider.key()).then((resolved) => typeof resolved === "string" ? importKey(resolved) : resolved);
110
+ }
111
+ function summarise(record) {
112
+ const { sealed: _sealed, sealedKey: _key, plain, history, ...rest } = record;
113
+ const summary = { ...rest, versions: history.length };
114
+ if (!record.isSealed && plain !== null)
115
+ summary.value = plain;
116
+ return summary;
117
+ }
118
+ function isExpired(record, now) {
119
+ return record.expiresAt !== null && record.expiresAt.getTime() <= now.getTime();
120
+ }
52
121
 
53
122
  class Vault {
54
- key;
123
+ keySource;
124
+ previousSources;
125
+ keyCache = null;
126
+ previousCache = null;
55
127
  store;
128
+ historyLimit;
129
+ generators;
130
+ onAccess;
56
131
  prefix;
57
- constructor({ key, store, prefix = DEFAULT_PREFIX }) {
58
- this.key = typeof key === "string" ? importKey(key) : Promise.resolve(key);
132
+ constructor({
133
+ key,
134
+ store,
135
+ prefix = DEFAULT_PREFIX,
136
+ previousKeys = [],
137
+ historyLimit = DEFAULT_HISTORY_LIMIT,
138
+ generators = {},
139
+ onAccess
140
+ }) {
141
+ this.keySource = key;
142
+ this.previousSources = previousKeys;
59
143
  this.store = store;
60
144
  this.prefix = prefix;
145
+ this.historyLimit = historyLimit;
146
+ this.generators = generators;
147
+ this.onAccess = onAccess;
148
+ }
149
+ master() {
150
+ this.keyCache ??= toKey(this.keySource);
151
+ return this.keyCache;
152
+ }
153
+ retired() {
154
+ this.previousCache ??= this.previousSources.map(toKey);
155
+ return this.previousCache;
156
+ }
157
+ record(event) {
158
+ if (!this.onAccess)
159
+ return;
160
+ try {
161
+ this.onAccess({ ...event, at: new Date });
162
+ } catch {}
163
+ }
164
+ async unsealWithMaster(sealed) {
165
+ try {
166
+ return await open(await this.master(), sealed);
167
+ } catch (error) {
168
+ for (const previous of this.retired()) {
169
+ try {
170
+ return await open(await previous, sealed);
171
+ } catch {}
172
+ }
173
+ throw error;
174
+ }
175
+ }
176
+ async unseal(sealed, sealedKey) {
177
+ if (!sealedKey) {
178
+ return this.unsealWithMaster(sealed);
179
+ }
180
+ const dataKey = await importKey(await this.unsealWithMaster(sealedKey));
181
+ return open(dataKey, sealed);
182
+ }
183
+ async enseal(value) {
184
+ const material = generateKey();
185
+ const dataKey = await importKey(material);
186
+ return {
187
+ sealed: await seal(dataKey, value),
188
+ sealedKey: await seal(await this.master(), material)
189
+ };
190
+ }
191
+ checkName(name) {
192
+ const clean = String(name ?? "").trim();
193
+ if (!NAME_PATTERN.test(clean)) {
194
+ throw new VaultError("A name can be up to 64 characters: letters, numbers, dot, dash or underscore.");
195
+ }
196
+ return clean;
61
197
  }
62
198
  async list(owner) {
63
199
  const records = await this.store.list(owner);
64
- return records.map(({ sealed: _sealed, ...summary }) => summary).sort((a, b) => a.name.localeCompare(b.name));
200
+ return records.map(summarise).sort((a, b) => a.name.localeCompare(b.name));
65
201
  }
66
- async put(owner, name, value, metadata = {}) {
202
+ async put(owner, name, value, options = {}) {
67
203
  const clean = this.checkName(name);
68
204
  if (!value)
69
205
  throw new VaultError("A secret needs a value.");
70
- const sealed = await seal(await this.key, value);
71
- const { sealed: _sealed, ...summary } = await this.store.put({
206
+ const existing = await this.store.get(owner, clean);
207
+ if (existing?.isFinal) {
208
+ this.record({ action: "denied", owner, name: clean, detail: "final" });
209
+ throw new VaultError(`"${clean}" is final: it cannot be changed, only deleted.`, 409);
210
+ }
211
+ const now = new Date;
212
+ const isSealed = options.open === undefined ? existing?.isSealed ?? true : !options.open;
213
+ const history = options.keepHistory && existing && existing.sealed ? [
214
+ {
215
+ sealed: existing.sealed,
216
+ sealedKey: existing.sealedKey,
217
+ createdAt: existing.updatedAt
218
+ },
219
+ ...existing.history
220
+ ].slice(0, this.historyLimit) : existing?.history ?? [];
221
+ const body = isSealed ? { ...await this.enseal(value), plain: null } : { sealed: "", sealedKey: null, plain: value };
222
+ const stored = await this.store.put({
72
223
  owner,
73
224
  name: clean,
74
- sealed,
75
- metadata
225
+ ...body,
226
+ isSealed,
227
+ isFinal: options.final === true,
228
+ rotation: options.rotation === undefined ? existing?.rotation ?? null : options.rotation,
229
+ rotatedAt: existing?.rotatedAt ?? null,
230
+ expiresAt: options.expiresAt === undefined ? existing?.expiresAt ?? null : options.expiresAt,
231
+ history,
232
+ metadata: options.metadata ?? existing?.metadata ?? {},
233
+ createdAt: existing?.createdAt ?? now,
234
+ updatedAt: now
76
235
  });
77
- return summary;
236
+ this.record({ action: "put", owner, name: clean });
237
+ return summarise(stored);
238
+ }
239
+ async rotate(owner, name, value, options = {}) {
240
+ const clean = this.checkName(name);
241
+ const next = value ?? await this.generate(owner, clean);
242
+ this.record({ action: "rotate", owner, name: clean });
243
+ const summary = await this.put(owner, clean, next, { ...options, keepHistory: true });
244
+ const stored = await this.store.get(owner, clean);
245
+ if (stored)
246
+ await this.store.put({ ...stored, rotatedAt: new Date });
247
+ return { ...summary, rotatedAt: new Date };
248
+ }
249
+ async generate(owner, name) {
250
+ const record = await this.require(owner, name);
251
+ const policy = record.rotation;
252
+ if (!policy) {
253
+ throw new VaultError(`"${name}" has no rotation policy, so there is nothing to make the next value with.`);
254
+ }
255
+ if (policy.kind === "random") {
256
+ return randomValue(policy.length, policy.alphabet);
257
+ }
258
+ const generator = policy.generator ? this.generators[policy.generator] : undefined;
259
+ if (!generator) {
260
+ throw new VaultError(`"${name}" wants the "${policy.generator ?? "unnamed"}" generator, which this vault does not have.`, 501);
261
+ }
262
+ return generator({ owner, name, arguments: policy.arguments ?? {} });
263
+ }
264
+ async rotationDue(now = new Date) {
265
+ const records = await this.store.all();
266
+ return records.filter((record) => {
267
+ const every = record.rotation?.every;
268
+ if (!every)
269
+ return false;
270
+ const last = (record.rotatedAt ?? record.createdAt).getTime();
271
+ return now.getTime() - last >= every * 1000;
272
+ }).map(summarise);
273
+ }
274
+ async versions(owner, name) {
275
+ const record = await this.require(owner, name);
276
+ return Promise.all(record.history.map((entry) => this.unseal(entry.sealed, entry.sealedKey)));
78
277
  }
79
278
  async has(owner, name) {
80
279
  return await this.store.get(owner, this.checkName(name)) !== null;
81
280
  }
82
- remove(owner, name) {
83
- return this.store.remove(owner, this.checkName(name));
281
+ async remove(owner, name) {
282
+ const removed = await this.store.remove(owner, this.checkName(name));
283
+ if (removed)
284
+ this.record({ action: "remove", owner, name });
285
+ return removed;
84
286
  }
85
- async open(owner, name) {
86
- const record = await this.store.get(owner, this.checkName(name));
287
+ async require(owner, name, now = new Date) {
288
+ const clean = this.checkName(name);
289
+ const record = await this.store.get(owner, clean);
87
290
  if (!record)
88
- throw new VaultError(`No secret named "${name}" in the vault.`, 404);
89
- return open(await this.key, record.sealed);
291
+ throw new VaultError(`No secret named "${clean}" in the vault.`, 404);
292
+ if (isExpired(record, now)) {
293
+ this.record({ action: "denied", owner, name: clean, detail: "expired" });
294
+ throw new VaultError(`"${clean}" expired and can no longer be used.`, 410);
295
+ }
296
+ return record;
297
+ }
298
+ async open(owner, name) {
299
+ const record = await this.require(owner, name);
300
+ const value = record.isSealed ? await this.unseal(record.sealed, record.sealedKey) : record.plain ?? "";
301
+ this.record({ action: "open", owner, name });
302
+ return value;
303
+ }
304
+ async read(owner, name) {
305
+ const record = await this.require(owner, name);
306
+ if (record.isSealed) {
307
+ this.record({ action: "denied", owner, name, detail: "sealed" });
308
+ throw new VaultError(`"${name}" is sealed and cannot be read back.`, 403);
309
+ }
310
+ this.record({ action: "read", owner, name });
311
+ return record.plain ?? "";
90
312
  }
91
313
  async resolve(owner, values) {
92
314
  const references = Object.entries(values).filter(([, value]) => value.startsWith(this.prefix));
@@ -98,17 +320,83 @@ class Vault {
98
320
  }
99
321
  return resolved;
100
322
  }
101
- checkName(name) {
102
- const clean = String(name ?? "").trim();
103
- if (!NAME_PATTERN.test(clean)) {
104
- throw new VaultError("A name can be up to 64 characters: letters, numbers, dot, dash or underscore.");
323
+ async reseal(owner) {
324
+ const records = owner ? await this.store.list(owner) : await this.store.all();
325
+ let resealed = 0;
326
+ for (const record of records) {
327
+ if (!record.isSealed || !record.sealed)
328
+ continue;
329
+ const value = await this.unseal(record.sealed, record.sealedKey);
330
+ await this.store.put({ ...record, ...await this.enseal(value) });
331
+ resealed += 1;
105
332
  }
106
- return clean;
333
+ return resealed;
334
+ }
335
+ async rekey(next) {
336
+ const nextKey = await toKey(next);
337
+ const report = { rekeyed: 0, failed: [] };
338
+ for (const record of await this.store.all()) {
339
+ if (!record.isSealed)
340
+ continue;
341
+ try {
342
+ const history = await this.rekeyHistory(record, nextKey);
343
+ if (record.sealedKey) {
344
+ const material = await this.unsealWithMaster(record.sealedKey);
345
+ await this.store.put({
346
+ ...record,
347
+ sealedKey: await seal(nextKey, material),
348
+ history
349
+ });
350
+ } else {
351
+ const value = await this.unsealWithMaster(record.sealed);
352
+ const material = generateKey();
353
+ const dataKey = await importKey(material);
354
+ await this.store.put({
355
+ ...record,
356
+ sealed: await seal(dataKey, value),
357
+ sealedKey: await seal(nextKey, material),
358
+ history
359
+ });
360
+ }
361
+ report.rekeyed += 1;
362
+ } catch {
363
+ report.failed.push(`${record.owner}/${record.name}`);
364
+ }
365
+ }
366
+ this.previousSources = [this.keySource, ...this.previousSources];
367
+ this.keySource = nextKey;
368
+ this.keyCache = Promise.resolve(nextKey);
369
+ this.previousCache = null;
370
+ this.record({
371
+ action: "rekey",
372
+ owner: "",
373
+ name: null,
374
+ detail: `${report.rekeyed} re-sealed, ${report.failed.length} failed`
375
+ });
376
+ return report;
377
+ }
378
+ async rekeyHistory(record, nextKey) {
379
+ return Promise.all(record.history.map(async (entry) => {
380
+ if (!entry.sealedKey)
381
+ return entry;
382
+ const material = await this.unsealWithMaster(entry.sealedKey);
383
+ return { ...entry, sealedKey: await seal(nextKey, material) };
384
+ }));
385
+ }
386
+ async purgeExpired(now = new Date) {
387
+ const expired = (await this.store.all()).filter((record) => isExpired(record, now));
388
+ for (const record of expired) {
389
+ await this.store.remove(record.owner, record.name);
390
+ }
391
+ return expired.length;
107
392
  }
108
393
  }
109
394
  // src/stores/memory.ts
110
395
  class MemoryStore {
111
- records = new Map;
396
+ records;
397
+ constructor() {
398
+ this.records = new Map;
399
+ }
112
400
  static key(owner, name) {
113
401
  return `${owner} ${name}`;
114
402
  }
@@ -118,32 +406,158 @@ class MemoryStore {
118
406
  async list(owner) {
119
407
  return [...this.records.values()].filter((record) => record.owner === owner);
120
408
  }
409
+ async all() {
410
+ return [...this.records.values()];
411
+ }
121
412
  async put(record) {
122
- const key = MemoryStore.key(record.owner, record.name);
123
- const now = new Date;
124
- const stored = {
125
- ...record,
126
- createdAt: this.records.get(key)?.createdAt ?? now,
127
- updatedAt: now
128
- };
129
- this.records.set(key, stored);
130
- return stored;
413
+ this.records.set(MemoryStore.key(record.owner, record.name), record);
414
+ return record;
131
415
  }
132
416
  async remove(owner, name) {
133
417
  return this.records.delete(MemoryStore.key(owner, name));
134
418
  }
135
419
  }
420
+ // src/stores/file.ts
421
+ import { open as openFile, readFile, rename, unlink } from "node:fs/promises";
422
+ var MAGIC = "VAULT1";
423
+
424
+ class FileStore {
425
+ path;
426
+ keySource;
427
+ keyCache = null;
428
+ records = null;
429
+ constructor(path, key) {
430
+ this.path = path;
431
+ this.keySource = key;
432
+ }
433
+ key() {
434
+ this.keyCache ??= (async () => {
435
+ const provider = isKeyProvider(this.keySource) ? this.keySource : staticKey(this.keySource);
436
+ const resolved = await provider.key();
437
+ return typeof resolved === "string" ? importKey(resolved) : resolved;
438
+ })();
439
+ return this.keyCache;
440
+ }
441
+ static id(owner, name) {
442
+ return `${owner} ${name}`;
443
+ }
444
+ async load() {
445
+ if (this.records)
446
+ return this.records;
447
+ let contents;
448
+ try {
449
+ contents = (await readFile(this.path, "utf8")).trim();
450
+ } catch (error) {
451
+ if (error.code !== "ENOENT")
452
+ throw error;
453
+ this.records = new Map;
454
+ return this.records;
455
+ }
456
+ const [magic, payload] = contents.split(`
457
+ `);
458
+ if (magic !== MAGIC || !payload) {
459
+ throw new VaultKeyError(`${this.path} is not a vault file.`);
460
+ }
461
+ const opened = await open(await this.key(), payload);
462
+ const parsed = JSON.parse(opened);
463
+ this.records = new Map(parsed.map((record) => [
464
+ FileStore.id(record.owner, record.name),
465
+ {
466
+ ...record,
467
+ expiresAt: record.expiresAt === null ? null : new Date(record.expiresAt),
468
+ history: record.history.map((entry) => ({
469
+ ...entry,
470
+ createdAt: new Date(entry.createdAt)
471
+ })),
472
+ rotatedAt: record.rotatedAt === null ? null : new Date(record.rotatedAt),
473
+ createdAt: new Date(record.createdAt),
474
+ updatedAt: new Date(record.updatedAt)
475
+ }
476
+ ]));
477
+ return this.records;
478
+ }
479
+ async save() {
480
+ const records = await this.load();
481
+ const sealed = await seal(await this.key(), JSON.stringify([...records.values()]));
482
+ const temporary = `${this.path}.${process.pid}.${Date.now()}.writing`;
483
+ const handle = await openFile(temporary, "w");
484
+ try {
485
+ await handle.writeFile(`${MAGIC}
486
+ ${sealed}
487
+ `);
488
+ await handle.sync();
489
+ } finally {
490
+ await handle.close();
491
+ }
492
+ await rename(temporary, this.path);
493
+ }
494
+ async get(owner, name) {
495
+ return (await this.load()).get(FileStore.id(owner, name)) ?? null;
496
+ }
497
+ async list(owner) {
498
+ return [...(await this.load()).values()].filter((record) => record.owner === owner);
499
+ }
500
+ async all() {
501
+ return [...(await this.load()).values()];
502
+ }
503
+ async put(record) {
504
+ const records = await this.load();
505
+ const id = FileStore.id(record.owner, record.name);
506
+ const displaced = records.get(id);
507
+ records.set(id, record);
508
+ try {
509
+ await this.save();
510
+ } catch (error) {
511
+ if (displaced)
512
+ records.set(id, displaced);
513
+ else
514
+ records.delete(id);
515
+ throw error;
516
+ }
517
+ return record;
518
+ }
519
+ async remove(owner, name) {
520
+ const records = await this.load();
521
+ const id = FileStore.id(owner, name);
522
+ const removed = records.get(id);
523
+ if (!removed)
524
+ return false;
525
+ records.delete(id);
526
+ try {
527
+ await this.save();
528
+ } catch (error) {
529
+ records.set(id, removed);
530
+ throw error;
531
+ }
532
+ return true;
533
+ }
534
+ forget() {
535
+ this.records = null;
536
+ }
537
+ async destroy() {
538
+ this.records = new Map;
539
+ await unlink(this.path).catch(() => {});
540
+ }
541
+ }
136
542
  export {
543
+ staticKey,
137
544
  seal,
545
+ randomValue,
138
546
  open,
547
+ isKeyProvider,
139
548
  importKey,
140
549
  generateKey,
550
+ fileKey,
551
+ envKey,
141
552
  VaultKeyError,
142
553
  VaultError,
143
554
  Vault,
144
555
  MemoryStore,
145
- DEFAULT_PREFIX
556
+ FileStore,
557
+ DEFAULT_PREFIX,
558
+ DEFAULT_HISTORY_LIMIT,
559
+ DEFAULT_ALPHABET
146
560
  };
147
561
 
148
- //# debugId=75E38B9D900E6BB164756E2164756E21
562
+ //# debugId=D639A370455C926764756E2164756E21
149
563
  //# sourceMappingURL=index.js.map