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).SecretRotationPrisma model —code(unique dotted namespace),categoryenum (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-severityBreachIncident.SecretsCipher(src/common/security/secrets-cipher.ts) — AES-256-GCM field-level cipher, single-key, nokid.PiiCipher(src/common/security/pii-cipher.ts) — HMAC hasher, multi-key viaPII_HASH_KEYSJSON withactive=trueflag +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
PiiCipherHMAC 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.recordedevents for the same rotation. - ISO 27001 A.9 / A.10 — secret management, key management,
dual-control on high-value operations. Only
secret_rotation:writeis 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— nodeletedAt, norotationReason, nosecondApprovedBy, nocompromiseDeclaredAt.
What works today
- 90-day default SLA is BOU-aligned and enforced by the overdue sweep.
- Actor capture:
rotatedByonrecordRotationgives the audit trail a who. - Idempotent upsert on
code— re-registering the same code fromonModuleInitdoesn'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.
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 priorlastRotatedAt. - 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 fillrotateAfterDays; ops can widen / narrow without a redeploy. - gap 3 — New
POST /secret-rotations/:code/compromiseendpoint gated by newsecret_rotation:compromisepermission (granted to super_admin + compliance_officer). Opens a high-severityBreachIncident(contrast the low-severity overdue one), stampscompromiseDeclaredAt/compromiseDeclaredBy/compromiseIncidentIdon the ledger row, emitssecret_rotation.compromisedaudit event. A subsequentrecordRotationclears the stamps. - gap 4 — New
recordRotationIdempotent()wrapper reuses the sharedIdempotencyService. Controller passesIdempotency-Keyheader throughPATCH /:code/rotated. Body mismatch → 409IDEMPOTENCY_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).
Release secret-p2-20260718-1733. Closes gaps 5–8:
- gap 5 —
SecretsCipherrewritten to support a multi-key window:SECRETS_KEYSJSON array ([{ kid, key, active }]) mirrors thePiiCiphershape. New writes prefix the ciphertext withkid.sodecrypt()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-keySECRETS_KEYfallback + the JWT-secret legacy-legacy fallback. - gap 6 —
SecretRotationOverdueJobinjects@Optional() RoleFanoutServiceand, on every new overdue incident, fans outsecret.rotation.overdueto 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_phase2addsrotationStatus(soft enumrotated/pending_second_approval) +secondApprovedBy+secondApprovedAt. Newsecurity.secret_rotation_dual_approval_categoriespolicy (defaultjwt,general) drives which categories require two distinct actors. FirstrecordRotationon a dual category flips topending_second_approval(does NOT touchlastRotatedAt) + emitssecret_rotation.recorded.first_approval; second distinct-actor call stampslastRotatedAt+ flips torotated. Same-actor second call → 409. - gap 8 — Migration adds
deletedAt+ index. NewDELETE /secret-rotations/:idendpoint gated bysecret_rotation:write. Soft-deletes stampdeletedAt+ emitsecret_rotation.deletedaudit event.list(),get(),overdue(), andrecordRotation()all filterdeletedAt: 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).
Release secret-p3-20260718-1749. Closes gaps 9–12:
- gap 9 —
@Throttleon every mutation:POST /secret-rotations10/min,PATCH /:code/rotated20/min,POST /:code/compromise5/min (compromises are rare + high-value),DELETE /:id20/min. GET endpoints intentionally unthrottled — compliance dashboards poll. - gap 10 — New
GET /secret-rotations/report/attestation?days=Nendpoint (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_phase3addsrotationReasonsoft-enum column (scheduled|compromise|audit_remediation|provider_reissued|other).RecordRotationDtoaccepts the field; both dual + non-dualrecordRotationpaths persist it. - gap 12 — Migration adds
retentionScrubbedAt+ index. Newdata_sharing.secret_rotations_retention_dayspolicy (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).RetentionPurgeJobscrubsdescription/notespast window + emitssecret_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.