From 5fbd13ba12808de3fac41774f032a53ce31e3a0f Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Wed, 30 Sep 2026 18:41:03 +0530 Subject: [PATCH] Add TPIN reset via OTP, with server-clock countdowns and redacted API logs - Reset TPIN: empty-body initiate -> OTP -> encrypted new TPIN via /tpin/reset/*, from a Services tile, search, and "Forgot your TPIN?" on the transfer's TPIN sheet (returns to the same transfer review) - Handle every documented code: SESSION_INVALID signs out, TPIN_NOT_SET offers Create TPIN, CBS customer/account/org refusals stop the flow, outages retry from the start, expired or spent OTP/token restarts, policy VALIDATION_ERROR stays on the new-TPIN step - Secrets live in screen state only and are dropped as each step ends - Countdowns use the envelope timestamp, kept aside by the interceptor, so a wrong device clock cannot skew OTP or token expiry - New TPINs refuse any three-digit run (1235, 5987), for create too - Dev API logger redacts the session token, OTPs, PINs, encrypted payloads and one-time tokens - Tests: request bodies, RSA-OAEP SHA-256 payload decrypted with Node, fresh nonce on key retry, server-clock deadlines, log redaction Co-Authored-By: Claude Opus 5.5 --- src/constants/featureKeys.ts | 1 + src/features/featureConfig.ts | 1 + .../payments/components/TpinResetFlow.tsx | 700 ++++++++++++++++++ .../screens/PaymentConfirmationScreen.tsx | 15 + .../payments/screens/ResetTpinScreen.tsx | 46 ++ .../payments/services/tpinService.test.ts | 187 +++++ src/modules/payments/services/tpinService.ts | 73 +- .../payments/utils/tpinValidation.test.ts | 30 + src/modules/payments/utils/tpinValidation.ts | 29 +- src/modules/search/searchCatalog.ts | 11 + .../services/screens/ResetTpinScreen.tsx | 48 ++ .../services/screens/ServicesHubScreen.tsx | 10 + src/navigation/PaymentsNavigator.tsx | 2 + src/navigation/ServicesNavigator.tsx | 2 + src/navigation/featureRoutes.ts | 4 + src/navigation/types.ts | 3 + src/services/api/apiLogger.test.ts | 131 ++++ src/services/api/apiLogger.ts | 98 ++- src/services/api/endpoints.ts | 9 + src/services/api/interceptors.ts | 13 +- src/services/api/serverClock.test.ts | 81 ++ src/services/api/serverClock.ts | 73 ++ src/services/security/mpinCrypto.ts | 2 +- src/types/cbs.ts | 31 + 24 files changed, 1571 insertions(+), 29 deletions(-) create mode 100644 src/modules/payments/components/TpinResetFlow.tsx create mode 100644 src/modules/payments/screens/ResetTpinScreen.tsx create mode 100644 src/modules/payments/services/tpinService.test.ts create mode 100644 src/modules/services/screens/ResetTpinScreen.tsx create mode 100644 src/services/api/apiLogger.test.ts create mode 100644 src/services/api/serverClock.test.ts create mode 100644 src/services/api/serverClock.ts diff --git a/src/constants/featureKeys.ts b/src/constants/featureKeys.ts index 5e3e9e7..11bab9a 100644 --- a/src/constants/featureKeys.ts +++ b/src/constants/featureKeys.ts @@ -184,6 +184,7 @@ export enum FeatureKey { SERVICES_MINI_STATEMENT = 'services.miniStatement', SERVICES_MPIN_CHANGE = 'services.mpinChange', SERVICES_TPIN_CHANGE = 'services.tpinChange', + SERVICES_TPIN_RESET = 'services.tpinReset', SERVICES_FATE_OF_CHEQUE = 'services.fateOfCheque', // --------------------------------------------------------------- Loans ---- diff --git a/src/features/featureConfig.ts b/src/features/featureConfig.ts index d536c01..591a91f 100644 --- a/src/features/featureConfig.ts +++ b/src/features/featureConfig.ts @@ -191,6 +191,7 @@ export const DEFAULT_FEATURE_CONFIG: Record = { }, [FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true }, [FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true }, + [FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' }, // Service-desk entry points to screens the home and profile stacks already // own. Switching one off takes the tile away, not the screen. [FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true }, diff --git a/src/modules/payments/components/TpinResetFlow.tsx b/src/modules/payments/components/TpinResetFlow.tsx new file mode 100644 index 0000000..4a343a8 --- /dev/null +++ b/src/modules/payments/components/TpinResetFlow.tsx @@ -0,0 +1,700 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { Button, Card, InfoBanner, Stepper } from '../../../components'; +import { useTheme } from '../../../theme'; +import { useToast } from '../../../hooks/useToast'; +import { describeError } from '../../../services/api/cbsErrors'; +import { secondsUntilDeadline } from '../../../services/api/serverClock'; +import { toApiError } from '../../../services/api/serviceHelpers'; +import type { ApiError } from '../../../types/api'; +import { isValidOtp } from '../../../utils/validation'; +import { CodeInput } from '../../auth/components/CodeInput'; +import { tpinService } from '../services/tpinService'; +import { TPIN_LENGTH, describeTpinProblem, isAcceptableNewTpin } from '../utils/tpinValidation'; + +interface TpinResetFlowProps { + /** + * The session's registered mobile. Feeds the "not the last four digits of + * your mobile" rule and the masked OTP copy; never sent — the server reads + * it from the session. + */ + mobileNumber?: string; + /** Shown above the first step, e.g. why a transfer sent the customer here. */ + notice?: string; + /** Runs once the new TPIN is active and every secret here is cleared. */ + onSuccess: () => void; + /** Leaves the flow when it cannot go on — no TPIN to reset, or the bank refused. */ + onExit: () => void; + /** + * TPIN_NOT_SET: there is nothing to reset, so offer to create one. Left out + * where Create TPIN is not reachable, and the customer gets Done instead. + */ + onCreateTpin?: () => void; +} + +type Step = 'start' | 'otp' | 'tpin' | 'notSet' | 'stopped'; + +/** How long before "send a new code" is offered. There is no resend endpoint. */ +const RESEND_COOLDOWN_SECONDS = 30; + +const OTP_LENGTH = 6; + +const STEPS: ReadonlyArray<{ key: Step; label: string }> = [ + { key: 'start', label: 'Start' }, + { key: 'otp', label: 'OTP' }, + { key: 'tpin', label: 'New TPIN' }, +]; + +const COPY = { + otpExpired: 'That code expired. Send a new code to continue.', + attemptsExceeded: 'Too many wrong codes. Send a new code to try again.', + requestInvalid: 'This reset request is no longer valid. Send a new code to start again.', + tokenSpent: 'Your time to set a new TPIN ran out. Send a new code to start again.', +}; + +/** + * The initiate refusals that end the flow for good. The shared copy for these + * is written for registration ("No accounts to link"), so a reset gets its + * own; the server's message, when there is one, still takes precedence. + */ +const STOP_COPY: Record = { + CUSTOMER_NOT_FOUND: { + title: 'We could not verify you with your bank', + description: + "Your bank's records do not match this profile, so your TPIN cannot be reset here. Contact your branch.", + }, + NO_ACCOUNTS_FOUND: { + title: 'No accounts found', + description: + 'Your bank returned no accounts for this profile, so your TPIN cannot be reset here. Contact your branch.', + }, + ORG_NOT_CONFIGURED: { + title: 'TPIN reset is unavailable', + description: 'Your bank has not set this service up yet. Contact your branch.', + }, +}; + +const formatCountdown = (seconds: number) => + `${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`; + +const messageFor = (apiError: ApiError) => + apiError.message || describeError(apiError.code).description; + +/** + * Resetting the TPIN, end to end: send OTP → verify OTP → new TPIN. + * + * Unlike creating one, a reset needs no MPIN: the signed-in session is the + * first factor, and initiate goes out with an empty body — the server takes + * org, mobile and device from the session and verifies the customer with the + * bank before any OTP is sent. The session must stay the same for all three + * calls; SESSION_INVALID anywhere signs the customer out (the interceptor does + * that) and this flow drops everything it holds. + * + * Shared by Services' Reset TPIN and the transfer sheet's "Forgot TPIN?", + * which differ only in where the customer goes afterwards — so the screens own + * the navigation, and this owns the flow. + * + * ── WHY LOCAL STATE, AND WHY NO REDUX ─────────────────────────────────────── + * The OTP, the `tpinResetId`, the one-time `tpinResetToken` and the TPIN + * itself must live in memory only, and be gone on back-navigation, restart, + * failure and success. Component state gives exactly that — leaving the screen + * unmounts it — and keeps them out of the Redux store, where dev tooling would + * record them. Each is also dropped the moment its step is done with it: the + * reset id once the OTP is verified, the token once it is spent or expires. + * Nothing here is persisted, logged or sent to analytics. + * + * ── TIME ──────────────────────────────────────────────────────────────────── + * Both countdowns run to deadlines worked out from the server's expiry and the + * server's own clock (see `serverClock`), not the handset's idea of the time. + * Reaching either one ends the attempt here, without waiting for the server to + * refuse a code or token that is already dead. + */ +export const TpinResetFlow = ({ + mobileNumber, + notice, + onSuccess, + onExit, + onCreateTpin, +}: TpinResetFlowProps) => { + const theme = useTheme(); + const toast = useToast(); + const [step, setStep] = useState('start'); + const [otp, setOtp] = useState(''); + const [tpin, setTpin] = useState(''); + const [confirmTpin, setConfirmTpin] = useState(''); + const [busy, setBusy] = useState(false); + /** A failure about the flow as a whole, shown as a banner. */ + const [error, setError] = useState(null); + /** A failure about the field on screen, shown under it. */ + const [fieldError, setFieldError] = useState(null); + /** Why the flow stopped for good, on the `stopped` step. */ + const [stopped, setStopped] = useState<{ title: string; message: string } | null>(null); + + // Secrets of the flow. Screen state only — see the note above. + const [tpinResetId, setTpinResetId] = useState(null); + const [otpDeadline, setOtpDeadline] = useState(null); + const [attemptsRemaining, setAttemptsRemaining] = useState(null); + const [tpinResetToken, setTpinResetToken] = useState(null); + const [tokenDeadline, setTokenDeadline] = useState(null); + + const [now, setNow] = useState(() => Date.now()); + const [cooldown, setCooldown] = useState(0); + const mounted = useRef(true); + /** + * Set synchronously when a call goes out, so a double tap cannot send two + * before `busy` has re-rendered the button disabled. + */ + const inFlight = useRef(false); + + useEffect( + () => () => { + mounted.current = false; + }, + [], + ); + + // One tick drives both countdowns and the resend cooldown. + useEffect(() => { + if (step !== 'otp' && step !== 'tpin') { + return; + } + + const timer = setInterval(() => { + setNow(Date.now()); + setCooldown((value) => (value > 0 ? value - 1 : 0)); + }, 1000); + + return () => clearInterval(timer); + }, [step]); + + const clearSecrets = useCallback(() => { + setTpinResetId(null); + setOtpDeadline(null); + setAttemptsRemaining(null); + setTpinResetToken(null); + setTokenDeadline(null); + setOtp(''); + setTpin(''); + setConfirmTpin(''); + }, []); + + /** + * Throws the whole attempt away and returns to the start. An OTP can only be + * reissued by a fresh initiate, which is one tap from there. + */ + const restart = useCallback( + (message?: string) => { + clearSecrets(); + setFieldError(null); + setStep('start'); + setError(message ?? null); + }, + [clearSecrets], + ); + + /** TPIN_NOT_SET: nothing to reset, so the flow stops and offers Create. */ + const endAsNotSet = useCallback(() => { + clearSecrets(); + setFieldError(null); + setError(null); + setStep('notSet'); + }, [clearSecrets]); + + /** The bank cannot match or serve this customer; retrying will not change that. */ + const endAsStopped = useCallback( + (apiError: ApiError) => { + const copy = (apiError.code && STOP_COPY[apiError.code]) || describeError(apiError.code); + + clearSecrets(); + setFieldError(null); + setError(null); + setStopped({ title: copy.title, message: apiError.message || copy.description }); + setStep('stopped'); + }, + [clearSecrets], + ); + + /** + * The default failure path. VALIDATION_ERROR's field map is split: the entry + * for the field on screen goes under it, the rest go in the banner. Any + * other failure is a banner with the server's message. + */ + const showFailure = useCallback((apiError: ApiError, field: string) => { + const fields = apiError.fieldErrors ?? {}; + const own = fields[field]; + const others = Object.entries(fields) + .filter(([key]) => key !== field) + .map(([, message]) => message); + + setFieldError(own ?? null); + setError(others.length > 0 ? others.join('\n') : own ? null : messageFor(apiError)); + }, []); + + // A code or token that has run out is dead whatever the server would say, so + // the attempt ends here. Never mid-call: a confirm sent a moment before the + // deadline may still be succeeding, and its answer decides what happens. + useEffect(() => { + if (inFlight.current) { + return; + } + + if (step === 'otp' && secondsUntilDeadline(otpDeadline, now) === 0) { + restart(COPY.otpExpired); + } else if (step === 'tpin' && secondsUntilDeadline(tokenDeadline, now) === 0) { + restart(COPY.tokenSpent); + } + }, [now, otpDeadline, restart, step, tokenDeadline]); + + /* ── Step 1: initiate — also the resend ───────────────────────────────── */ + + const handleInitiate = useCallback(async () => { + if (inFlight.current) { + return; + } + + inFlight.current = true; + setBusy(true); + setError(null); + setFieldError(null); + // A new code replaces any code already sent. + setOtp(''); + + try { + const result = await tpinService.initiateReset(); + + if (!mounted.current) return; + + setTpinResetId(result.tpinResetId); + setOtpDeadline(result.otpDeadline); + setAttemptsRemaining(result.attemptsRemaining); + setCooldown(RESEND_COOLDOWN_SECONDS); + setNow(Date.now()); + setStep('otp'); + toast('Code sent', 'success', 'Enter the 6-digit code we sent to your registered mobile.'); + } catch (caught) { + if (!mounted.current) return; + + const apiError = toApiError(caught); + + switch (apiError.code) { + case 'SESSION_INVALID': + // The interceptor has already signed the customer out. + clearSecrets(); + return; + case 'TPIN_NOT_SET': + endAsNotSet(); + return; + case 'CUSTOMER_NOT_FOUND': + case 'NO_ACCOUNTS_FOUND': + case 'ORG_NOT_CONFIGURED': + endAsStopped(apiError); + return; + default: + // CBS_UNAVAILABLE, CBS_ERROR, CONNECTOR_UNAVAILABLE, + // OTP_PROVIDER_UNAVAILABLE, SMS_DELIVERY_FAILED and the rest: back to + // the start with the server's message, where Send code retries. A + // failed resend lands here too — the code it replaced is abandoned. + clearSecrets(); + setStep('start'); + setError(messageFor(apiError)); + } + } finally { + inFlight.current = false; + if (mounted.current) setBusy(false); + } + }, [clearSecrets, endAsNotSet, endAsStopped, toast]); + + /* ── Step 2: verify OTP ───────────────────────────────────────────────── */ + + const handleVerifyOtp = useCallback(async () => { + if (!tpinResetId || !isValidOtp(otp) || inFlight.current) { + return; + } + + if (secondsUntilDeadline(otpDeadline) === 0) { + restart(COPY.otpExpired); + return; + } + + inFlight.current = true; + setBusy(true); + setError(null); + setFieldError(null); + + try { + const result = await tpinService.verifyResetOtp({ tpinResetId, otp }); + + if (!mounted.current) return; + + // Only the token goes forward; the OTP and the reset id are done with. + setOtp(''); + setTpinResetId(null); + setOtpDeadline(null); + setAttemptsRemaining(null); + setTpinResetToken(result.tpinResetToken); + setTokenDeadline(result.tokenDeadline); + setNow(Date.now()); + setStep('tpin'); + } catch (caught) { + if (!mounted.current) return; + + const apiError = toApiError(caught); + setOtp(''); + + switch (apiError.code) { + case 'SESSION_INVALID': + clearSecrets(); + return; + case 'TPIN_NOT_SET': + endAsNotSet(); + return; + // An expired, exhausted or voided request cannot be retried — a fresh + // initiate can. + case 'OTP_EXPIRED': + restart(COPY.otpExpired); + return; + case 'OTP_ATTEMPTS_EXCEEDED': + restart(COPY.attemptsExceeded); + return; + case 'OTP_NOT_VERIFIED': + restart(COPY.requestInvalid); + return; + case 'OTP_INVALID': { + const remaining = + attemptsRemaining === null ? null : Math.max(0, attemptsRemaining - 1); + + // Retry only while attempts remain; with none left the request is + // spent and only a new code will do. + if (remaining === 0) { + restart(COPY.attemptsExceeded); + return; + } + + setAttemptsRemaining(remaining); + setFieldError( + remaining + ? `That code is incorrect. ${remaining} ${remaining === 1 ? 'attempt' : 'attempts'} left.` + : 'That code is incorrect.', + ); + return; + } + default: + // VALIDATION_ERROR paints its `otp` entry under the field. + showFailure(apiError, 'otp'); + } + } finally { + inFlight.current = false; + if (mounted.current) setBusy(false); + } + }, [ + attemptsRemaining, + clearSecrets, + endAsNotSet, + otp, + otpDeadline, + restart, + showFailure, + tpinResetId, + ]); + + /* ── Step 3: encrypt the new TPIN and confirm ─────────────────────────── */ + + const handleConfirm = useCallback(async () => { + if ( + !tpinResetToken || + !isAcceptableNewTpin(tpin, mobileNumber) || + confirmTpin !== tpin || + inFlight.current + ) { + return; + } + + // An expired token is refused anyway; say so without the round trip. + if (secondsUntilDeadline(tokenDeadline) === 0) { + restart(COPY.tokenSpent); + return; + } + + inFlight.current = true; + setBusy(true); + setError(null); + setFieldError(null); + + try { + // Fetches the current public key and encrypts {tpin, nonce, ts} fresh. + await tpinService.confirmReset({ tpinResetToken, tpin }); + + if (!mounted.current) return; + + clearSecrets(); + toast('TPIN reset', 'success', 'Use your new TPIN to authorise fund transfers.'); + onSuccess(); + } catch (caught) { + if (!mounted.current) return; + + const apiError = toApiError(caught); + setTpin(''); + setConfirmTpin(''); + + switch (apiError.code) { + case 'SESSION_INVALID': + clearSecrets(); + return; + case 'TPIN_NOT_SET': + endAsNotSet(); + return; + // A spent or expired token: this attempt is over. + case 'OTP_NOT_VERIFIED': + case 'OTP_EXPIRED': + case 'OTP_ATTEMPTS_EXCEEDED': + restart(COPY.tokenSpent); + return; + case 'INTERNAL_ERROR': + // Nothing sensitive is kept past an unexplained failure — token + // included. The customer starts again with a new code. + restart(messageFor(apiError)); + return; + case 'VALIDATION_ERROR': + // A policy refusal — a sequence, the mobile's last four, the date of + // birth, the current or a recent TPIN — comes as a message with no + // field map. It is about the TPIN, so it goes under the TPIN, and the + // same token takes a different one. + if (!apiError.fieldErrors) { + setFieldError(messageFor(apiError)); + return; + } + + showFailure(apiError, 'encryptedTpin'); + return; + default: + // MALFORMED_REQUEST has already been retried once with a new key and + // payload; Reset TPIN again builds another. The token stays. + showFailure(apiError, 'encryptedTpin'); + } + } finally { + inFlight.current = false; + if (mounted.current) setBusy(false); + } + }, [ + clearSecrets, + confirmTpin, + endAsNotSet, + mobileNumber, + onSuccess, + restart, + showFailure, + toast, + tokenDeadline, + tpin, + tpinResetToken, + ]); + + /* ── render ───────────────────────────────────────────────────────────── */ + + if (step === 'notSet') { + return ( + + + + {onCreateTpin ?