@happyvertical/smrt-secrets

Per-tenant secret management with envelope encryption (AMK โ†’ TDEK โ†’ secret), key rotation, and audit logging.

v0.29.34Envelope EncryptionKey RotationAudit TrailTenancy Exception

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

bash
npm install @happyvertical/smrt-secrets

Requires the SMRT_SECRET_MASTER_KEY environment variable (64 hex characters) as the Application Master Key.

Quick Start

typescript
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

typescript
// @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

typescript
// @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

typescript
// @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

typescript
// 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.

typescript
// 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.

typescript
// 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 / remainingIssues

Best Practices

DOs

  • Set SMRT_SECRET_MASTER_KEY as a 64-character hex string
  • Call reencryptAll() separately after rotateKey()
  • 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()

Related Modules