Add TPIN creation, TPIN-authorised transfers, MPIN reset and transfer history
- Create TPIN: current MPIN -> OTP -> encrypted TPIN via /tpin/create/*, from Services and from the transfer screen on TPIN_NOT_SET - IMPS initiate now sends encryptedTpin instead of encryptedMpin; handles INVALID_TPIN and TPIN_NOT_SET - Share one RSA-OAEP encryption and key-retry path for MPIN and TPIN - Forgot MPIN on sign-in and Reset MPIN in the Security Center - Fund transfer history list and detail, reachable from Home - Brand logo on sign-in and palette updates Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abb14d5768
commit
f1def81eb7
@@ -9,9 +9,26 @@ import {
|
||||
} from './cbsErrors';
|
||||
|
||||
describe('error code catalogue', () => {
|
||||
it('covers all 48 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(48);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(48);
|
||||
it('covers all 52 documented codes, with no duplicates', () => {
|
||||
expect(CBS_ERROR_CODES).toHaveLength(52);
|
||||
expect(new Set(CBS_ERROR_CODES).size).toBe(52);
|
||||
});
|
||||
|
||||
it('knows the TPIN codes, so none of them collapses into UNKNOWN_ERROR', () => {
|
||||
// The transfer and create-TPIN screens branch on each of these: a wrong
|
||||
// TPIN keeps the sheet up, a missing one sends the customer to create it,
|
||||
// and an existing one ends the create flow.
|
||||
expect(isCbsErrorCode('INVALID_TPIN')).toBe(true);
|
||||
expect(isCbsErrorCode('TPIN_NOT_SET')).toBe(true);
|
||||
expect(isCbsErrorCode('TPIN_ALREADY_SET')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not invite a retry on a TPIN refusal', () => {
|
||||
// A wrong TPIN is the customer's to re-enter, not the identical request's
|
||||
// to repeat; the other two are states a retry cannot change.
|
||||
expect(isRetryableError('INVALID_TPIN')).toBe(false);
|
||||
expect(isRetryableError('TPIN_NOT_SET')).toBe(false);
|
||||
expect(isRetryableError('TPIN_ALREADY_SET')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not invite a retry when the bank withholds a balance', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const CBS_ERROR_CODES = [
|
||||
'OTP_EXPIRED',
|
||||
'BENEFICIARY_DETAILS_INCOMPLETE',
|
||||
'INVALID_CREDENTIALS',
|
||||
'INVALID_TPIN',
|
||||
'SESSION_INVALID',
|
||||
'DEVICE_NOT_BOUND',
|
||||
'RESOURCE_NOT_FOUND',
|
||||
@@ -30,6 +31,8 @@ export const CBS_ERROR_CODES = [
|
||||
'OTP_NOT_VERIFIED',
|
||||
'MPIN_ALREADY_SET',
|
||||
'MPIN_NOT_SET',
|
||||
'TPIN_ALREADY_SET',
|
||||
'TPIN_NOT_SET',
|
||||
'BENEFICIARY_ALREADY_ADDED',
|
||||
'POSITIVE_PAY_NOT_AVAILABLE',
|
||||
'CHEQUE_ALREADY_REGISTERED',
|
||||
@@ -40,6 +43,7 @@ export const CBS_ERROR_CODES = [
|
||||
'CHEQUE_BOOK_REQUEST_DECLINED',
|
||||
'MPIN_LOCKED',
|
||||
'OTP_ATTEMPTS_EXCEEDED',
|
||||
'TRANSACTION_NOT_FOUND',
|
||||
'INTERNAL_ERROR',
|
||||
'CBS_CONNECTOR_NOT_SUPPORTED',
|
||||
'MMID_ALLOCATION_FAILED',
|
||||
@@ -121,6 +125,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Sign-in failed',
|
||||
description: 'Check your mobile number and MPIN, then try again.',
|
||||
},
|
||||
INVALID_TPIN: {
|
||||
title: 'Incorrect TPIN',
|
||||
description: 'That TPIN is not right. Check it and try again.',
|
||||
},
|
||||
SESSION_INVALID: {
|
||||
title: 'Session ended',
|
||||
description: 'Please sign in again to continue securely.',
|
||||
@@ -209,6 +217,14 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Registration unfinished',
|
||||
description: 'Finish setting your MPIN to start banking.',
|
||||
},
|
||||
TPIN_ALREADY_SET: {
|
||||
title: 'You already have a TPIN',
|
||||
description: 'Your TPIN is active. Use it to authorise fund transfers.',
|
||||
},
|
||||
TPIN_NOT_SET: {
|
||||
title: 'Create your TPIN first',
|
||||
description: 'Transfers are authorised with a TPIN. Create one, then try the transfer again.',
|
||||
},
|
||||
BENEFICIARY_ALREADY_ADDED: {
|
||||
title: 'Payee already saved',
|
||||
description: 'This payee is already in your list.',
|
||||
@@ -260,6 +276,10 @@ const ERROR_COPY: Record<AnyErrorCode, ErrorCopy> = {
|
||||
title: 'Too many wrong codes',
|
||||
description: 'Start again to get a new code.',
|
||||
},
|
||||
TRANSACTION_NOT_FOUND: {
|
||||
title: 'Transaction not found',
|
||||
description: 'This transfer is not on your profile any more. Pull to refresh your history.',
|
||||
},
|
||||
INTERNAL_ERROR: {
|
||||
title: 'Something went wrong at the bank',
|
||||
description: 'Please try again in a moment.',
|
||||
|
||||
@@ -8,6 +8,18 @@ export const endpoints = {
|
||||
crypto: {
|
||||
publicKey: '/crypto/public-key',
|
||||
},
|
||||
/**
|
||||
* Forgot MPIN. Unauthenticated, like registration — the customer has no MPIN
|
||||
* to open a session with. `initiate` checks the customer with the CBS and
|
||||
* sends an OTP; `verifyOtp` swaps the OTP for a one-time reset token;
|
||||
* `confirm` spends that token on the new, encrypted MPIN and revokes every
|
||||
* open session. There is no resend endpoint: a fresh `initiate` is the resend.
|
||||
*/
|
||||
mpinReset: {
|
||||
initiate: '/mpin-reset/initiate',
|
||||
verifyOtp: '/mpin-reset/verify-otp',
|
||||
confirm: '/mpin-reset/confirm',
|
||||
},
|
||||
/**
|
||||
* Server-driven feature switches. Deliberately callable without a session —
|
||||
* the sign-in screen's own controls are gated, so this has to resolve before
|
||||
@@ -21,6 +33,20 @@ export const endpoints = {
|
||||
me: '/auth/me',
|
||||
logout: '/auth/logout',
|
||||
},
|
||||
/**
|
||||
* Creating the Transaction PIN that authorises fund transfers. Signed-in
|
||||
* only: org, customer, mobile, device and profile all come from the session,
|
||||
* so none of them go in a body. `createInitiate` re-checks the current MPIN
|
||||
* and sends an OTP; `createVerifyOtp` swaps the OTP for a one-time create
|
||||
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
|
||||
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
|
||||
* resend.
|
||||
*/
|
||||
tpin: {
|
||||
createInitiate: '/tpin/create/initiate',
|
||||
createVerifyOtp: '/tpin/create/verify-otp',
|
||||
createConfirm: '/tpin/create/confirm',
|
||||
},
|
||||
accounts: {
|
||||
/**
|
||||
* Every account on the signed-in profile. No parameters — the profile comes
|
||||
@@ -78,6 +104,16 @@ export const endpoints = {
|
||||
initiate: '/fund-transfers/initiate',
|
||||
verifyOtp: (transferId: string) => `/fund-transfers/${transferId}/verify-otp`,
|
||||
},
|
||||
/**
|
||||
* The customer's own IMPS transfers, as the server recorded them — the status
|
||||
* authority for how each one ended. The list is paginated and filterable by
|
||||
* status, mode and an inclusive `fromDate`/`toDate`; the detail takes a
|
||||
* `transferId` from the list. Payee identifiers arrive already masked.
|
||||
*/
|
||||
fundTransferHistory: {
|
||||
list: '/fund-transfer-history',
|
||||
detail: (transferId: string) => `/fund-transfer-history/${transferId}`,
|
||||
},
|
||||
dashboard: {
|
||||
summary: '/dashboard/summary',
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import forge from 'node-forge';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { apiClient } from '../api/apiClient';
|
||||
import { CbsApiError } from '../api/cbsErrors';
|
||||
import { CbsApiError, isCbsApiError } from '../api/cbsErrors';
|
||||
import { endpoints } from '../api/endpoints';
|
||||
import type { PublicKeyResult } from '../../types/cbs';
|
||||
|
||||
@@ -15,6 +15,28 @@ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* `MALFORMED_REQUEST` on an MPIN or TPIN call is what a stale or wrong public
|
||||
* key looks like from the client — the server will not say more, because
|
||||
* saying more would leak whether the profile exists. The key is fetched fresh
|
||||
* on every encryption, so a retry picks up a rotated key; try exactly once
|
||||
* more. A second failure is a real problem worth surfacing.
|
||||
*
|
||||
* `call` must encrypt inside itself, so the retry sends a fresh ciphertext
|
||||
* rather than the same one twice.
|
||||
*/
|
||||
export const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||
return call();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const mpinCrypto = {
|
||||
/**
|
||||
* The server's *current* key, fetched fresh on every call — the backend
|
||||
@@ -35,7 +57,7 @@ export const mpinCrypto = {
|
||||
throw new CbsApiError({
|
||||
status: 0,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: 'Could not fetch MPIN encryption key',
|
||||
message: 'Could not fetch the PIN encryption key',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +65,9 @@ export const mpinCrypto = {
|
||||
},
|
||||
|
||||
/**
|
||||
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
|
||||
* public key, and the ciphertext travels as `encryptedMpin`.
|
||||
* Neither PIN ever leaves the device in the clear. Both are wrapped as
|
||||
* `{ [field]: pin, nonce, ts }` and RSA-OAEP encrypted to the server's
|
||||
* public key — one path for both, so the rules below cannot drift apart.
|
||||
*
|
||||
* Three things break integrations here, all of them silently:
|
||||
*
|
||||
@@ -58,14 +81,14 @@ export const mpinCrypto = {
|
||||
* - `nonce` must be fresh on every single call, or it is refused as a replay.
|
||||
*
|
||||
* The result is single-use and must be sent within 120 seconds: call this
|
||||
* immediately before each request, and never reuse one ciphertext for a
|
||||
* login and a transfer.
|
||||
* immediately before each request, and never reuse one ciphertext for two
|
||||
* requests — even when the digits are the same.
|
||||
*/
|
||||
async encryptMpin(mpin: string): Promise<string> {
|
||||
async encryptPin(field: 'mpin' | 'tpin', pin: string): Promise<string> {
|
||||
const publicKey = await this.getPublicKey();
|
||||
|
||||
const payload = JSON.stringify({
|
||||
mpin,
|
||||
[field]: pin,
|
||||
nonce: uuidv4(),
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
@@ -77,4 +100,14 @@ export const mpinCrypto = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/** Travels as `encryptedMpin`: sign-in, MPIN setup and reset, TPIN creation. */
|
||||
encryptMpin(mpin: string): Promise<string> {
|
||||
return this.encryptPin('mpin', mpin);
|
||||
},
|
||||
|
||||
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
|
||||
encryptTpin(tpin: string): Promise<string> {
|
||||
return this.encryptPin('tpin', tpin);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user