@happyvertical/smrt-secrets

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

v0.20.44Envelope EncryptionKey RotationAudit Trail

Overview

smrt-secrets provides per-tenant secret storage using a three-layer envelope encryption chain. An Application Master Key (AMK) from the environment wraps per-tenant Data Encryption Keys (TDEK), which encrypt individual secret values. Every operation is audit-logged.

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
class Secret extends SmrtObject {
  name: string
  encryptedValue: string      // JSON envelope (encrypted)
  category?: string
  description?: string
  status: 'active' | 'disabled' | 'expired'
  expiresAt?: Date
  accessCount: number
  lastAccessedAt?: Date

  // No API/MCP exposure (security)
  // CLI: list-only
}

TenantKey

typescript
class TenantKey extends SmrtObject {
  tenantId: string
  wrappedKey: string          // TDEK wrapped by AMK
  keyVersion: number
  status: 'active' | 'rotating' | 'retired' | 'compromised'

  // NOT tenant-scoped (tracks keys FOR tenants)
}

SecretAuditLog

typescript
class SecretAuditLog extends SmrtObject {
  secretName: string
  action: 'create' | 'read' | 'update' | 'delete' | 'rotate_key' | 'disable' | 'enable'
  result: 'success' | 'failure' | 'denied'
  userId?: string
  ipAddress?: string
  userAgent?: string

  // Immutable: CLI list-only
}

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()

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