@happyvertical/secrets
Envelope encryption SDK for per-tenant secret management with pluggable backends.
Installation
npm install @happyvertical/secrets
# or
pnpm add @happyvertical/secrets
Claude Code Context
Install Claude Code context files for AI-assisted development:
npx have-secrets-context
This copies the package's AGENT.md documentation and metadata.json metadata to your project's .claude/ directory, enabling Claude to provide better assistance when working with this package.
Overview
This package provides envelope encryption primitives for secure, per-tenant secret management. It uses a two-tier key hierarchy:
Application Master Key (AMK) - from environment variable
└── wraps → Tenant Data Encryption Keys (TDEKs) - per tenant, stored in DB
└── encrypts → Secret values (AES-256-GCM)
Quick Start
import { getSecretStore } from '@happyvertical/secrets';
import { getDatabase } from '@happyvertical/sql';
// Set up database and AMK
const db = await getDatabase({ type: 'sqlite', url: ':memory:' });
process.env.MY_SECRET_KEY = crypto.randomBytes(32).toString('hex');
// Create secret store
const store = await getSecretStore({
type: 'database',
db,
amk: {
provider: 'env',
keyEnvVar: 'MY_SECRET_KEY',
keyId: 'amk-v1',
},
});
// Create tenant key
await store.createTenantKey('tenant-123');
// Encrypt a secret
const envelope = await store.encrypt('tenant-123', 'api-key', 'synthetic-secret');
// Decrypt the secret
const { value } = await store.decrypt('tenant-123', envelope);
// Use value without logging or retaining plaintext.
Core Concepts
Envelope Encryption
Envelope encryption separates data encryption from key management:
- Application Master Key (AMK): A 32-byte key stored securely (env var, KMS, etc.)
- Tenant Data Encryption Keys (TDEKs): Per-tenant keys wrapped by the AMK
- Secret Values: Encrypted with tenant's TDEK using AES-256-GCM
This architecture enables:
- Per-tenant key isolation
- Key rotation without re-encrypting all secrets
- Secure key storage (only wrapped keys in database)
SecretStore Interface
interface SecretStore {
// Encrypt a secret for a tenant
encrypt(tenantId: string, secretName: string, plaintext: string): Promise<EncryptedEnvelope>;
// Decrypt a secret
decrypt(tenantId: string, envelope: EncryptedEnvelope): Promise<DecryptedSecret>;
// Tenant key management
getTenantKey(tenantId: string): Promise<TenantDataEncryptionKey | null>;
createTenantKey(tenantId: string): Promise<TenantDataEncryptionKey>;
rotateTenantKey(tenantId: string): Promise<TenantDataEncryptionKey>;
// Event subscription
subscribe(listener: SecretStoreEventListener): Unsubscribe;
}
API Reference
getSecretStore(options)
Factory function to create a secret store instance.
const store = await getSecretStore({
type: 'database',
db: databaseInstance,
keysTable: 'tenant_encryption_keys', // optional, default
amk: {
provider: 'env',
keyEnvVar: 'SMRT_SECRET_MASTER_KEY',
keyId: 'production-amk-v1',
},
});
EnvelopeEncryption
Low-level encryption primitives:
import { EnvelopeEncryption } from '@happyvertical/secrets';
// Generate a data key
const dataKey = EnvelopeEncryption.generateDataKey();
// Wrap the key with AMK
const wrapped = EnvelopeEncryption.wrapKey(dataKey, amk);
// Encrypt data
const encrypted = EnvelopeEncryption.encryptData('secret', dataKey);
// Decrypt data
const plaintext = EnvelopeEncryption.decryptData(
encrypted.ciphertext,
encrypted.iv,
encrypted.authTag,
dataKey,
);
Key Rotation
Rotate tenant keys without service interruption:
// Old keys are retained for decryption
const newKey = await store.rotateTenantKey('tenant-123');
// Old envelopes still decrypt (using retained key version)
const decrypted = await store.decrypt('tenant-123', oldEnvelope);
// New envelopes use the new key
const newEnvelope = await store.encrypt('tenant-123', 'new-secret', 'value');
Events
Subscribe to encryption/decryption events:
const unsubscribe = store.subscribe((event) => {
console.log(`${event.type} for tenant ${event.tenantId}`);
});
// Later: unsubscribe
unsubscribe();
Event types:
secret.encrypted- Secret was encryptedsecret.decrypted- Secret was decryptedkey.created- Tenant key was createdkey.rotated- Tenant key was rotated
Security Considerations
-
AMK Protection: Store the Application Master Key securely
- Use environment variables for simple deployments
- Use AWS KMS, HashiCorp Vault, or Azure Key Vault for production
-
Key Isolation: Each tenant has a unique TDEK
- Compromise of one tenant's data doesn't expose others
- Keys can be rotated independently
-
AES-256-GCM: Authenticated encryption
- Provides confidentiality and integrity
- 12-byte IV, 16-byte auth tag
-
Memory Safety: Key buffers are zeroed after use
- Reduces exposure window for sensitive key material
Error Handling
import {
AMKUnavailableError,
TenantKeyMissingError,
EncryptionError,
DecryptionError,
} from '@happyvertical/secrets';
try {
await store.encrypt('tenant-123', 'secret', 'value');
} catch (error) {
if (error instanceof AMKUnavailableError) {
// AMK not configured or inaccessible
} else if (error instanceof TenantKeyMissingError) {
// Tenant doesn't have a key - create one first
} else if (error instanceof EncryptionError) {
// Encryption failed
}
}
Credential custody orchestration
CredentialCustody coordinates a provider-neutral issuer, verifier, optional
secret sink, and receipt ledger. It supports two explicit modes:
ephemeralkeeps opaqueSecretMaterialin memory for a bounded lifetime, automatically revokes it at expiry, and never stores it in a sink.durablerequires aCredentialSecretSink, then completes issue → store → retrieve → verify before returning a lease. Any failure removes the stored value when present and revokes the issued credential.
import {
CredentialCustody,
type CredentialReceiptAttestor,
type CredentialIssuer,
type CredentialCustodyFinalizer,
type CredentialSecretSink,
type CredentialVerifier,
type CustodyLedger,
} from '@happyvertical/secrets';
const custody = new CredentialCustody({
issuer: myIssuer satisfies CredentialIssuer,
verifier: myVerifier satisfies CredentialVerifier,
sink: mySink satisfies CredentialSecretSink,
ledger: myLedger satisfies CustodyLedger,
attestor: myAttestor satisfies CredentialReceiptAttestor,
finalizer: myFinalizer satisfies CredentialCustodyFinalizer,
});
const lease = await custody.issue({
mode: 'durable',
subject: 'deployment-agent',
attribution: {
actor: 'automation',
runtime: 'scheduler',
session: 'job-123',
},
metadata: { purpose: 'repository-maintenance' },
});
await lease.withEnvironment('SERVICE_CREDENTIAL', async () => {
// The variable exists only for this callback and is restored afterward.
await runAuthenticatedOperation();
});
const child = await lease.withChildProcess({
trust: 'cooperative-process-group',
command: 'service-cli',
args: ['verify'],
environmentVariable: 'SERVICE_CREDENTIAL',
timeoutMs: 30_000,
});
The returned CustodyReceipt contains identifiers, attribution, verification
state, sink reference, rotation lineage, and an Ed25519 attestation only.
Plaintext is represented by
SecretMaterial, whose string, JSON, and inspection forms are always redacted.
Adapters can access it only inside SecretMaterial.use(...) and should avoid
copying or retaining the supplied string. Both SecretMaterial.use(...) and
withEnvironment(...) return Promise<void> so callback code cannot return
plaintext through the custody API. Environment callbacks are serialized and
always restore the prior value. Because JavaScript callbacks cannot be forcibly
cancelled, expiring credentials fail closed for withEnvironment(...); use
withChildProcess(...) for bounded execution.
Attestation binds every receipt field—including the stable sink tuple
sinkName, reference, version, and storedAt—to an internal SHA-256
commitment of the credential. The commitment is signed but is not included in
the receipt. CredentialCustodyOptions.attestor is required, so unsigned
issuance fails closed. Ed25519CustodyReceiptAttestor accepts a Node KeyObject
private key; remote signers can implement CredentialReceiptAttestor without
exposing their key.
Consumers verify a presented credential without issuer or sink access by
constructing its SecretMaterial and calling
verifyCustodyReceiptAttestation(receipt, material, publicKey). Resolve
receipt.attestation.keyId only through trusted configuration, never from
receipt-supplied key material. Import a trusted PEM/SPKI public key with Node's
createPublicKey(...); verification requires the resulting public KeyObject.
Always destroy the temporary SecretMaterial after verification.
The required CredentialCustodyFinalizer is a staged, idempotent transaction.
The signed receipt is first recorded as finalization-pending; prepare then
receives it with bounded SecretMaterial. The receipt binds the finalizer name
and SDK-generated finalizationId. commit activates the prepared record, and
the ledger atomically marks it issued while recording any predecessor cleanup.
Only then does rotation retire the predecessor. Cleanup failure keeps the new
credential active and is retried by recoverPendingRollbacks(). The finalizer's
status resolves crash ambiguity; abort and issuer/sink cleanup run
independently. Implement prepare, commit, abort, and status idempotently
by finalizationId. Fresh pending transactions are protected from concurrent
takeover for finalizationTakeoverMs (30 seconds by default); a one-shot
recovery call schedules takeover automatically at that deadline.
Prefer withChildProcess(...) for trusted command-line consumers. It requires
the explicit trust: 'cooperative-process-group' acknowledgement, injects the
credential into the child environment without mutating the parent, disables
shell interpretation, bounds runtime and captured output, owns a detached
process group so descendants are terminated, confirms that group is gone
before returning, and exact/token-redacts stdout and stderr. The trusted
command and every credential-bearing descendant must remain in that group;
daemonizing, calling setsid, or spawning a detached child violates this
boundary because portable POSIX process groups cannot contain a process that
creates a new session. Use only audited executables that honor this contract.
Process-group custody fails closed on Windows, where the required POSIX group
semantics are unavailable. If group cleanup cannot be verified, the lease is
immediately invalidated and finalizer/issuer/sink cleanup runs through the same
persisted, restart-safe rollback path used by failed issuance.
Use rotate(receiptId, request) to issue and verify a replacement before
retiring its predecessor. reconcile() compares active durable receipts with
the sink inventory; recoverOrphans(report) removes unowned sink records while
retaining attributable history in the ledger. Missing records can be replaced
through rotate, preserving replacesReceiptId and rotationRootReceiptId.
The ledger's recordIssuance operation atomically persists the receipt with a
finalization-pending event and rejects duplicate or branching replacements.
commitIssuance idempotently appends the issued event and, for rotation, the
retirement-pending event in the same transaction. Issuer revocation
and exact-version sink removal must be idempotent so failed cleanup can retry.
appendEvent is idempotent by eventId: exact replay succeeds, while binding
the same identifier to different content fails closed.
Transient finalizer-status or ledger-commit errors retain
finalization-pending state and retry ambiguity resolution; only an explicit
non-committed finalizer status triggers rollback. Sink identity comparison uses
the full attested sinkName, reference, version, and storedAt tuple.
Sinks must also implement idempotent removeByCredentialId(...) for ambiguous
store failures, and every inventory entry must identify its credential.
Orphan recovery re-runs reconciliation and applies a configurable age grace
before deleting an unchanged sink version. Failed pre-receipt rollback records a
non-secret pending event, retries automatically, and can be resumed after a
restart with recoverPendingRollbacks().
redactCredentialText and redactCredentialValues remove common bearer-token
shapes and known plaintext values from strings, structured outputs, errors, and
metadata. CustodyError serializes only its safe code, stage, redacted message,
and redacted details; underlying provider causes are intentionally not retained.
License
MIT