@absolutejs/secrets 0.6.1 → 0.7.1
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.md +53 -41
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +264 -0
- package/dist/agent.js.map +12 -0
- package/dist/broker.d.ts +340 -0
- package/dist/broker.js +825 -0
- package/dist/broker.js.map +11 -0
- package/dist/index.d.ts +2 -341
- package/dist/index.js +126 -58
- package/dist/index.js.map +7 -6
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +2 -4
- package/dist/manifest.js.map +3 -3
- package/dist/operations.d.ts +1 -1
- package/dist/postgres.d.ts +13 -0
- package/package.json +13 -3
package/dist/broker.d.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @absolutejs/secrets — host-side secret broker for multi-tenant Bun
|
|
3
|
+
* runtimes.
|
|
4
|
+
*
|
|
5
|
+
* Three responsibilities, kept narrow on purpose:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Resolve.** A pluggable adapter fetches a secret by name. The broker
|
|
8
|
+
* caches the answer, hands the caller back a `{ value, fingerprint }`
|
|
9
|
+
* pair (fingerprint is a sha256 prefix safe to put in logs), and fires
|
|
10
|
+
* an audit event for every lookup.
|
|
11
|
+
* 2. **Redact.** Walks every known cached secret out of an arbitrary string
|
|
12
|
+
* before it leaves the host (e.g. an error message that contains the
|
|
13
|
+
* leaked API key the host call just made). The replacement is
|
|
14
|
+
* `[REDACTED:name]`; matches shorter than `redactionMinLength` are
|
|
15
|
+
* skipped to avoid blanking coincidental short tokens.
|
|
16
|
+
* 3. **Rotate.** Delegates to `adapter.rotate?(name)` if the adapter
|
|
17
|
+
* supports it, invalidates the cache entry. Caller is expected to
|
|
18
|
+
* re-distribute to dependent surfaces.
|
|
19
|
+
*
|
|
20
|
+
* v0.0.1 ships three adapters: `inMemoryAdapter`, `envAdapter`,
|
|
21
|
+
* `compositeAdapter`. AWS Secrets Manager / Vault / Doppler / Infisical
|
|
22
|
+
* adapters ship later as siblings.
|
|
23
|
+
*
|
|
24
|
+
* The broker is bun/elysia-agnostic — same posture as router + meter.
|
|
25
|
+
*/
|
|
26
|
+
import { type TracerProvider } from "@absolutejs/telemetry";
|
|
27
|
+
export type SecretValue = {
|
|
28
|
+
/** The plaintext secret. Treat as poison: never log, never serialize. */
|
|
29
|
+
value: string;
|
|
30
|
+
/**
|
|
31
|
+
* Short sha256-derived id (first 8 hex chars). Stable across calls for
|
|
32
|
+
* the same value; safe to print in logs and traces. Useful for "which
|
|
33
|
+
* version of the key did this request use?" diagnostics without leaking.
|
|
34
|
+
*/
|
|
35
|
+
fingerprint: string;
|
|
36
|
+
};
|
|
37
|
+
export type SecretAdapter = {
|
|
38
|
+
/** Return the plaintext value for a name, or `null` if not stored here. */
|
|
39
|
+
fetch: (name: string) => Promise<string | null>;
|
|
40
|
+
/** Write a value. Optional — adapters may be read-only. */
|
|
41
|
+
put?: (name: string, value: string) => Promise<void>;
|
|
42
|
+
/** Delete a value. Optional. */
|
|
43
|
+
remove?: (name: string) => Promise<void>;
|
|
44
|
+
/** Rotate a value; return the NEW plaintext. Optional. */
|
|
45
|
+
rotate?: (name: string) => Promise<string>;
|
|
46
|
+
/** Enumerate names. Optional; default omitted to avoid leaking the index. */
|
|
47
|
+
list?: () => Promise<string[]>;
|
|
48
|
+
};
|
|
49
|
+
export type AuditEvent = {
|
|
50
|
+
event: "resolve.hit";
|
|
51
|
+
name: string;
|
|
52
|
+
fingerprint: string;
|
|
53
|
+
at: number;
|
|
54
|
+
} | {
|
|
55
|
+
event: "resolve.miss";
|
|
56
|
+
name: string;
|
|
57
|
+
fingerprint?: string;
|
|
58
|
+
at: number;
|
|
59
|
+
} | {
|
|
60
|
+
event: "resolve.error";
|
|
61
|
+
name: string;
|
|
62
|
+
error: string;
|
|
63
|
+
at: number;
|
|
64
|
+
} | {
|
|
65
|
+
event: "rotate";
|
|
66
|
+
name: string;
|
|
67
|
+
fingerprint: string;
|
|
68
|
+
at: number;
|
|
69
|
+
} | {
|
|
70
|
+
event: "invalidate";
|
|
71
|
+
name: string | null;
|
|
72
|
+
at: number;
|
|
73
|
+
};
|
|
74
|
+
export type AuditHook = (event: AuditEvent) => void | Promise<void>;
|
|
75
|
+
export type RedactionEncoding = "plain" | "base64";
|
|
76
|
+
export type SecretBrokerOptions = {
|
|
77
|
+
/** The adapter the broker delegates fetch / rotate / put to. */
|
|
78
|
+
adapter: SecretAdapter;
|
|
79
|
+
/** Audit hook fired on every resolve / rotate / invalidate. */
|
|
80
|
+
audit?: AuditHook;
|
|
81
|
+
/**
|
|
82
|
+
* How long a cached secret stays fresh, in ms. After this, the next
|
|
83
|
+
* resolve re-hits the adapter. Default 60_000 (1 minute). Set to
|
|
84
|
+
* `Infinity` to disable TTL — only `rotate` / `invalidate` evict.
|
|
85
|
+
*/
|
|
86
|
+
cacheTtlMs?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Per-name TTL overrides. The override wins over `cacheTtlMs`. Use
|
|
89
|
+
* a short TTL for high-blast-radius secrets (admin tokens, signing
|
|
90
|
+
* keys) so a compromised value's lifetime is bounded by the override,
|
|
91
|
+
* not the global default.
|
|
92
|
+
*/
|
|
93
|
+
cacheTtlOverrides?: Record<string, number>;
|
|
94
|
+
/**
|
|
95
|
+
* Minimum length a cached value must have before `redact` will rewrite
|
|
96
|
+
* occurrences of it in arbitrary text. Default 8 — short values risk
|
|
97
|
+
* blanking out coincidental matches (e.g. a short password "abc1"
|
|
98
|
+
* appearing as a substring of unrelated text).
|
|
99
|
+
*/
|
|
100
|
+
redactionMinLength?: number;
|
|
101
|
+
/**
|
|
102
|
+
* Encodings to redact alongside the plaintext value. Default `['plain']`.
|
|
103
|
+
* Add `'base64'` to also catch base64-encoded forms — useful when
|
|
104
|
+
* secrets end up inside JWTs, cookies, or any payload that base64-wraps
|
|
105
|
+
* a credential.
|
|
106
|
+
*/
|
|
107
|
+
redactionEncodings?: RedactionEncoding[];
|
|
108
|
+
/** Override `Date.now` for tests. */
|
|
109
|
+
clock?: () => number;
|
|
110
|
+
/**
|
|
111
|
+
* Optional OpenTelemetry tracer provider. When set, `broker.resolve`
|
|
112
|
+
* and `broker.rotate` are wrapped in `secrets.resolve` /
|
|
113
|
+
* `secrets.rotate` spans with `abs.secret.name` +
|
|
114
|
+
* `abs.secret.fingerprint` attributes. `broker.redact` is NOT
|
|
115
|
+
* traced — it's called per log line, which would explode span
|
|
116
|
+
* volume. When omitted, all tracing is a zero-allocation noop.
|
|
117
|
+
* Added in 0.3.0.
|
|
118
|
+
*/
|
|
119
|
+
tracerProvider?: TracerProvider;
|
|
120
|
+
};
|
|
121
|
+
/** Listener registered via {@link SecretBroker.onRotate}. */
|
|
122
|
+
export type RotationListener = (event: {
|
|
123
|
+
name: string;
|
|
124
|
+
value: string;
|
|
125
|
+
fingerprint: string;
|
|
126
|
+
at: number;
|
|
127
|
+
}) => void | Promise<void>;
|
|
128
|
+
export type SecretBroker = {
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a secret by name. Returns `null` if the adapter reports
|
|
131
|
+
* no value. Caches the answer for `cacheTtlMs`.
|
|
132
|
+
*/
|
|
133
|
+
resolve: (name: string) => Promise<SecretValue | null>;
|
|
134
|
+
/**
|
|
135
|
+
* Returns the fingerprint of a value WITHOUT touching the adapter.
|
|
136
|
+
* Useful for hashing a value the caller already has — e.g. a webhook
|
|
137
|
+
* payload — to compare against an audit log.
|
|
138
|
+
*/
|
|
139
|
+
fingerprint: (value: string) => string;
|
|
140
|
+
/**
|
|
141
|
+
* Replace every cached secret value found in `text` with
|
|
142
|
+
* `[REDACTED:name]`. Returns the rewritten text. Subjects shorter than
|
|
143
|
+
* `redactionMinLength` are skipped.
|
|
144
|
+
*/
|
|
145
|
+
redact: (text: string) => string;
|
|
146
|
+
/**
|
|
147
|
+
* Streaming variant of {@link redact}. Returns a `TransformStream`
|
|
148
|
+
* that catches secrets even when they're split across chunks (a chunk
|
|
149
|
+
* boundary in the middle of `sk_live_abc...` would otherwise miss). The
|
|
150
|
+
* stream keeps a lookback buffer the size of the longest cached secret;
|
|
151
|
+
* once the buffer outgrows that, the safe-region prefix is emitted.
|
|
152
|
+
*
|
|
153
|
+
* Use this on `process.stdout` / `process.stderr` / a tenant log forwarder
|
|
154
|
+
* so plaintext secrets never reach the sink.
|
|
155
|
+
*/
|
|
156
|
+
redactStream: () => TransformStream<string, string>;
|
|
157
|
+
/**
|
|
158
|
+
* Rotate a secret. Calls `adapter.rotate(name)`, invalidates the cache,
|
|
159
|
+
* returns the new `{ value, fingerprint }`. Throws if the adapter does
|
|
160
|
+
* not support rotation. Fires every `onRotate` listener registered for
|
|
161
|
+
* this name.
|
|
162
|
+
*/
|
|
163
|
+
rotate: (name: string) => Promise<SecretValue>;
|
|
164
|
+
/**
|
|
165
|
+
* Subscribe to rotation events for a specific name. Listener fires
|
|
166
|
+
* AFTER the new value is in the cache. Returns an unsubscribe handle.
|
|
167
|
+
* Use this for long-lived connections (DB clients, AI provider SDKs)
|
|
168
|
+
* that need to swap credentials in-place when rotation lands.
|
|
169
|
+
*/
|
|
170
|
+
onRotate: (name: string, listener: RotationListener) => () => void;
|
|
171
|
+
/**
|
|
172
|
+
* Invalidate one cache entry, or the whole cache when `name` is omitted.
|
|
173
|
+
*/
|
|
174
|
+
invalidate: (name?: string) => void;
|
|
175
|
+
/** Tear down the broker — clears the cache; further resolves still hit the adapter. */
|
|
176
|
+
dispose: () => void;
|
|
177
|
+
/**
|
|
178
|
+
* Operator-shaped cumulative counters since `createSecretBroker()`.
|
|
179
|
+
* Scrape on a 30s interval for tier monitoring + rotation cadence.
|
|
180
|
+
* Added in 0.2.0.
|
|
181
|
+
*/
|
|
182
|
+
metrics: () => SecretBrokerMetrics;
|
|
183
|
+
/**
|
|
184
|
+
* Refuse new `resolve()` / `rotate()` calls (they reject with
|
|
185
|
+
* `BrokerDrainedError`); in-flight adapter calls keep running. Use
|
|
186
|
+
* during graceful shutdown so a tenant whose process is about to
|
|
187
|
+
* stop doesn't issue a fresh fetch against the secret store mid-
|
|
188
|
+
* teardown. Symmetric with `runtime.drain()` / `queue.drain()`.
|
|
189
|
+
* Added in 0.2.0.
|
|
190
|
+
*/
|
|
191
|
+
drain: () => void;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Returned by {@link SecretBroker.metrics}. All counters cumulative
|
|
195
|
+
* since `createSecretBroker()`; cleared by neither `dispose()` nor
|
|
196
|
+
* `drain()` (so the operator can see what happened pre-shutdown).
|
|
197
|
+
* Added in 0.2.0.
|
|
198
|
+
*/
|
|
199
|
+
export type SecretBrokerMetrics = {
|
|
200
|
+
/** `resolve()` calls — including cached hits, misses, and errors. */
|
|
201
|
+
resolves: number;
|
|
202
|
+
/** `resolve()` calls served from cache (no adapter hit). */
|
|
203
|
+
resolveHits: number;
|
|
204
|
+
/** `resolve()` calls that hit the adapter (cache miss OR expired). */
|
|
205
|
+
resolveMisses: number;
|
|
206
|
+
/** `resolve()` calls where the adapter threw. */
|
|
207
|
+
resolveErrors: number;
|
|
208
|
+
/** Successful `rotate()` calls. */
|
|
209
|
+
rotates: number;
|
|
210
|
+
/** `rotate()` calls where the adapter threw. */
|
|
211
|
+
rotateErrors: number;
|
|
212
|
+
/** `invalidate()` calls (per call, regardless of cache size). */
|
|
213
|
+
invalidations: number;
|
|
214
|
+
/** `redact()` calls (whether anything was rewritten or not). */
|
|
215
|
+
redactCalls: number;
|
|
216
|
+
/**
|
|
217
|
+
* Distinct (secret, encoding) pairs that triggered a replacement —
|
|
218
|
+
* NOT total occurrences. A `redact()` call that rewrites the same
|
|
219
|
+
* key three times in one string bumps this by 1. Useful for
|
|
220
|
+
* "is anything ever actually getting redacted, or are we configured
|
|
221
|
+
* for nothing."
|
|
222
|
+
*/
|
|
223
|
+
redactionsApplied: number;
|
|
224
|
+
/** Subset of `redactionsApplied` for base64 encoding. */
|
|
225
|
+
redactionsBase64: number;
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* Thrown by `resolve()` / `rotate()` after `drain()` has been called.
|
|
229
|
+
* Added in 0.2.0.
|
|
230
|
+
*/
|
|
231
|
+
export declare class BrokerDrainedError extends Error {
|
|
232
|
+
constructor();
|
|
233
|
+
}
|
|
234
|
+
export type InMemoryAdapterOptions = {
|
|
235
|
+
initial?: Record<string, string>;
|
|
236
|
+
/** Override the rotation strategy. Default = random 32-char base36 string. */
|
|
237
|
+
rotate?: (name: string, previous: string | null) => string;
|
|
238
|
+
};
|
|
239
|
+
export declare const inMemoryAdapter: (options?: InMemoryAdapterOptions) => SecretAdapter;
|
|
240
|
+
export type EnvAdapterOptions = {
|
|
241
|
+
/**
|
|
242
|
+
* If set, lookups are prefixed before reading from env. e.g.
|
|
243
|
+
* `prefix: 'ABS_SECRET_'` and `resolve('STRIPE_KEY')` reads `ABS_SECRET_STRIPE_KEY`.
|
|
244
|
+
* Default `''` (no prefix).
|
|
245
|
+
*/
|
|
246
|
+
prefix?: string;
|
|
247
|
+
/** The env object to read from. Default `process.env`. */
|
|
248
|
+
env?: Record<string, string | undefined>;
|
|
249
|
+
};
|
|
250
|
+
export declare const envAdapter: (options?: EnvAdapterOptions) => SecretAdapter;
|
|
251
|
+
/**
|
|
252
|
+
* Compose adapters: `fetch` falls through to the first non-null result;
|
|
253
|
+
* `put` / `rotate` / `remove` go to the first adapter that implements them.
|
|
254
|
+
*/
|
|
255
|
+
export declare const compositeAdapter: (adapters: SecretAdapter[]) => SecretAdapter;
|
|
256
|
+
/** Override for tests; defaults touch disk via `node:fs/promises`. */
|
|
257
|
+
export type EncryptedFileIO = {
|
|
258
|
+
readFile: (path: string) => Promise<string | undefined>;
|
|
259
|
+
writeFileAtomic: (path: string, contents: string) => Promise<void>;
|
|
260
|
+
};
|
|
261
|
+
export type EncryptedFileAdapterMasterKey = {
|
|
262
|
+
type: "passphrase";
|
|
263
|
+
passphrase: string;
|
|
264
|
+
} | {
|
|
265
|
+
type: "raw";
|
|
266
|
+
bytes: Uint8Array;
|
|
267
|
+
};
|
|
268
|
+
export type EncryptedFileAdapterOptions = {
|
|
269
|
+
/** Absolute or relative path to the encrypted JSON file. */
|
|
270
|
+
path: string;
|
|
271
|
+
/**
|
|
272
|
+
* Master key. Either a `passphrase` (KDF'd via PBKDF2-SHA256 with the
|
|
273
|
+
* salt stored in the file) or `raw` 32 bytes (no KDF — pass the key
|
|
274
|
+
* directly, useful when sourced from a vendor secret manager).
|
|
275
|
+
*/
|
|
276
|
+
key: EncryptedFileAdapterMasterKey;
|
|
277
|
+
/**
|
|
278
|
+
* PBKDF2 iterations when `key.type === 'passphrase'`. Default 600_000
|
|
279
|
+
* (OWASP 2025 recommendation for SHA-256). The chosen value is stored
|
|
280
|
+
* in the file so future opens use it.
|
|
281
|
+
*/
|
|
282
|
+
pbkdf2Iterations?: number;
|
|
283
|
+
/** Override the rotation strategy (matches `inMemoryAdapter`). */
|
|
284
|
+
rotate?: (name: string, previous: string | null) => string;
|
|
285
|
+
/** Override file IO (tests). */
|
|
286
|
+
io?: EncryptedFileIO;
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Durable secret adapter that stores `name → value` in an encrypted
|
|
290
|
+
* JSON file (AES-256-GCM, per-value random IV). File is safe to commit
|
|
291
|
+
* to a private repo as long as the master key is kept separately
|
|
292
|
+
* (1Password, env var, hardware key, etc.).
|
|
293
|
+
*
|
|
294
|
+
* Two master-key shapes:
|
|
295
|
+
*
|
|
296
|
+
* - `{ type: 'passphrase', passphrase }` — PBKDF2-SHA256 from the
|
|
297
|
+
* passphrase, salt stored in the file. OWASP 2025 default (600k
|
|
298
|
+
* iterations); add a stronger one via `pbkdf2Iterations`.
|
|
299
|
+
* - `{ type: 'raw', bytes }` — 32 raw bytes. No KDF. Useful when the
|
|
300
|
+
* key comes from a vendor secret manager that already gave you
|
|
301
|
+
* random bytes.
|
|
302
|
+
*
|
|
303
|
+
* Caches decrypted values in memory after first read (consistent with
|
|
304
|
+
* the broker's caching layer above it).
|
|
305
|
+
*/
|
|
306
|
+
export declare const encryptedFileAdapter: (options: EncryptedFileAdapterOptions) => SecretAdapter;
|
|
307
|
+
export type RotateMasterKeyOptions = {
|
|
308
|
+
/** Path to the encrypted JSON file (must exist). */
|
|
309
|
+
path: string;
|
|
310
|
+
/** Master key the file was written with. */
|
|
311
|
+
oldKey: EncryptedFileAdapterMasterKey;
|
|
312
|
+
/** Master key to re-encrypt under. */
|
|
313
|
+
newKey: EncryptedFileAdapterMasterKey;
|
|
314
|
+
/**
|
|
315
|
+
* PBKDF2 iterations for the new passphrase (when `newKey.type ===
|
|
316
|
+
* 'passphrase'`). Default 600_000. Stored in the rewritten file.
|
|
317
|
+
*/
|
|
318
|
+
newPbkdf2Iterations?: number;
|
|
319
|
+
/** Override file IO (tests). */
|
|
320
|
+
io?: EncryptedFileIO;
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Re-encrypt the entire secrets file under a new master key. Reads
|
|
324
|
+
* every value with `oldKey`, writes them back encrypted under
|
|
325
|
+
* `newKey`. Atomic at the file layer (temp + rename); the old file
|
|
326
|
+
* remains intact if any step fails before the final write.
|
|
327
|
+
*
|
|
328
|
+
* Use cases:
|
|
329
|
+
* - master passphrase leak: rotate to a new passphrase
|
|
330
|
+
* - moving from passphrase to raw-bytes (or vice versa) — e.g.
|
|
331
|
+
* graduating from "operator-typed passphrase" to "key sourced
|
|
332
|
+
* from a vendor secret manager"
|
|
333
|
+
* - periodic master-key rotation as a compliance hygiene measure
|
|
334
|
+
*
|
|
335
|
+
* After this returns, any process still holding the old key
|
|
336
|
+
* will fail to decrypt on next access. Rotate the consumers'
|
|
337
|
+
* master-key references at the same time you call this.
|
|
338
|
+
*/
|
|
339
|
+
export declare const rotateMasterKey: (options: RotateMasterKeyOptions) => Promise<void>;
|
|
340
|
+
export declare const createSecretBroker: (options: SecretBrokerOptions) => SecretBroker;
|