@absolutejs/secrets 0.7.0 → 0.7.2

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.
@@ -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;