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 <noreply@anthropic.com>
This commit is contained in:
Abin Toppo
2026-09-30 18:41:03 +05:30
co-authored by Claude Opus 5.5
parent f1def81eb7
commit 5fbd13ba12
24 changed files with 1571 additions and 29 deletions
+131
View File
@@ -0,0 +1,131 @@
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { apiLogger, redactSecrets } from './apiLogger';
describe('redactSecrets', () => {
it('hides OTPs, PINs, encrypted payloads and every kind of token', () => {
expect(
redactSecrets({
otp: '123456',
debugOtp: '123456',
mpin: '2580',
tpin: '5826',
encryptedMpin: 'base64…',
encryptedTpin: 'base64…',
sessionToken: 'live-session',
setupToken: 'setup',
resetToken: 'mpin-reset',
tpinCreateToken: 'create',
tpinResetToken: 'reset',
}),
).toEqual({
otp: '<redacted>',
debugOtp: '<redacted>',
mpin: '<redacted>',
tpin: '<redacted>',
encryptedMpin: '<redacted>',
encryptedTpin: '<redacted>',
sessionToken: '<redacted>',
setupToken: '<redacted>',
resetToken: '<redacted>',
tpinCreateToken: '<redacted>',
tpinResetToken: '<redacted>',
});
});
it('keeps the fields that help debugging — expiries, statuses, ids', () => {
const data = {
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
status: 'PENDING_OTP',
otpExpiresAt: '2026-09-30T13:50:31+05:30',
tokenExpiresAt: '2026-09-30T13:51:02+05:30',
resetTokenExpiresAt: '2026-09-30T13:51:02+05:30',
attemptsRemaining: 3,
mpinStatus: 'ACTIVE',
};
expect(redactSecrets(data)).toEqual(data);
});
it('reaches into the envelope and into arrays', () => {
expect(
redactSecrets({
success: true,
data: { tpinResetToken: 'reset', items: [{ otp: '1' }, { name: 'x' }] },
}),
).toEqual({
success: true,
data: { tpinResetToken: '<redacted>', items: [{ otp: '<redacted>' }, { name: 'x' }] },
});
});
it('leaves an absent secret visibly absent, and non-objects alone', () => {
expect(redactSecrets({ debugOtp: null })).toEqual({ debugOtp: null });
expect(redactSecrets('(no body)')).toBe('(no body)');
expect(redactSecrets(undefined)).toBeUndefined();
});
});
describe('apiLogger', () => {
let log: jest.SpyInstance;
let warn: jest.SpyInstance;
beforeEach(() => {
log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
log.mockRestore();
warn.mockRestore();
});
const config = (data: unknown) =>
({
method: 'post',
baseURL: 'https://bank.example/api/v1',
url: '/tpin/reset/confirm',
headers: { Authorization: 'Bearer live-session-token', 'Content-Type': 'application/json' },
data,
}) as unknown as InternalAxiosRequestConfig;
it('prints the curl without the session token or the secrets in the body', () => {
apiLogger.request(config({ tpinResetToken: 'one-time-reset-token', encryptedTpin: 'cipher' }));
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
expect(printed).toContain('/tpin/reset/confirm');
expect(printed).toContain('Authorization: Bearer <session token>');
expect(printed).not.toContain('live-session-token');
expect(printed).not.toContain('one-time-reset-token');
expect(printed).not.toContain('cipher');
});
it('redacts a body the caller serialised themselves', () => {
apiLogger.request(config(JSON.stringify({ tpinResetId: 'id-1', otp: '654321' })));
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
expect(printed).toContain('id-1');
expect(printed).not.toContain('654321');
});
it('redacts secrets in what came back, success or failure', () => {
const request = config({});
apiLogger.response({
status: 200,
config: request,
data: { success: true, data: { tpinResetToken: 'one-time-reset-token' } },
} as AxiosResponse);
apiLogger.failure({
status: 400,
config: request,
data: { success: false, data: { otp: '654321' }, errorCode: 'VALIDATION_ERROR' },
} as AxiosResponse);
const printed = [...log.mock.calls, ...warn.mock.calls]
.map((call) => String(call[0]))
.join('\n');
expect(printed).toContain('VALIDATION_ERROR');
expect(printed).not.toContain('one-time-reset-token');
expect(printed).not.toContain('654321');
});
});
+82 -16
View File
@@ -3,23 +3,88 @@ import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axio
/**
* Dev-only request/response logging for the CBS client.
*
* Every request is printed as a runnable `curl`, so a failure can be reproduced
* against the backend outside the app — which is usually the fastest way to
* settle whether a problem is the handset, the client, or the server.
* Every request is printed as a `curl`, so a failure can be reproduced against
* the backend outside the app — which is usually the fastest way to settle
* whether a problem is the handset, the client, or the server.
*
* ── WHY THIS IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ───────────────────────
* The curl carries whatever the request carried: the `Authorization` header
* with a live session token, and the RSA-encrypted MPIN blob on the
* registration calls. That is exactly what makes it useful, and exactly why it
* must never run in a release build — a bank's production logs are not a place
* to put a working credential. There is deliberately no runtime flag to turn
* this on outside development; `__DEV__` is compiled out, so none of this code
* ships.
* ── SECRETS ARE REDACTED, EVEN HERE ─────────────────────────────────────────
* The bank's integration contracts forbid logging the session token, OTPs,
* PINs, encrypted PIN payloads and one-time tokens anywhere — the console
* included — and Metro logs get pasted into tickets and chats. So the
* `Authorization` header prints as a placeholder, and any body field named like
* a secret prints as `<redacted>`, on the way out and on the way back. The rest
* of the curl still runs: paste a session token in place of the placeholder.
*
* ── AND IT IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ─────────────────────────
* Redaction is by field name, so it is a safety net rather than a guarantee.
* The log must still never run in a release build: there is deliberately no
* runtime flag to turn it on outside development, and `__DEV__` is compiled
* out, so none of this code ships.
*/
/** Set false to silence the log without unpicking the interceptors. */
const ENABLED = __DEV__;
/**
* Body fields never printed: OTPs (a dev `debugOtp` included), plain and
* encrypted PINs, and every token — session, setup, MPIN reset, TPIN create
* and reset. Their `…ExpiresAt` companions stay: when a code expired is often
* the whole question.
*/
const SECRET_FIELD = /^(otp|debugOtp|mpin|tpin|password)$|^encrypted|token$/i;
const REDACTED = '<redacted>';
/** What an `Authorization` header prints as. Paste a real token to run the curl. */
const AUTHORIZATION_PLACEHOLDER = 'Bearer <session token>';
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
/**
* A copy of `value` with every secret-named field replaced, at any depth.
* Anything that is not a plain object or array — FormData, a Blob — is left
* as it is.
*/
export const redactSecrets = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(redactSecrets);
}
if (!isPlainObject(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, field]) => [
key,
SECRET_FIELD.test(key) && field !== null && field !== undefined
? REDACTED
: redactSecrets(field),
]),
);
};
/** A JSON string, redacted; any other string as it came. */
const redactString = (value: string): string => {
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === 'object' && parsed !== null
? JSON.stringify(redactSecrets(parsed))
: value;
} catch {
return value;
}
};
/**
* Per-request bookkeeping, so a response can be matched to its request in a
* busy log and timed.
@@ -109,11 +174,11 @@ const readBody = (config: InternalAxiosRequestConfig): string | undefined => {
}
if (typeof data === 'string') {
return data;
return redactString(data);
}
try {
return JSON.stringify(data);
return JSON.stringify(redactSecrets(data));
} catch {
return String(data);
}
@@ -124,7 +189,8 @@ const toCurl = (config: InternalAxiosRequestConfig): string => {
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
for (const [key, value] of Object.entries(readHeaders(config))) {
lines.push(`-H ${quote(`${key}: ${value}`)}`);
const shown = key.toLowerCase() === 'authorization' ? AUTHORIZATION_PLACEHOLDER : value;
lines.push(`-H ${quote(`${key}: ${shown}`)}`);
}
const body = readBody(config);
@@ -194,7 +260,7 @@ export const apiLogger = {
console.log(
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
response.config as InternalAxiosRequestConfig,
)} (${elapsed(response.config)})\n${pretty(response.data)}\n└──`,
)} (${elapsed(response.config)})\n${pretty(redactSecrets(response.data))}\n└──`,
);
},
@@ -212,7 +278,7 @@ export const apiLogger = {
)} (${elapsed(response?.config)})`;
console.warn(
`\n${head}${note ? `\n${note}` : ''}\n${pretty(response?.data ?? '(no body)')}\n└──`,
`\n${head}${note ? `\n${note}` : ''}\n${pretty(redactSecrets(response?.data ?? '(no body)'))}\n└──`,
);
},
+9
View File
@@ -41,11 +41,20 @@ export const endpoints = {
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
* resend.
*
* Resetting it follows the same three steps under `/tpin/reset`, with two
* differences: `resetInitiate` takes an empty body — the session alone
* identifies the customer, and the bank re-verifies them before the OTP goes
* out — and it needs a TPIN to already be active. The same session must carry
* all three calls; the server refuses a flow that changes user or device.
*/
tpin: {
createInitiate: '/tpin/create/initiate',
createVerifyOtp: '/tpin/create/verify-otp',
createConfirm: '/tpin/create/confirm',
resetInitiate: '/tpin/reset/initiate',
resetVerifyOtp: '/tpin/reset/verify-otp',
resetConfirm: '/tpin/reset/confirm',
},
accounts: {
/**
+10 -3
View File
@@ -5,6 +5,7 @@ import { sessionManager } from '../security/sessionManager';
import { secureStorage } from '../storage/secureStorage';
import { apiLogger } from './apiLogger';
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
import { rememberServerTime } from './serverClock';
const unauthorizedHandlers = new Set<() => void>();
@@ -83,8 +84,9 @@ export const attachInterceptors = (client: AxiosInstance) => {
config.headers = nextHeaders as never;
}
// Logged here, at the end of the interceptor, so the curl carries the
// Authorization header the request will actually be sent with.
// Logged here, at the end of the interceptor, so the curl shows the
// headers the request will actually be sent with — the token itself as a
// placeholder.
apiLogger.request(config);
return config;
@@ -117,7 +119,12 @@ export const attachInterceptors = (client: AxiosInstance) => {
}
// Unwrap so callers read the payload directly rather than the envelope.
return { ...response, data: envelope.data };
// The server's `timestamp` is kept aside for anything counting down to
// a server expiry — see `serverClock`.
const unwrapped = { ...response, data: envelope.data };
rememberServerTime(unwrapped, envelope.timestamp);
return unwrapped;
},
async (error: AxiosError) => {
if (error instanceof CbsApiError) {
+81
View File
@@ -0,0 +1,81 @@
import {
parseServerInstant,
rememberServerTime,
secondsUntilDeadline,
serverTimeOf,
toLocalDeadline,
} from './serverClock';
describe('parseServerInstant', () => {
it('reads the backend timestamp, seven fractional digits and an offset included', () => {
expect(parseServerInstant('2026-09-30T13:50:31.1653828+05:30')).toBe(
Date.UTC(2026, 8, 30, 8, 20, 31, 165),
);
});
it('reads timestamps with fewer fractional digits, none, or a Z', () => {
expect(parseServerInstant('2026-09-30T08:20:31.5Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 500));
expect(parseServerInstant('2026-09-30T13:50:31+05:30')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31));
expect(parseServerInstant('2026-09-30T08:20:31.123Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 123));
});
it('gives null for nothing, or for something that is not a date', () => {
expect(parseServerInstant(null)).toBeNull();
expect(parseServerInstant(undefined)).toBeNull();
expect(parseServerInstant('')).toBeNull();
expect(parseServerInstant('soon')).toBeNull();
});
});
describe('toLocalDeadline', () => {
// The initiate response from the TPIN reset guide: five minutes to go.
const serverNow = '2026-09-30T13:45:31.1653828+05:30';
const otpExpiresAt = '2026-09-30T13:50:31.1653828+05:30';
it('counts the server’s time left from when the answer arrived, ignoring the device clock', () => {
// A handset an hour and a bit fast. The deadline still lands five minutes
// after the answer arrived, not five minutes after 13:45 server time.
const receivedAt = Date.UTC(2026, 8, 30, 9, 30, 0);
expect(toLocalDeadline(otpExpiresAt, serverNow, receivedAt)).toBe(receivedAt + 5 * 60 * 1000);
});
it('takes the expiry at face value when the server sent no timestamp', () => {
expect(toLocalDeadline(otpExpiresAt, undefined, 0)).toBe(parseServerInstant(otpExpiresAt));
expect(toLocalDeadline(otpExpiresAt, 'garbage', 0)).toBe(parseServerInstant(otpExpiresAt));
});
it('has no deadline without an expiry', () => {
expect(toLocalDeadline(null, serverNow)).toBeNull();
});
});
describe('secondsUntilDeadline', () => {
it('counts down in whole seconds and stops at zero', () => {
expect(secondsUntilDeadline(10_000, 0)).toBe(10);
expect(secondsUntilDeadline(10_000, 9_700)).toBe(0);
expect(secondsUntilDeadline(10_000, 60_000)).toBe(0);
});
it('is unknown when the deadline is', () => {
expect(secondsUntilDeadline(null, 0)).toBeNull();
});
});
describe('rememberServerTime', () => {
it('files the envelope timestamp against the response it came with', () => {
const response = {};
rememberServerTime(response, '2026-09-30T13:45:31+05:30');
expect(serverTimeOf(response)).toBe('2026-09-30T13:45:31+05:30');
expect(serverTimeOf({})).toBeUndefined();
});
it('ignores a missing or non-string timestamp', () => {
const response = {};
rememberServerTime(response, undefined);
rememberServerTime(response, 1790000000);
expect(serverTimeOf(response)).toBeUndefined();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The server's clock, as each response reported it.
*
* Every envelope carries a `timestamp` — the server's "now" when it answered.
* The interceptor unwraps the envelope, so it files that reading here, keyed on
* the unwrapped response it hands back, rather than adding a field to axios's
* response type. The entry goes when axios drops the response.
*
* Why it matters: an OTP or a one-time token comes back with an absolute
* `expiresAt`. Counting down to that against the handset's clock is wrong by
* however far the handset's clock is off — minutes, on a phone with automatic
* time switched off. The gap between `expiresAt` and the server's own
* `timestamp` is the true time left, whatever the device thinks the time is.
*/
const serverTimes = new WeakMap<object, string>();
/** Called by the interceptor with the envelope's `timestamp`. */
export const rememberServerTime = (response: object, timestamp: unknown) => {
if (typeof timestamp === 'string' && timestamp.length > 0) {
serverTimes.set(response, timestamp);
}
};
/** The server's `timestamp` for this response, when it sent one. */
export const serverTimeOf = (response: object): string | undefined => serverTimes.get(response);
/**
* Epoch milliseconds from the backend's ISO-8601 offset timestamps.
*
* Java writes up to nine fractional digits (`13:50:31.1653828+05:30`), and the
* ECMAScript date format only promises three. V8 is lenient about the rest;
* other engines need not be — so the fraction is cut to exactly three first.
*/
export const parseServerInstant = (iso: string | null | undefined): number | null => {
if (!iso) {
return null;
}
const normalised = iso.replace(/\.(\d+)/, (_match, fraction: string) =>
`.${`${fraction}00`.slice(0, 3)}`,
);
const ms = Date.parse(normalised);
return Number.isFinite(ms) ? ms : null;
};
/**
* A server expiry as a deadline on this device's clock: the time the server
* says is left, counted from when the answer arrived. Only the elapsed time is
* measured locally, so a wrong device clock does not move the deadline.
*
* Without the server's `timestamp` the expiry is taken at face value — which is
* right exactly when the device clock is.
*/
export const toLocalDeadline = (
expiresAt: string | null | undefined,
serverNow: string | null | undefined,
receivedAt: number = Date.now(),
): number | null => {
const expiry = parseServerInstant(expiresAt);
if (expiry === null) {
return null;
}
const now = parseServerInstant(serverNow);
return now === null ? expiry : receivedAt + (expiry - now);
};
/** Whole seconds until a local deadline, never below zero. `null` when unknown. */
export const secondsUntilDeadline = (deadline: number | null, now: number = Date.now()) =>
deadline === null ? null : Math.max(0, Math.round((deadline - now) / 1000));
+1 -1
View File
@@ -106,7 +106,7 @@ export const mpinCrypto = {
return this.encryptPin('mpin', mpin);
},
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
/** Travels as `encryptedTpin`: TPIN creation and reset, and every fund transfer. */
encryptTpin(tpin: string): Promise<string> {
return this.encryptPin('tpin', tpin);
},