@happyvertical/smrt-secrets
Per-tenant secret management with envelope encryption (AMK โ TDEK โ secret), key rotation, and audit logging.
Overview
smrt-secrets stores per-tenant secrets using a three-layer envelope encryption chain: an Application Master Key (AMK) from the environment wraps per-tenant Data Encryption Keys (TDEK), which in turn encrypt individual secret values. Every operation is audit-logged. The three models in this package deliberately deviate from the framework's default tenancy decorator โ see Known exceptions to monorepo standards below.
Installation
npm install @happyvertical/smrt-secretsRequires the SMRT_SECRET_MASTER_KEY environment variable (64 hex characters) as the
Application Master Key.
Quick Start
import { SecretService } from '@happyvertical/smrt-secrets';
import { withTenant } from '@happyvertical/smrt-tenancy';
// Create the service (reads AMK from env by default)
const service = await SecretService.create({ db });
await withTenant({ tenantId: 'tenant-123' }, async () => {
// Store a secret (upserts if name already exists)
await service.store('stripe-api-key', 'sk_live_xxx', {
category: 'api-keys',
description: 'Stripe production key',
expiresAt: new Date('2027-01-01'),
});
// Retrieve and decrypt (increments accessCount)
const { value, accessCount } = await service.retrieve('stripe-api-key');
// List secret names (values never included)
const secrets = await service.list({ category: 'api-keys' });
// Disable/enable without deleting
await service.disable('stripe-api-key');
await service.enable('stripe-api-key');
// Rotate the tenant's encryption key
await service.rotateKey();
// Re-encrypt all secrets with the new key (separate step)
await service.reencryptAll();
// Query audit logs
const logs = await service.getAuditLogs({ secretName: 'stripe-api-key' });
// Hard delete
await service.delete('stripe-api-key');
});Core Models
Secret
// @smrt({ tenantScoped: true, api: { include: [] }, mcp: { include: [] },
// cli: { include: ['list'], skipApiCheck: true } })
// Uses the inline tenantScoped form โ NOT the @TenantScoped decorator.
// SecretService.store() manually sets context = tenantId so the (slug, context)
// upsert key isolates secret names per tenant. See Known Exceptions below.
class Secret extends SmrtObject {
name: string
context?: string // set to tenantId by SecretService.store()
encryptedValue: string // JSON envelope (encrypted with TDEK)
category?: string
description?: string
status: 'active' | 'disabled' | 'expired'
expiresAt?: Date
accessCount: number
lastAccessedAt?: Date
// No API/MCP exposure (security); CLI list-only
}TenantKey
// @smrt({ api: { include: [] }, mcp: { include: [] },
// cli: { include: ['list', 'get'], skipApiCheck: true } })
// Deliberately NOT tenant-scoped at all. Carries a tenantId column because each
// TDEK belongs to a tenant, but key-rotation tooling and super-admin audits
// must query across tenants. See Known Exceptions below.
class TenantKey extends SmrtObject {
tenantId: string
wrappedKey: string // TDEK wrapped by AMK
keyVersion: number
status: 'active' | 'rotating' | 'retired' | 'compromised'
}SecretAuditLog
// @smrt({ tenantScoped: true, api: { include: [] }, mcp: { include: [] },
// cli: { include: ['list'], skipApiCheck: true } })
// Uses the inline tenantScoped form rather than the @TenantScoped decorator.
// Audit reads run in mixed contexts (tenant-scoped reports vs. super-admin
// compliance review). See Known Exceptions below.
class SecretAuditLog extends SmrtObject {
secretName: string
action: 'create' | 'read' | 'update' | 'delete' | 'rotate_key' | 'disable' | 'enable' | 'expire'
result: 'success' | 'failure' | 'denied'
userId?: string
ipAddress?: string
userAgent?: string
// Immutable: CLI list-only
}Known exceptions to monorepo standards
Per docs/content/standards.md ยง7, tenant-aware models should normally apply @TenantScoped({ mode: 'optional' }) from @happyvertical/smrt-tenancy. The three models in this
package deviate intentionally; each @smrt(...) block carries an inline comment pointing
back to this rationale.
Secret (src/models/Secret.ts) โ inline tenantScoped: true
Uses the inline tenantScoped: true form on @smrt() instead of the @TenantScoped decorator. SecretService.store() performs manual
scoping by populating context = tenantId on each row, so the (slug, context) upsert key from the base SmrtObject is what isolates
secret names per tenant. Switching to the decorator without rethinking the upsert key would surface
false-positive name collisions across tenants.
TenantKey (src/models/TenantKey.ts) โ NOT tenant-scoped
Deliberately not tenant-scoped at all. The row carries a tenantId column
because each TDEK belongs to a tenant, but key-rotation tooling, AMK rewrap jobs, and super-admin audits must query across tenants; the tenancy interceptor would silently filter rows those flows rely on. This is the same
reasoning that keeps Partner / Commission / Payout in smrt-affiliates out of the tenancy interceptor.
SecretAuditLog (src/models/SecretAuditLog.ts) โ inline tenantScoped: true
Uses the inline tenantScoped: true form rather than the decorator. Audit reads
run in mixed contexts (tenant-scoped reports vs. super-admin compliance review).
Cross-tenant audit queries should be wrapped in withSuperAdminBypass() from smrt-tenancy at the call site โ there are no such cross-tenant
call sites in this package today, but consumers building compliance tooling should adopt that
pattern explicitly rather than relying on decorator-implicit filtering.
Key Rotation
// Encryption chain:
// AMK (env var, 256-bit)
// -> wraps TDEK (per-tenant, auto-generated)
// -> encrypts secret value (stored as JSON envelope)
// Key rotation creates a new TDEK and retires the old one
await service.rotateKey();
// IMPORTANT: rotateKey() does NOT auto-re-encrypt secrets
// Retired keys are kept for decryption until re-encryption
await service.reencryptAll();
// Returns: { success: number, failed: number }
// TenantKey statuses:
// active - current encryption key
// rotating - transitional during rotation
// retired - kept for decryption of old secrets
// compromised - should not be used
// Cleanup retired keys after 90 days
// TenantKeyCollection.cleanupRetiredKeys()Context-free tenant access
store() / retrieve() read the ambient tenant from smrt-tenancy via requireTenantId(), so they
must run inside a withTenant() block. Integrations that have already resolved
tenant ownership but run outside the application's tenant context (background jobs,
webhooks, cross-tenant tooling) can pass the tenant explicitly with the *ForTenant variants, which simply wrap the call in withTenant() for
you.
// No ambient tenant context required โ the tenantId is explicit.
await service.storeForTenant('tenant-123', 'stripe-api-key', 'sk_live_xxx', {
category: 'api-keys',
});
const { value } = await service.retrieveForTenant('tenant-123', 'stripe-api-key');Key-drift diagnosis & repair
Envelope encryption has a failure mode: a secret value can outlive the tenant encryption key
that wraps it, or the configured Application Master Key can stop being able to unwrap a stored
key. SecretService exposes a non-destructive diagnosis and an explicit,
confirmation-gated repair for exactly this drift โ neither ever decrypts or exposes a
secret value.
// 1) Diagnose (read-only). Pass the tenant explicitly, or use the
// current-context variant inside a withTenant() block.
const report = await service.diagnoseTenantSecretKeyDrift('tenant-123');
// const report = await service.diagnoseCurrentTenantSecretKeyDrift();
// report.ok === true when no issues. Otherwise report.issues[] each carry a
// code, severity, message, and a suggested repairAction:
// 'delete-unrecoverable-secret'
// 'delete-unusable-tenant-encryption-key'
// 'store-fresh-secret-value'
// 'none'
// report.summary counts active secrets, tenant encryption keys, usable keys, etc.
// 2) Preview the repair without deleting anything.
const preview = await service.repairTenantSecretKeyDrift('tenant-123', {
dryRun: true,
});
// preview.wouldDeleteSecrets / preview.wouldDeleteTenantEncryptionKeys
// 3) Execute. Destructive deletes require an explicit confirmation flag.
const repaired = await service.repairTenantSecretKeyDrift('tenant-123', {
confirmDeleteUnrecoverableData: true,
});
// repaired.deletedSecrets / repaired.deletedTenantEncryptionKeys / remainingIssuesBest Practices
DOs
- Set
SMRT_SECRET_MASTER_KEYas a 64-character hex string - Call
reencryptAll()separately afterrotateKey() - Use categories to organize secrets logically
- Set expiration dates on time-sensitive credentials
- Monitor audit logs for unexpected access patterns
DON'Ts
- Don't assume
rotateKey()re-encrypts secrets automatically - Don't expose Secret or TenantKey models via API or MCP
- Don't skip the tenant context when storing/retrieving secrets
- Don't ignore that
retrieve()increments accessCount on every read - Don't hard-delete retired keys before calling
reencryptAll()