Skip to main content

Secret rotations (crypto-key + provider-token rotation ledger)

Scope

One model, one module, five endpoints, one scheduled sweep — plus the two crypto primitives (SecretsCipher, PiiCipher) that consume the keys the ledger is meant to govern.

  • secret-rotations/SecretRotationsService (upsert / recordRotation / list / overdue / get) + controller (POST / PATCH /rotated / GET / GET /overdue / GET /:id).
  • SecretRotation Prisma modelcode (unique dotted namespace), category enum (general / bank_fetcher / payment_provider / sanctions / webhook / jwt), lastRotatedAt, rotatedBy, rotateAfterDays (default 90).
  • SecretRotationOverdueJob — daily 03:30 UTC sweep; on overdue codes it opens a low-severity BreachIncident.
  • SecretsCipher (src/common/security/secrets-cipher.ts) — AES-256-GCM field-level cipher, single-key, no kid.
  • PiiCipher (src/common/security/pii-cipher.ts) — HMAC hasher, multi-key via PII_HASH_KEYS JSON with active=true flag + activeKid() / hashAllKids().

Auto-registration is confined to two subsystems today: InsurerWebhooksService (per-insurer webhook secret) and BankStatementFetcher (provider API tokens). Everything else depends on ops manually calling POST /secret-rotations per env-var — which is precisely the coverage gap.

Compliance envelope

  • BOU cybersecurity framework §7 — regulated entities must rotate credentials on a defined cadence (90 days is the published guidance) and keep an audit trail of who rotated what, when. The ledger exists for exactly this; the gap is which secrets it covers and what it does when the SLA is breached.
  • DPPA 2019 §22 — the "appropriate technical measures" for PII processing include key management with rotation. The PiiCipher HMAC key that hashes every phone + email + national-ID for equality search is the highest-value key on the platform and is not registered in this ledger.
  • AML Act 2013 §14 — 7-year record integrity floor. A non-idempotent rotation-acknowledgement endpoint means a double-tap poisons the audit chain with two secret_rotation.recorded events for the same rotation.
  • ISO 27001 A.9 / A.10 — secret management, key management, dual-control on high-value operations. Only secret_rotation:write is required to acknowledge a rotation today; there is no dual-approval hook.

Current state (2026-07-18)

Module footprint

  • src/modules/secret-rotations/secret-rotations.module.ts — 253 lines. DTOs + service + controller + module in one file.
  • src/modules/scheduled-jobs/secret-rotation-overdue.job.ts — 88 lines. Daily 03:30 UTC.
  • Prisma model: 17-line SecretRotation — no deletedAt, no rotationReason, no secondApprovedBy, no compromiseDeclaredAt.

What works today

  • 90-day default SLA is BOU-aligned and enforced by the overdue sweep.
  • Actor capture: rotatedBy on recordRotation gives the audit trail a who.
  • Idempotent upsert on code — re-registering the same code from onModuleInit doesn't wipe the last-rotated stamp.
  • Overdue → BreachIncident wiring: a stale secret is auto-declared as a low-severity compliance breach, so the breach-notification pipeline picks it up even if nobody reads the ledger directly.
  • MfaSecretRotationReminderJob does dispatch a template to the affected user — proof that the notification wiring works elsewhere; just not for platform secrets.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-18)

Release secret-p1-20260718-1640. Closes gaps 1–4:

  • gap 1 — New SecretRotationsBootstrap.onModuleInit() auto- registers every long-lived env var whose value is set: jwt.access_secret, jwt.refresh_secret, secrets_key, pii_hash_keys, pii_hash_key (legacy), mfa_key, sanctions.provider_api_key, and per-provider payment tokens (MTN MoMo, Airtel, Flutterwave, Pesapal, Africa's Talking). Missing env vars are skipped so the ledger has no noise for unconfigured providers. Upsert is idempotent so boots preserve prior lastRotatedAt.
  • gap 2 — New policy codes security.jwt_rotation_days, security.secrets_key_rotation_days, security.pii_hash_rotation_days, security.mfa_key_rotation_days, security.provider_token_rotation_days, security.sanctions_provider_rotation_days (defaults 90 each). Bootstrap reads them to fill rotateAfterDays; ops can widen / narrow without a redeploy.
  • gap 3 — New POST /secret-rotations/:code/compromise endpoint gated by new secret_rotation:compromise permission (granted to super_admin + compliance_officer). Opens a high-severity BreachIncident (contrast the low-severity overdue one), stamps compromiseDeclaredAt / compromiseDeclaredBy / compromiseIncidentId on the ledger row, emits secret_rotation.compromised audit event. A subsequent recordRotation clears the stamps.
  • gap 4 — New recordRotationIdempotent() wrapper reuses the shared IdempotencyService. Controller passes Idempotency-Key header through PATCH /:code/rotated. Body mismatch → 409 IDEMPOTENCY_CONFLICT.

Migration 20260718160000_secret_rotations_phase1 — adds compromiseDeclaredAt, compromiseDeclaredBy, compromiseIncidentId to secret_rotations + indexes.

Locked in by src/modules/secret-rotations/secret-rotations-phase1.spec.ts (6 cases). Test suite: 527/527 (77 suites).

Phase 2 shipped (2026-07-18)

Release secret-p2-20260718-1733. Closes gaps 5–8:

  • gap 5 — SecretsCipher rewritten to support a multi-key window: SECRETS_KEYS JSON array ([{ kid, key, active }]) mirrors the PiiCipher shape. New writes prefix the ciphertext with kid. so decrypt() picks the correct key on read; legacy prefix-less ciphertext falls back to trying every registered key. Zero-downtime rotation is now architecturally possible + backward-compatible with the single-key SECRETS_KEY fallback + the JWT-secret legacy-legacy fallback.
  • gap 6 — SecretRotationOverdueJob injects @Optional() RoleFanoutService and, on every new overdue incident, fans out secret.rotation.overdue to compliance officers. Best-effort — template lookup failures are logged and swallowed so a template mismatch doesn't block the incident write.
  • gap 7 — Migration 20260718170000_secret_rotations_phase2 adds rotationStatus (soft enum rotated / pending_second_approval) + secondApprovedBy + secondApprovedAt. New security.secret_rotation_dual_approval_categories policy (default jwt,general) drives which categories require two distinct actors. First recordRotation on a dual category flips to pending_second_approval (does NOT touch lastRotatedAt) + emits secret_rotation.recorded.first_approval; second distinct-actor call stamps lastRotatedAt + flips to rotated. Same-actor second call → 409.
  • gap 8 — Migration adds deletedAt + index. New DELETE /secret-rotations/:id endpoint gated by secret_rotation:write. Soft-deletes stamp deletedAt + emit secret_rotation.deleted audit event. list(), get(), overdue(), and recordRotation() all filter deletedAt: null — soft-deleted rows no longer surface in the ledger or trigger overdue breach incidents.

Locked in by src/modules/secret-rotations/secret-rotations-phase2.spec.ts (8 cases). Test suite: 535/535 (78 suites).

Phase 3 shipped (2026-07-18)

Release secret-p3-20260718-1749. Closes gaps 9–12:

  • gap 9 — @Throttle on every mutation: POST /secret-rotations 10/min, PATCH /:code/rotated 20/min, POST /:code/compromise 5/min (compromises are rare + high-value), DELETE /:id 20/min. GET endpoints intentionally unthrottled — compliance dashboards poll.
  • gap 10 — New GET /secret-rotations/report/attestation?days=N endpoint (window clamped to 1-3650, default 90). Returns { generatedAt, windowDays, rotated: [...], overdue: [...] } with rotator + secondApprover + rotationReason + rotationStatus per row.
  • gap 11 — Migration 20260718180000_secret_rotations_phase3 adds rotationReason soft-enum column (scheduled | compromise | audit_remediation | provider_reissued | other). RecordRotationDto accepts the field; both dual + non-dual recordRotation paths persist it.
  • gap 12 — Migration adds retentionScrubbedAt + index. New data_sharing.secret_rotations_retention_days policy (default 3650 = 10 y — one cycle beyond AML §14) + data_sharing.secret_rotations_soft_deleted_retention_days (default 2555 = 7 y for soft-deleted rows). RetentionPurgeJob scrubs description / notes past window + emits secret_rotation.retention.pii_scrubbed.

Locked in by src/modules/secret-rotations/secret-rotations-phase3.spec.ts (2 cases). Test suite: 537/537 (79 suites).

All 12 gaps closed. Secret-rotations module is audit-clean against BOU cybersecurity §7, DPPA §22 + §17, AML §14, and ISO 27001 A.9 + A.10 baselines.


Phased implementation plan

Complete — every phase shipped.

Each phase deploys to prod + sandbox at 157.173.99.48 and ships a locked spec suite. Success gate: all 12 gaps closed for the module.