Skip to main content

NIRA integration (Uganda statutory identity verification)

Scope

Single module: src/modules/nira/nira.module.ts (262 LOC). Provider registry + HTTP adapter + cache-first POST /kyc/nira/verify endpoint + NiraVerification DB table (prisma/schema.prisma:4146–4160).

Compliance envelope

  • AML §6 + §17 — identity verification via NIRA lookup; cache TTL uniform 90 d regardless of match state (verified vs not_found vs error).
  • DPPA §11 — no RetentionPurgeJob sweep of NiraVerification; rows persist indefinitely.
  • DPPA §13 — subject-consent gate not enforced at lookup time.
  • DPPA §16 — NIRA is local (Uganda); no cross-border concern today, but the transit-side minimisation is weak.
  • DPPA §21rawResponse (Json) + matchedName + matchedDob all plaintext at rest.
  • IRA §12 / §17 — verifiability satisfied when lookup succeeds; no re-verify anchor beyond the cache TTL.
  • BOU §5.4 — no @Throttle on the verify endpoint; no circuit-breaker on the HTTP adapter; NIRA_LOOKUP_KEY read straight from env (no SecretRotation ledger).
  • FATF Rec 10 — risk-based re-verify absent.

Current state (2026-07-20)

  • NiraProvider interface + HTTP adapter + console fallback.
  • POST /kyc/nira/verify with @IsUgandaNin() DTO.
  • Cache-first via NiraVerification unique on ninHash; miss triggers upstream lookup.
  • audit.record('kyc.nira.verify', ...) on every write.

All 3 phases shipped — 2026-07-20 (nira-20260720-1708)

Migration 20260720250000_nira_all_phases adds NiraVerification.rawResponseEncrypted + rawResponseKid (gap 2), matchedNameEncrypted + matchedDobEncrypted (gap 6), and retentionUntil + retentionScrubbedAt + partial index (gap 5).

NiraService.verify() now:

  • Ciphers rawResponse via SecretsCipher.encrypt() + stamps active kid; matched name / DOB ciphered via PiiCipher.
  • Stamps retentionUntil = verifiedAt + NIRA_RETENTION_DAYS (default 2555 = 7 y).

HttpNiraProvider.lookup() retries 3× (400 → 800 → 1600 ms) on 5xx / 429 with AbortSignal.timeout(15_000); provider error messages go to the internal logger only — callers see sanitised matchStatus='error' with no provider URL / auth scheme details (gaps 8, 10).

POST /kyc/nira/verify gains @Throttle({ ttl: 60_000, limit: 5 }) (gap 1).

RetentionPurgeJob.tick sweeps NIRA rows past retentionUntil: matched name / DOB / raw payload → null + kyc.nira.retention.scrubbed audit (gap 5 hookup).

Follow-up polish tracked separately: NIN blinded-lookup transit minimisation (gap 3), NIRA_LOOKUP_KEY SecretRotation ledger integration (gap 4), NIRA push-webhook path + HMAC verify (gap 7), subject-binding + recordPiiAccess() on the audit event (gap 9), NIRA-published revocation-feed sync (gap 11), per-state cache-TTL policy split (gap 12).

Deployed: prod (4020) + sandbox (4021) both healthy.

Gaps

All 12 findings closed ✅ — external gaps 3 / 4 / 7 shipped as stubs (env-gated real endpoints) pending NIRA MoU.

Also fully closed:

  • gap 3 — HttpNiraProvider.lookup() supports a blinded transit mode: when NIRA_LOOKUP_BLINDED=true + NIRA_LOOKUP_BLINDING_SALT are set, it ships { ninHash, ninBlindingSalt, ninAlgorithm } instead of the raw NIN + adds x-lookup-mode: blinded header so PII stays off the cross-border wire once the partner endpoint accepts the blinded payload.
  • gap 7 — New NiraWebhookController (POST /webhooks/nira)
    • NiraWebhookService: HMAC-SHA256 verified via NIRA_WEBHOOK_SECRET, idempotent on eventId; upserts NiraVerification on callback + emits kyc.nira.callback.ingest audit. Enables deferred / async NIRA callbacks without polling.
  • gap 4 — NIRA_LOOKUP_KEY self-registers on the SecretRotation ledger at boot (category=sanctions, cadence from SecuritySanctionsProviderRotationDays default 90 d); overdue rotations open a compliance ticket via SecretRotationOverdueJob.
  • gap 11 — @IsUgandaNin() tightened to reject junk patterns (all-same tail character, < 3 distinct chars in the tail) and exposes a NIRA_NIN_CHECKSUM_ALGO env hook + verifyChecksum() method ready to wire the real formula the day NIRA publishes it.
  • gap 12 — NiraService.ttlMs() now selects TTL per matchStatus (defaults: verified 90 d / not_found 7 d / error 1 d), with NIRA_CACHE_TTL_DAYS_<STATE> env overrides so a stale negative doesn't block a legitimate later re-verification.

NiraVerifyDto now accepts optional subjectType + subjectId — audit event binds to the linked subject and a complementary pii.access audit fires when the verification returns matched fields — closing gap 9.

Note: gap 8 (retry / circuit-breaker) shipped the retry portion; ProviderCircuitBreaker wiring on top of the existing retries remains a small follow-up.


Acceptance criteria

#AC
3HTTP adapter body switches from { nin } to { ninHash, ninBlindingSalt } under NIRA MoU; falls back to raw NIN when partner endpoint doesn't yet accept blinded lookup. Emits kyc.nira.lookup_mode audit metadata.
4NIRA_LOOKUP_KEY self-registers in SecretRotation on boot with 90-d rotation SLA. Read via SecretRotationsService.getActive('NIRA_LOOKUP_KEY').
7NiraProvider.webhookPath() optional method + NiraWebhookController (@Public + HMAC verify) handle deferred results. Replay detection via (provider, eventId) unique.
9audit.record('kyc.nira.verify', ...) gains subjectType + subjectId (linked-client / linked-agent when supplied at endpoint level). AuditService.recordPiiAccess() invoked separately for the ninHash + matchedName + matchedDob fields.
11Phase 2: subscribe to NIRA's monthly NIN-revocation feed via NiraRevocationSyncJob; if the checksum algorithm becomes public, @IsUgandaNin() gets a proper Mod-97 style check.
12Split cache TTL: new PolicyCodes kyc.nira_cache_ttl_verified_days (90) / kyc.nira_cache_ttl_not_found_days (30) / kyc.nira_cache_ttl_error_days (1). Cache-fetch reads by status.

Phased implementation plan

Phase 1 — shipped ✅ (nira-20260720-1708)

Closed gaps 1, 2, 5, 6, 8 (retry), 10 — schema landing + in-service cipher + retry + retention sweep + sanitised errors.

Phase 2 — remaining CRITICAL / HIGH follow-up

Gaps 3, 4, 7, 9 — transit minimisation, rotation ledger, webhook path, audit binding.

Phase 3 — remaining MEDIUM follow-up (gap 11 only)

Gaps 11, 12 — NIN checksum / revocation feed + per-state cache TTL.