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_foundvserror). - DPPA §11 — no
RetentionPurgeJobsweep ofNiraVerification; 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 §21 —
rawResponse(Json) +matchedName+matchedDoball plaintext at rest. - IRA §12 / §17 — verifiability satisfied when lookup succeeds; no re-verify anchor beyond the cache TTL.
- BOU §5.4 — no
@Throttleon the verify endpoint; no circuit-breaker on the HTTP adapter;NIRA_LOOKUP_KEYread straight from env (no SecretRotation ledger). - FATF Rec 10 — risk-based re-verify absent.
Current state (2026-07-20)
NiraProviderinterface + HTTP adapter + console fallback.POST /kyc/nira/verifywith@IsUgandaNin()DTO.- Cache-first via
NiraVerificationunique onninHash; miss triggers upstream lookup. audit.record('kyc.nira.verify', ...)on every write.
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
rawResponseviaSecretsCipher.encrypt()+ stamps activekid; matched name / DOB ciphered viaPiiCipher. - 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: whenNIRA_LOOKUP_BLINDED=true+NIRA_LOOKUP_BLINDING_SALTare set, it ships{ ninHash, ninBlindingSalt, ninAlgorithm }instead of the raw NIN + addsx-lookup-mode: blindedheader 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 viaNIRA_WEBHOOK_SECRET, idempotent oneventId; upsertsNiraVerificationon callback + emitskyc.nira.callback.ingestaudit. Enables deferred / async NIRA callbacks without polling.
- gap 4 —
NIRA_LOOKUP_KEYself-registers on theSecretRotationledger at boot (category=sanctions, cadence fromSecuritySanctionsProviderRotationDaysdefault 90 d); overdue rotations open a compliance ticket viaSecretRotationOverdueJob. - gap 11 —
@IsUgandaNin()tightened to reject junk patterns (all-same tail character, < 3 distinct chars in the tail) and exposes aNIRA_NIN_CHECKSUM_ALGOenv hook +verifyChecksum()method ready to wire the real formula the day NIRA publishes it. - gap 12 —
NiraService.ttlMs()now selects TTL permatchStatus(defaults: verified 90 d / not_found 7 d / error 1 d), withNIRA_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 |
|---|---|
| 3 | HTTP 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. |
| 4 | NIRA_LOOKUP_KEY self-registers in SecretRotation on boot with 90-d rotation SLA. Read via SecretRotationsService.getActive('NIRA_LOOKUP_KEY'). |
| 7 | NiraProvider.webhookPath() optional method + NiraWebhookController (@Public + HMAC verify) handle deferred results. Replay detection via (provider, eventId) unique. |
| 9 | audit.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. |
| 11 | Phase 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. |
| 12 | Split 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.