@happyvertical/secrets 0.87.0 → 0.88.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/README.md CHANGED
@@ -55,11 +55,11 @@ const store = await getSecretStore({
55
55
  await store.createTenantKey('tenant-123');
56
56
 
57
57
  // Encrypt a secret
58
- const envelope = await store.encrypt('tenant-123', 'api-key', 'sk_live_xxx');
58
+ const envelope = await store.encrypt('tenant-123', 'api-key', 'synthetic-secret');
59
59
 
60
60
  // Decrypt the secret
61
61
  const { value } = await store.decrypt('tenant-123', envelope);
62
- console.log(value); // 'sk_live_xxx'
62
+ // Use value without logging or retaining plaintext.
63
63
  ```
64
64
 
65
65
  ## Core Concepts
@@ -215,6 +215,146 @@ try {
215
215
  }
216
216
  ```
217
217
 
218
+ ## Credential custody orchestration
219
+
220
+ `CredentialCustody` coordinates a provider-neutral issuer, verifier, optional
221
+ secret sink, and receipt ledger. It supports two explicit modes:
222
+
223
+ - `ephemeral` keeps opaque `SecretMaterial` in memory for a bounded lifetime,
224
+ automatically revokes it at expiry, and never stores it in a sink.
225
+ - `durable` requires a `CredentialSecretSink`, then completes
226
+ issue → store → retrieve → verify before returning a lease. Any failure
227
+ removes the stored value when present and revokes the issued credential.
228
+
229
+ ```typescript
230
+ import {
231
+ CredentialCustody,
232
+ type CredentialReceiptAttestor,
233
+ type CredentialIssuer,
234
+ type CredentialCustodyFinalizer,
235
+ type CredentialSecretSink,
236
+ type CredentialVerifier,
237
+ type CustodyLedger,
238
+ } from '@happyvertical/secrets';
239
+
240
+ const custody = new CredentialCustody({
241
+ issuer: myIssuer satisfies CredentialIssuer,
242
+ verifier: myVerifier satisfies CredentialVerifier,
243
+ sink: mySink satisfies CredentialSecretSink,
244
+ ledger: myLedger satisfies CustodyLedger,
245
+ attestor: myAttestor satisfies CredentialReceiptAttestor,
246
+ finalizer: myFinalizer satisfies CredentialCustodyFinalizer,
247
+ });
248
+
249
+ const lease = await custody.issue({
250
+ mode: 'durable',
251
+ subject: 'deployment-agent',
252
+ attribution: {
253
+ actor: 'automation',
254
+ runtime: 'scheduler',
255
+ session: 'job-123',
256
+ },
257
+ metadata: { purpose: 'repository-maintenance' },
258
+ });
259
+
260
+ await lease.withEnvironment('SERVICE_CREDENTIAL', async () => {
261
+ // The variable exists only for this callback and is restored afterward.
262
+ await runAuthenticatedOperation();
263
+ });
264
+
265
+ const child = await lease.withChildProcess({
266
+ trust: 'cooperative-process-group',
267
+ command: 'service-cli',
268
+ args: ['verify'],
269
+ environmentVariable: 'SERVICE_CREDENTIAL',
270
+ timeoutMs: 30_000,
271
+ });
272
+ ```
273
+
274
+ The returned `CustodyReceipt` contains identifiers, attribution, verification
275
+ state, sink reference, rotation lineage, and an Ed25519 attestation only.
276
+ Plaintext is represented by
277
+ `SecretMaterial`, whose string, JSON, and inspection forms are always redacted.
278
+ Adapters can access it only inside `SecretMaterial.use(...)` and should avoid
279
+ copying or retaining the supplied string. Both `SecretMaterial.use(...)` and
280
+ `withEnvironment(...)` return `Promise<void>` so callback code cannot return
281
+ plaintext through the custody API. Environment callbacks are serialized and
282
+ always restore the prior value. Because JavaScript callbacks cannot be forcibly
283
+ cancelled, expiring credentials fail closed for `withEnvironment(...)`; use
284
+ `withChildProcess(...)` for bounded execution.
285
+
286
+ Attestation binds every receipt field—including the stable sink tuple
287
+ `sinkName`, `reference`, `version`, and `storedAt`—to an internal SHA-256
288
+ commitment of the credential. The commitment is signed but is not included in
289
+ the receipt. `CredentialCustodyOptions.attestor` is required, so unsigned
290
+ issuance fails closed. `Ed25519CustodyReceiptAttestor` accepts a Node `KeyObject`
291
+ private key; remote signers can implement `CredentialReceiptAttestor` without
292
+ exposing their key.
293
+
294
+ Consumers verify a presented credential without issuer or sink access by
295
+ constructing its `SecretMaterial` and calling
296
+ `verifyCustodyReceiptAttestation(receipt, material, publicKey)`. Resolve
297
+ `receipt.attestation.keyId` only through trusted configuration, never from
298
+ receipt-supplied key material. Import a trusted PEM/SPKI public key with Node's
299
+ `createPublicKey(...)`; verification requires the resulting public `KeyObject`.
300
+ Always destroy the temporary `SecretMaterial` after verification.
301
+
302
+ The required `CredentialCustodyFinalizer` is a staged, idempotent transaction.
303
+ The signed receipt is first recorded as `finalization-pending`; `prepare` then
304
+ receives it with bounded `SecretMaterial`. The receipt binds the finalizer name
305
+ and SDK-generated `finalizationId`. `commit` activates the prepared record, and
306
+ the ledger atomically marks it issued while recording any predecessor cleanup.
307
+ Only then does rotation retire the predecessor. Cleanup failure keeps the new
308
+ credential active and is retried by `recoverPendingRollbacks()`. The finalizer's
309
+ `status` resolves crash ambiguity; `abort` and issuer/sink cleanup run
310
+ independently. Implement `prepare`, `commit`, `abort`, and `status` idempotently
311
+ by `finalizationId`. Fresh pending transactions are protected from concurrent
312
+ takeover for `finalizationTakeoverMs` (30 seconds by default); a one-shot
313
+ recovery call schedules takeover automatically at that deadline.
314
+
315
+ Prefer `withChildProcess(...)` for trusted command-line consumers. It requires
316
+ the explicit `trust: 'cooperative-process-group'` acknowledgement, injects the
317
+ credential into the child environment without mutating the parent, disables
318
+ shell interpretation, bounds runtime and captured output, owns a detached
319
+ process group so descendants are terminated, confirms that group is gone
320
+ before returning, and exact/token-redacts stdout and stderr. The trusted
321
+ command and every credential-bearing descendant must remain in that group;
322
+ daemonizing, calling `setsid`, or spawning a detached child violates this
323
+ boundary because portable POSIX process groups cannot contain a process that
324
+ creates a new session. Use only audited executables that honor this contract.
325
+ Process-group custody fails closed on Windows, where the required POSIX group
326
+ semantics are unavailable. If group cleanup cannot be verified, the lease is
327
+ immediately invalidated and finalizer/issuer/sink cleanup runs through the same
328
+ persisted, restart-safe rollback path used by failed issuance.
329
+
330
+ Use `rotate(receiptId, request)` to issue and verify a replacement before
331
+ retiring its predecessor. `reconcile()` compares active durable receipts with
332
+ the sink inventory; `recoverOrphans(report)` removes unowned sink records while
333
+ retaining attributable history in the ledger. Missing records can be replaced
334
+ through `rotate`, preserving `replacesReceiptId` and `rotationRootReceiptId`.
335
+ The ledger's `recordIssuance` operation atomically persists the receipt with a
336
+ `finalization-pending` event and rejects duplicate or branching replacements.
337
+ `commitIssuance` idempotently appends the `issued` event and, for rotation, the
338
+ `retirement-pending` event in the same transaction. Issuer revocation
339
+ and exact-version sink removal must be idempotent so failed cleanup can retry.
340
+ `appendEvent` is idempotent by `eventId`: exact replay succeeds, while binding
341
+ the same identifier to different content fails closed.
342
+ Transient finalizer-status or ledger-commit errors retain
343
+ `finalization-pending` state and retry ambiguity resolution; only an explicit
344
+ non-committed finalizer status triggers rollback. Sink identity comparison uses
345
+ the full attested `sinkName`, `reference`, `version`, and `storedAt` tuple.
346
+ Sinks must also implement idempotent `removeByCredentialId(...)` for ambiguous
347
+ store failures, and every inventory entry must identify its credential.
348
+ Orphan recovery re-runs reconciliation and applies a configurable age grace
349
+ before deleting an unchanged sink version. Failed pre-receipt rollback records a
350
+ non-secret pending event, retries automatically, and can be resumed after a
351
+ restart with `recoverPendingRollbacks()`.
352
+
353
+ `redactCredentialText` and `redactCredentialValues` remove common bearer-token
354
+ shapes and known plaintext values from strings, structured outputs, errors, and
355
+ metadata. `CustodyError` serializes only its safe code, stage, redacted message,
356
+ and redacted details; underlying provider causes are intentionally not retained.
357
+
218
358
  ## License
219
359
 
220
360
  MIT
@@ -22,7 +22,7 @@ import { ApplicationMasterKey, DatabaseSecretStoreOptions, DecryptedSecret, Encr
22
22
  * await store.initialize();
23
23
  *
24
24
  * // Encrypt a secret for a tenant
25
- * const envelope = await store.encrypt('tenant-123', 'api-key', 'sk_live_xxx');
25
+ * const envelope = await store.encrypt('tenant-123', 'api-key', 'synthetic-secret');
26
26
  *
27
27
  * // Decrypt
28
28
  * const { value } = await store.decrypt('tenant-123', envelope);
package/dist/index.d.ts CHANGED
@@ -23,11 +23,11 @@
23
23
  * });
24
24
  *
25
25
  * // Encrypt a secret for a tenant
26
- * const envelope = await store.encrypt('tenant-123', 'api-key', 'sk_live_xxx');
26
+ * const envelope = await store.encrypt('tenant-123', 'api-key', 'synthetic-secret');
27
27
  *
28
28
  * // Decrypt the secret
29
29
  * const { value } = await store.decrypt('tenant-123', envelope);
30
- * console.log(value); // 'sk_live_xxx'
30
+ * // Use value without logging or retaining plaintext.
31
31
  *
32
32
  * // Rotate tenant's encryption key
33
33
  * await store.rotateTenantKey('tenant-123');
@@ -36,6 +36,8 @@
36
36
  * @packageDocumentation
37
37
  */
38
38
  export { DatabaseSecretStore } from './adapters/database.js';
39
+ export type { CredentialChildProcessOptions, CredentialChildProcessResult, CredentialCustodyFinalizer, CredentialCustodyOptions, CredentialIssuanceMode, CredentialIssueRequest, CredentialIssuer, CredentialLease, CredentialReceiptAttestor, CredentialSecretSink, CredentialVerifier, CustodyAttribution, CustodyEvent, CustodyEventType, CustodyIssuanceRequest, CustodyLedger, CustodyReceipt, CustodyReceiptAttestation, CustodyReconciliation, CustodyStage, IssuedCredential, SecretSinkInventoryEntry, SecretSinkRecord, } from './shared/custody.js';
40
+ export { CredentialCustody, CustodyError, Ed25519CustodyReceiptAttestor, InMemoryCustodyLedger, redactCredentialText, redactCredentialValues, runCredentialChildProcess, SecretMaterial, verifyCustodyReceiptAttestation, withEnvironmentSecret, } from './shared/custody.js';
39
41
  export { EnvelopeEncryption } from './shared/envelope.js';
40
42
  export { AMKUnavailableError, DecryptionError, EncryptionError, InvalidKeyFormatError, KeyNotFoundError, KeyRotationError, SecretError, StoreNotInitializedError, TenantKeyMissingError, } from './shared/errors.js';
41
43
  export { getSecretStore, isAWSKMSOptions, isAzureKeyVaultOptions, isDatabaseOptions, isVaultOptions, } from './shared/factory.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAGH,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EACL,cAAc,EACd,eAAe,EACf,sBAAsB,EACtB,iBAAiB,EACjB,cAAc,GACf,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EAEV,SAAS,EACT,oBAAoB,EACpB,wBAAwB,EACxB,+BAA+B,EAC/B,0BAA0B,EAC1B,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,uBAAuB,EACvB,WAAW,EACX,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,gBAAgB;AAChB,eAAO,MAAM,2BAA2B,OAAO,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAGH,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EACV,6BAA6B,EAC7B,4BAA4B,EAC5B,0BAA0B,EAC1B,wBAAwB,EACxB,sBAAsB,EACtB,sBAAsB,EACtB,gBAAgB,EAChB,eAAe,EACf,yBAAyB,EACzB,oBAAoB,EACpB,kBAAkB,EAClB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,sBAAsB,EACtB,aAAa,EACb,cAAc,EACd,yBAAyB,EACzB,qBAAqB,EACrB,YAAY,EACZ,gBAAgB,EAChB,wBAAwB,EACxB,gBAAgB,GACjB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,iBAAiB,EACjB,YAAY,EACZ,6BAA6B,EAC7B,qBAAqB,EACrB,oBAAoB,EACpB,sBAAsB,EACtB,yBAAyB,EACzB,cAAc,EACd,+BAA+B,EAC/B,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,WAAW,EACX,wBAAwB,EACxB,qBAAqB,GACtB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,cAAc,EACd,eAAe,EACf,sBAAsB,EACtB,iBAAiB,EACjB,cAAc,GACf,MAAM,qBAAqB,CAAC;AAE7B,YAAY,EAEV,SAAS,EACT,oBAAoB,EACpB,wBAAwB,EACxB,+BAA+B,EAC/B,0BAA0B,EAC1B,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,WAAW,EACX,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,uBAAuB,EACvB,WAAW,EACX,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,gBAAgB;AAChB,eAAO,MAAM,2BAA2B,OAAO,CAAC"}